diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1941046 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,18 @@ +* text=auto + +# Shell-скрипты исполняются в Linux-контейнерах и на серверах: CRLF в шебанге +# ломает exec (например, postgres init-runtime-roles.sh на Windows-чекауте). +*.sh text eol=lf +chatballs text eol=lf + +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.zip binary diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..751754d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,123 @@ +# Публикация образов Chatballs в GitHub Container Registry. +# +# Смысл: человек не должен собирать продукт, чтобы его запустить. По тегу vX.Y.Z +# workflow собирает backend и frontend, публикует их в ghcr.io и прикладывает к +# релизу release.env — файл со ссылками на образы по digest. С ним запуск +# сводится к «docker compose up» без единой сборки. +# +# Ничего настраивать не нужно: путь образов выводится из github.repository, +# публикация идёт встроенным GITHUB_TOKEN. + +name: release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + version: + description: "Версия релиза (например 1.0.0)" + required: true + +permissions: + contents: write + packages: write + +env: + APP_DIR: . + REGISTRY: ghcr.io + # Сторонние образы пинуются тем же способом, что и свои: по digest. + POSTGRES_IMAGE: pgvector/pgvector:pg16 + REDIS_IMAGE: redis:7-alpine + GATEWAY_IMAGE: caddy:2.8.4 + COTURN_IMAGE: coturn/coturn:4.6 + +jobs: + images: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Версия релиза + id: version + run: | + version="${{ inputs.version }}" + [ -n "$version" ] || version="${GITHUB_REF_NAME#v}" + case "$version" in + *[!A-Za-z0-9._-]*|"") echo "Недопустимая версия: $version" >&2; exit 1 ;; + esac + echo "value=$version" >> "$GITHUB_OUTPUT" + echo "repo=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Backend + id: backend + uses: docker/build-push-action@v6 + with: + context: ${{ env.APP_DIR }} + file: ${{ env.APP_DIR }}/apps/backend/Dockerfile.production + push: true + tags: | + ${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/backend:${{ steps.version.outputs.value }} + ${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/backend:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Frontend + id: frontend + uses: docker/build-push-action@v6 + with: + context: ${{ env.APP_DIR }} + file: ${{ env.APP_DIR }}/deploy/docker/frontend.Dockerfile + push: true + tags: | + ${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/frontend:${{ steps.version.outputs.value }} + ${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/frontend:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: release.env со ссылками по digest + run: | + set -eu + version="${{ steps.version.outputs.value }}" + repo="${{ steps.version.outputs.repo }}" + prefix="${{ env.REGISTRY }}/$repo" + + # Digest своих образов отдаёт сам build-push-action; сторонние + # спрашиваем у реестра — :latest в релизе недопустим. + third_party_digest() { + docker buildx imagetools inspect "$1" --format '{{ "{{json .Manifest.Digest}}" }}' | tr -d '"' + } + + { + echo "# release.env — сгенерирован ${{ github.workflow }} для $version." + echo "# Все образы закреплены по digest: :latest источником релиза не является." + echo "CHATBALLS_VERSION=$version" + echo "CHATBALLS_BACKEND_IMAGE=$prefix/backend:$version@${{ steps.backend.outputs.digest }}" + echo "CHATBALLS_FRONTEND_IMAGE=$prefix/frontend:$version@${{ steps.frontend.outputs.digest }}" + echo "CHATBALLS_POSTGRES_IMAGE=${POSTGRES_IMAGE}@$(third_party_digest "$POSTGRES_IMAGE")" + echo "CHATBALLS_REDIS_IMAGE=${REDIS_IMAGE}@$(third_party_digest "$REDIS_IMAGE")" + echo "CHATBALLS_GATEWAY_IMAGE=${GATEWAY_IMAGE}@$(third_party_digest "$GATEWAY_IMAGE")" + echo "CHATBALLS_COTURN_IMAGE=${COTURN_IMAGE}@$(third_party_digest "$COTURN_IMAGE")" + } > release.env + + cat release.env + + - uses: actions/upload-artifact@v4 + with: + name: release.env + path: release.env + + - name: Приложить release.env к релизу + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: release.env diff --git a/.gitignore b/.gitignore index 078349e..843e6db 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,8 @@ coverage/ playwright-report/ test-results/ .DS_Store + +# Перенесено из корневого .gitignore при переносе корня репозитория в code/chatballs +data/ +.env.bak-* +**/staticfiles/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..a928ddf --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,29 @@ +# GitLab CI/CD — canonical pipeline Chatballs (ADR-HUB-0028 / PLAN-CHATBALLS-0002). +# Application orchestration is owned only by the release-bundled `chatballs` CLI. + +stages: + - validate + - test + - build + - release + - deploy + +variables: + APP_DIR: "." + DOCKER_TLS_CERTDIR: "" + DOCKER_DRIVER: overlay2 + + BACKEND_IMAGE: "$CI_REGISTRY_IMAGE/backend:$CI_COMMIT_SHA" + FRONTEND_IMAGE: "$CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_SHA" + BACKEND_LATEST_IMAGE: "$CI_REGISTRY_IMAGE/backend:latest" + FRONTEND_LATEST_IMAGE: "$CI_REGISTRY_IMAGE/frontend:latest" + + POSTGRES_IMAGE: "pgvector/pgvector:pg16" + REDIS_IMAGE: "redis:7-alpine" + GATEWAY_IMAGE: "caddy:2.8.4" + COTURN_IMAGE: "coturn/coturn:4.6" + +include: + - local: "/.gitlab/ci/validate-test.yml" + - local: "/.gitlab/ci/build-release.yml" + - local: "/.gitlab/ci/deploy.yml" diff --git a/.gitlab/ci/build-release.yml b/.gitlab/ci/build-release.yml new file mode 100644 index 0000000..8779c1d --- /dev/null +++ b/.gitlab/ci/build-release.yml @@ -0,0 +1,125 @@ +images:build: + stage: build + image: docker:27 + services: + - name: docker:27-dind + alias: docker + needs: + - backend:test + - frontend:build + - gateway:validate + - cli:test + before_script: + - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin + - docker buildx version + script: + - | + set -eu + VERSION="${CI_COMMIT_TAG:-$CI_COMMIT_SHA}" + case "$VERSION" in + *[!A-Za-z0-9._-]*|'') + echo "Unsupported release version: $VERSION" >&2 + exit 1 + ;; + esac + + docker build \ + -f "$APP_DIR/apps/backend/Dockerfile.production" \ + -t "$BACKEND_IMAGE" \ + -t "$BACKEND_LATEST_IMAGE" \ + "$APP_DIR" + docker build \ + -f "$APP_DIR/deploy/docker/frontend.Dockerfile" \ + -t "$FRONTEND_IMAGE" \ + -t "$FRONTEND_LATEST_IMAGE" \ + "$APP_DIR" + + docker push "$BACKEND_IMAGE" + docker push "$FRONTEND_IMAGE" + + if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then + docker push "$BACKEND_LATEST_IMAGE" + docker push "$FRONTEND_LATEST_IMAGE" + fi + + resolve_digest_ref() { + image="$1" + manifest="$(mktemp)" + docker buildx imagetools inspect --raw "$image" > "$manifest" + test -s "$manifest" + digest="sha256:$(sha256sum "$manifest" | awk '{print $1}')" + rm -f "$manifest" + printf '%s@%s' "$image" "$digest" + } + + mkdir -p "$APP_DIR/.ci" + cat > "$APP_DIR/.ci/release.env" < checksums.txt + sha256sum -c checksums.txt + ) + + tar -czf "$DIST_DIR/chatballs-release.tar.gz" "$BUNDLE_DIR" + printf 'CHATBALLS_RELEASE_VERSION=%s\n' "$VERSION" > "$DIST_DIR/release-metadata.env" + printf 'CHATBALLS_RELEASE_ARCHIVE=%s/dist/chatballs-release.tar.gz\n' "$APP_DIR" >> "$DIST_DIR/release-metadata.env" + artifacts: + name: "chatballs-$CI_COMMIT_REF_SLUG" + paths: + - "$APP_DIR/dist/chatballs-release.tar.gz" + - "$APP_DIR/dist/release-metadata.env" + reports: + dotenv: "$APP_DIR/dist/release-metadata.env" + expire_in: 1 year + when: on_success + rules: + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + - if: '$CI_COMMIT_TAG' diff --git a/.gitlab/ci/deploy.yml b/.gitlab/ci/deploy.yml new file mode 100644 index 0000000..869a668 --- /dev/null +++ b/.gitlab/ci/deploy.yml @@ -0,0 +1,129 @@ +deploy:production: + stage: deploy + image: alpine:3.20 + needs: + - job: release:bundle + artifacts: true + resource_group: production + interruptible: false + variables: + DEPLOY_ROOT: "/opt/chatballs" + before_script: + - apk add --no-cache bash openssh-client + - mkdir -p ~/.ssh + - chmod 700 ~/.ssh + - printf '%s\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 + - chmod 600 ~/.ssh/id_ed25519 + - ssh-keyscan -p "${DEPLOY_PORT:-22}" "$DEPLOY_HOST" >> ~/.ssh/known_hosts + script: + - | + set -eu + REMOTE_ARCHIVE="/tmp/chatballs-${CI_PIPELINE_ID}-${CI_JOB_ID}.tar.gz" + scp -P "${DEPLOY_PORT:-22}" \ + "$CHATBALLS_RELEASE_ARCHIVE" \ + "$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_ARCHIVE" + + ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" \ + bash -s -- "$DEPLOY_ROOT" "$CHATBALLS_RELEASE_VERSION" "$REMOTE_ARCHIVE" <<'REMOTE' + set -Eeuo pipefail + + root="$1" + version="$2" + archive="$3" + release_name="chatballs-$version" + target="$root/releases/$release_name" + incoming="" + + cleanup() { + if [[ -n "$incoming" ]]; then + rm -rf "$incoming" + fi + rm -f "$archive" + } + trap cleanup EXIT + + sudo mkdir -p \ + "$root/releases" \ + "$root/instance/state" \ + "$root/instance/data" \ + "$root/instance/backups" \ + "$root/instance/logs" + sudo chown "$(id -u):$(id -g)" \ + "$root" \ + "$root/releases" \ + "$root/instance" \ + "$root/instance/state" \ + "$root/instance/data" \ + "$root/instance/backups" \ + "$root/instance/logs" + + incoming="$(mktemp -d "$root/releases/.incoming-${release_name}.XXXXXX")" + tar -xzf "$archive" -C "$incoming" + source_dir="$incoming/$release_name" + + [[ -d "$source_dir" ]] || { + echo "Release directory is missing in bundle: $release_name" >&2 + exit 1 + } + [[ -f "$source_dir/checksums.txt" ]] || { + echo "checksums.txt is missing in release bundle" >&2 + exit 1 + } + ( + cd "$source_dir" + sha256sum -c checksums.txt + ) + + if [[ -e "$target" ]]; then + [[ -f "$target/checksums.txt" ]] || { + echo "Existing release has no checksums: $target" >&2 + exit 1 + } + ( + cd "$target" + sha256sum -c checksums.txt + ) + incoming_manifest="$(sha256sum "$source_dir/checksums.txt" | awk '{print $1}')" + target_manifest="$(sha256sum "$target/checksums.txt" | awk '{print $1}')" + [[ "$incoming_manifest" = "$target_manifest" ]] || { + echo "Release $version already exists with different content" >&2 + exit 1 + } + else + mv "$source_dir" "$target" + fi + + chmod +x "$target/chatballs" + REMOTE + + - | + set -eu + printf '%s' "$CI_REGISTRY_PASSWORD" \ + | ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" \ + "docker login '$CI_REGISTRY' -u '$CI_REGISTRY_USER' --password-stdin" + + - | + set -eu + ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" \ + bash -s -- "$DEPLOY_ROOT" "$CHATBALLS_RELEASE_VERSION" <<'REMOTE' + set -Eeuo pipefail + + root="$1" + version="$2" + release_dir="$root/releases/chatballs-$version" + + CHATBALLS_INSTANCE_DIR="$root/instance" \ + "$release_dir/chatballs" deploy --non-interactive + + ln -sfnT "releases/chatballs-$version" "$root/current" + REMOTE + after_script: + - | + ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" \ + "docker logout '$CI_REGISTRY' >/dev/null 2>&1 || true" || true + environment: + name: production + url: https://${CHATBALLS_APP_DOMAIN} + rules: + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + when: manual diff --git a/.gitlab/ci/validate-test.yml b/.gitlab/ci/validate-test.yml new file mode 100644 index 0000000..415d0b0 --- /dev/null +++ b/.gitlab/ci/validate-test.yml @@ -0,0 +1,86 @@ +frontend:build: + stage: validate + image: node:22-alpine + before_script: + - cd "$APP_DIR" + - npm ci + script: + - npm run typecheck + - npm run build + rules: + - if: '$CI_COMMIT_BRANCH' + - if: '$CI_COMMIT_TAG' + +gateway:validate: + stage: validate + image: docker:27 + services: + - name: docker:27-dind + alias: docker + before_script: + - mkdir -p "$APP_DIR/.ci-instance/data/caddy" "$APP_DIR/.ci-instance/data/caddy-config" + script: + - | + set -eu + # Никакого .env: продукт поднимается со значениями по умолчанию, и + # проверять его надо ровно так же, как он ставится у человека. + export CHATBALLS_INSTANCE_DIR="$CI_PROJECT_DIR/$APP_DIR/.ci-instance" + export CHATBALLS_RELEASE_DIR="$CI_PROJECT_DIR/$APP_DIR" + + docker compose \ + --project-directory "$CHATBALLS_INSTANCE_DIR" \ + -f "$APP_DIR/compose.yaml" \ + config -q + + docker compose \ + --project-directory "$CHATBALLS_INSTANCE_DIR" \ + -f "$APP_DIR/compose.yaml" \ + run --rm --no-deps gateway \ + caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile + rules: + - if: '$CI_COMMIT_BRANCH' + - if: '$CI_COMMIT_TAG' + +backend:test: + stage: test + image: python:3.12-slim + services: + - name: pgvector/pgvector:pg16 + alias: postgres + - name: redis:7-alpine + alias: redis + variables: + CHATBALLS_DEBUG: "true" + CHATBALLS_SECRET_KEY: "ci-test-secret" + CHATBALLS_FIELD_ENCRYPTION_KEY: "" + POSTGRES_DB: "chatballs_test" + POSTGRES_USER: "chatballs" + POSTGRES_PASSWORD: "chatballs" + POSTGRES_HOST: "postgres" + POSTGRES_PORT: "5432" + REDIS_URL: "redis://redis:6379/0" + before_script: + - cd "$APP_DIR/apps/backend" + - python -m pip install --upgrade pip + - pip install -r requirements.txt + script: + - python manage.py check + - pytest + rules: + - if: '$CI_COMMIT_BRANCH' + - if: '$CI_COMMIT_TAG' + +cli:test: + stage: test + image: python:3.12-slim + before_script: + - apt-get update + - apt-get install -y --no-install-recommends bash coreutils util-linux + - rm -rf /var/lib/apt/lists/* + - python -m pip install --no-cache-dir pytest + script: + - cd "$APP_DIR" + - pytest -c tests/cli/pytest.ini tests/cli + rules: + - if: '$CI_COMMIT_BRANCH' + - if: '$CI_COMMIT_TAG'