diff --git a/.github/workflows/release-deploy.yml b/.github/workflows/release-deploy.yml new file mode 100644 index 00000000..7400aa91 --- /dev/null +++ b/.github/workflows/release-deploy.yml @@ -0,0 +1,171 @@ +name: Release & Deploy + +on: + workflow_run: + workflows: ["Docker Scan & Promote"] + types: [completed] + workflow_dispatch: + inputs: + skip_deploy: + description: 'Skip deployment (release only)' + required: false + type: boolean + default: false + +env: + REGISTRY: ghcr.io + +jobs: + release: + name: Create CalVer Release + if: > + (github.event_name == 'workflow_dispatch' && github.ref_name == 'main') || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main') + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + outputs: + version: ${{ steps.calver.outputs.version }} + sha: ${{ steps.sha.outputs.sha }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Determine SHA + id: sha + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + SHA="${{ github.sha }}" + else + SHA="${{ github.event.workflow_run.head_sha }}" + fi + echo "sha=$SHA" >> $GITHUB_OUTPUT + echo "short-sha=${SHA::7}" >> $GITHUB_OUTPUT + echo "owner=${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_OUTPUT + + - name: Compute CalVer tag + id: calver + run: | + YEAR=$(date +%Y) + MONTH=$(date +%-m) + PATTERN="^${YEAR}\\.${MONTH}\\.[0-9]+$" + git fetch --tags + + HIGHEST=$(git tag -l | grep -E "$PATTERN" | \ + awk -F. '{print $3}' | sort -n | tail -1) + + if [ -z "$HIGHEST" ]; then + PATCH=0 + else + PATCH=$((HIGHEST + 1)) + fi + + VERSION="${YEAR}.${MONTH}.${PATCH}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Install crane + uses: imjasonh/setup-crane@v0.5 + + - name: Tag images with CalVer version + run: | + PREFIX="${GITHUB_REPOSITORY_OWNER,,}/integr8scode" + VERSION="${{ steps.calver.outputs.version }}" + for img in base backend frontend cert-generator zookeeper-certgen; do + crane copy "$REGISTRY/$PREFIX/$img:latest" "$REGISTRY/$PREFIX/$img:$VERSION" + done + + - name: Create Git tag + run: | + VERSION="${{ steps.calver.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$VERSION" -m "Release $VERSION" + git push origin "$VERSION" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.calver.outputs.version }} + name: ${{ steps.calver.outputs.version }} + generate_release_notes: true + body: | + **Commit**: `${{ steps.sha.outputs.short-sha }}` + + ### Docker Images + + ``` + docker pull ghcr.io/${{ steps.sha.outputs.owner }}/integr8scode/backend:${{ steps.calver.outputs.version }} + docker pull ghcr.io/${{ steps.sha.outputs.owner }}/integr8scode/frontend:${{ steps.calver.outputs.version }} + ``` + + deploy: + name: Deploy to Production + needs: [release] + if: > + (github.event_name == 'workflow_run') || + (github.event_name == 'workflow_dispatch' && !inputs.skip_deploy) + runs-on: ubuntu-latest + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + env: + GHCR_TOKEN: ${{ secrets.DEPLOY_GHCR_TOKEN }} + GHCR_USER: ${{ github.repository_owner }} + IMAGE_TAG: ${{ needs.release.outputs.version }} + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + envs: GHCR_TOKEN,GHCR_USER,IMAGE_TAG + command_timeout: 10m + script: | + set -e + cd /opt/Integr8sCode + + git pull origin main + + echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin + + export IMAGE_TAG="$IMAGE_TAG" + docker compose pull + docker compose up -d --remove-orphans --no-build + + TIMEOUT=120; ELAPSED=0 + until curl -kfs https://localhost/api/v1/health/live > /dev/null 2>&1; do + [ $ELAPSED -ge $TIMEOUT ] && echo "Health check failed" && docker compose logs backend --tail=30 && exit 1 + sleep 5; ELAPSED=$((ELAPSED + 5)) + done + echo "Backend healthy (IMAGE_TAG=$IMAGE_TAG)" + + docker image prune -af --filter "until=72h" || true + + summary: + name: Summary + needs: [release, deploy] + if: always() + runs-on: ubuntu-latest + steps: + - name: Generate summary + run: | + echo "## Release & Deploy" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${{ needs.release.result }}" = "success" ]; then + echo "- Release **${{ needs.release.outputs.version }}** created" >> $GITHUB_STEP_SUMMARY + else + echo "- Release: **${{ needs.release.result }}**" >> $GITHUB_STEP_SUMMARY + fi + if [ "${{ needs.deploy.result }}" = "success" ]; then + echo "- Deployed to production" >> $GITHUB_STEP_SUMMARY + else + echo "- Deploy: **${{ needs.deploy.result }}**" >> $GITHUB_STEP_SUMMARY + fi diff --git a/backend/grafana/grafana.ini b/backend/grafana/grafana.ini index fcd1d650..bf9130c9 100644 --- a/backend/grafana/grafana.ini +++ b/backend/grafana/grafana.ini @@ -1,3 +1,7 @@ +[server] +root_url = %(protocol)s://%(domain)s:%(http_port)s/grafana/ +serve_from_sub_path = true + [security] admin_user = admin admin_password = admin123 diff --git a/docs/operations/cicd.md b/docs/operations/cicd.md index 5e58c337..ff452d85 100644 --- a/docs/operations/cicd.md +++ b/docs/operations/cicd.md @@ -38,6 +38,12 @@ graph LR Scan --> Promote end + subgraph "Release & Deploy" + Release["CalVer Tag + GitHub Release"] + Deploy["SSH Deploy to Production"] + Release --> Deploy + end + subgraph "Documentation" Docs["MkDocs Build"] Pages["GitHub Pages"] @@ -45,13 +51,15 @@ graph LR Push["Push / PR"] --> Ruff & MyPy & Vulture & ESLint & Bandit & SBOM & UnitBE & UnitFE & Docs Build -->|main, all tests pass| Scan + Promote -->|main, scans pass| Release Docs -->|main only| Pages ``` -The two heavyweight workflows are **Stack Tests** (builds images, runs all tests) and **Docker Scan & Promote** -(scans images with Trivy and promotes to `latest`). They're connected: Docker Scan & Promote triggers automatically -after Stack Tests succeeds on `main`, forming a build-test-scan-promote pipeline where the `latest` tag only moves -forward when everything passes. +The three heavyweight workflows are **Stack Tests** (builds images, runs all tests), **Docker Scan & Promote** +(scans images with Trivy and promotes to `latest`), and **Release & Deploy** (creates CalVer releases and deploys to +production). They're chained: Docker Scan & Promote triggers after Stack Tests succeeds on `main`, and Release & Deploy +triggers after Docker Scan & Promote succeeds, forming a build-test-scan-promote-release-deploy pipeline where +production only updates when everything passes. ## Workflow files @@ -59,6 +67,7 @@ forward when everything passes. |-------------------------|----------------------------------------------|-----------------------------------------------|--------------------------------------------| | Stack Tests | `.github/workflows/stack-tests.yml` | Push/PR to `main`, tags `v*` | Unit tests, image build, E2E tests | | Docker Scan & Promote | `.github/workflows/docker.yml` | After Stack Tests completes on `main` | Trivy scan + promote SHA tag to `latest` | +| Release & Deploy | `.github/workflows/release-deploy.yml` | After Docker Scan & Promote completes on `main`| CalVer release + SSH deploy to production | | SBOM & Supply Chain | `.github/workflows/sbom-compliance.yml` | Push/PR to `main`, weekly schedule | SPDX SBOM generation + Grype vulnerability scan | | Ruff Linting | `.github/workflows/ruff.yml` | Push/PR to `main` | Python code style and import checks | | MyPy Type Checking | `.github/workflows/mypy.yml` | Push/PR to `main` | Python static type analysis | @@ -252,6 +261,65 @@ Uses [crane](https://github.com/google/go-containerregistry/blob/main/cmd/crane/ registry level (`crane copy sha-tag latest`), avoiding any rebuild or re-push. This is a fast, atomic operation that simply re-tags existing image manifests. +## Release & Deploy + +This workflow creates a CalVer-tagged GitHub Release and deploys to production. It chains after Docker Scan & Promote, +completing the full pipeline from code push to production deployment. + +```mermaid +graph LR + DSP["Docker Scan & Promote
(main, success)"] -->|workflow_run trigger| Release + Release["CalVer Tag
+ GitHub Release"] --> Deploy["SSH Deploy
to Production"] + Deploy --> Summary["Step Summary"] +``` + +### Trigger + +Runs automatically when `Docker Scan & Promote` completes successfully on `main`. Can also be triggered manually via +`workflow_dispatch` with an optional `skip_deploy` flag to create a release without deploying. + +### CalVer tagging + +Releases use [Calendar Versioning](https://calver.org/) with the format `YYYY.M.PATCH`: + +- `YYYY` — full year (e.g., `2026`) +- `M` — month without leading zero (e.g., `2` for February) +- `PATCH` — auto-incrementing counter within the month, starting at `0` + +Examples: `2026.2.0`, `2026.2.1`, `2026.3.0`. The workflow counts existing tags matching the current `YYYY.M.*` pattern +and increments the patch number. All 5 deployed GHCR images are tagged with the CalVer version using crane (same +registry-level manifest copy as the promote step). + +### GitHub Release + +The workflow creates an annotated git tag and a GitHub Release using `softprops/action-gh-release@v2` with +`generate_release_notes: true`, which auto-generates a changelog from merged PRs since the previous release. The release +body includes the commit SHA and docker pull commands for the tagged images. + +### Production deployment + +The deploy job SSHs into the production server using `appleboy/ssh-action@v1` and runs: + +1. `git pull origin main` — update config files and compose definitions +2. `docker login ghcr.io` — authenticate with a dedicated read-only PAT (passed via `envs`, not embedded in the script) +3. `docker compose pull` — pull the latest images +4. `docker compose up -d --remove-orphans` — recreate changed containers +5. Health check — polls `/api/v1/health/live` with a 120-second timeout +6. `docker image prune` — clean up images older than 72 hours + +The deploy job is skippable via the `skip_deploy` input on manual dispatch. + +### Required secrets + +| Secret | Purpose | +|--------------------|--------------------------------------| +| `DEPLOY_HOST` | Production server IP | +| `DEPLOY_USER` | SSH username | +| `DEPLOY_SSH_KEY` | Ed25519 private key for SSH | +| `DEPLOY_GHCR_TOKEN`| GitHub PAT with `read:packages` scope| + +See [Deployment — Production deployment](deployment.md#production-deployment) for setup instructions. + ## SBOM & Supply Chain Security The `sbom-compliance.yml` workflow generates [SPDX](https://spdx.dev/) Software Bills of Materials for both backend diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md index 81b52a15..40b3b532 100644 --- a/docs/operations/deployment.md +++ b/docs/operations/deployment.md @@ -261,6 +261,38 @@ IMAGE_TAG=sha-abc1234 docker compose up -d --no-build |---------------|------------------------------------| | `latest` | Most recent build from main branch | | `sha-abc1234` | Specific commit SHA | +| `2026.2.0` | CalVer release version | + +## Production deployment + +Merges to `main` trigger automatic deployment to the production server via the +[Release & Deploy](cicd.md#release--deploy) workflow. The full pipeline chain is: + +1. **Stack Tests** — unit tests, image build, E2E tests +2. **Docker Scan & Promote** — Trivy vulnerability scan, promote `sha-xxx` to `latest` +3. **Release & Deploy** — create CalVer tag + GitHub Release, SSH deploy to production + +The deploy step pulls the latest images on the server and recreates containers with zero-downtime health checks. No +manual intervention is required for normal merges. + +### Rollback + +To roll back to a previous release, use a specific CalVer or SHA tag: + +```bash +# On the production server +IMAGE_TAG=2026.2.0 docker compose pull +IMAGE_TAG=2026.2.0 docker compose up -d --remove-orphans +``` + +Or trigger the Release & Deploy workflow manually with `skip_deploy` enabled to create a release without deploying, +then deploy a specific version via SSH. + +### First-time setup + +To configure the production server and GitHub Secrets, follow the [Required secrets](cicd.md#required-secrets) section +in the CI/CD docs. You will need to generate an SSH key pair, create a GitHub PAT with `read:packages` scope, and add +all four secrets (`DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, `DEPLOY_GHCR_TOKEN`) to the repository settings. ## Key files @@ -270,3 +302,4 @@ IMAGE_TAG=sha-abc1234 docker compose up -d --no-build | [`docker-compose.yaml`](https://github.com/HardMax71/Integr8sCode/blob/main/docker-compose.yaml) | Full stack definition | | [`backend/Dockerfile.base`](https://github.com/HardMax71/Integr8sCode/blob/main/backend/Dockerfile.base) | Shared base image with deps | | [`.github/workflows/docker.yml`](https://github.com/HardMax71/Integr8sCode/blob/main/.github/workflows/docker.yml) | CI/CD image build pipeline | +| [`.github/workflows/release-deploy.yml`](https://github.com/HardMax71/Integr8sCode/blob/main/.github/workflows/release-deploy.yml) | Release + deploy pipeline | diff --git a/docs/operations/nginx-configuration.md b/docs/operations/nginx-configuration.md index 6a68702b..ae0e90d8 100644 --- a/docs/operations/nginx-configuration.md +++ b/docs/operations/nginx-configuration.md @@ -9,11 +9,12 @@ The frontend uses Nginx as a reverse proxy and static file server. The configura flowchart LR Browser --> Nginx Nginx -->|"/api/*"| Backend["Backend :443"] + Nginx -->|"/grafana/*"| Grafana["Grafana :3000"] Nginx -->|"static files"| Static["Static files"] ``` -Nginx serves two purposes: static file server for the Svelte frontend build, and reverse proxy for API requests to the -backend. +Nginx serves three purposes: static file server for the Svelte frontend build, reverse proxy for API requests to the +backend, and reverse proxy for Grafana (when the `observability` Docker Compose profile is active). ## Configuration breakdown @@ -89,6 +90,31 @@ the SSE block defines its own `proxy_set_header` directives (e.g., `Connection ' redeclare all of them — `proxy_set_header` follows the same all-or-nothing inheritance as `add_header`: once a child block defines **any** `proxy_set_header`, all parent-level `proxy_set_header` directives are dropped for that block. +### Grafana proxy + +```nginx +--8<-- "frontend/nginx.conf.template:grafana_proxy" +``` + +Grafana is only available when the `observability` Docker Compose profile is active. Without it, requests to `/grafana/` +return 502 (expected). + +| Directive | Purpose | +|---------------------------------------------------------------|------------------------------------------------------------------------------| +| `proxy_pass http://grafana:3000` | Forward requests to the Grafana container on the internal Docker network | +| `proxy_set_header Host $host` | Forward the original `Host` header so Grafana sees the client's hostname | +| `proxy_set_header X-Real-IP $remote_addr` | Pass the client's real IP address | +| `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for` | Append client IP to the proxy chain header | +| `proxy_set_header X-Forwarded-Proto $scheme` | Preserve the original protocol so Grafana can build correct redirect URLs | + +Grafana must also be configured to serve from a subpath. This is done in `backend/grafana/grafana.ini`: + +```ini +[server] +root_url = %(protocol)s://%(domain)s:%(http_port)s/grafana/ +serve_from_sub_path = true +``` + ### Static asset caching ```nginx @@ -187,6 +213,7 @@ the `server` level apply uniformly to all responses: |--------------------------------|-------------------|-----------------------------| | `location /api/` | No | Yes | | `location ~ ^/api/v1/events/` | No | Yes | +| `location /grafana/` | No | Yes | | `location ~* \.(js\|css\|…)` | No | Yes | | `location /build/` | No | Yes | | `location ~* \.html$` | No | Yes | diff --git a/frontend/.env b/frontend/.env index e33f9e66..0b0df48a 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1 +1,2 @@ -VITE_BACKEND_URL=https://127.0.0.1:443 \ No newline at end of file +VITE_BACKEND_URL=https://127.0.0.1:443 +VITE_GRAFANA_URL=/grafana diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template index e25316be..3d45ac55 100644 --- a/frontend/nginx.conf.template +++ b/frontend/nginx.conf.template @@ -83,6 +83,16 @@ server { } # --8<-- [end:static_caching] + # --8<-- [start:grafana_proxy] + location /grafana/ { + proxy_pass http://grafana:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # --8<-- [end:grafana_proxy] + location / { try_files $uri $uri/ /index.html; } diff --git a/frontend/rollup.config.js b/frontend/rollup.config.js index 73116963..a6e02157 100644 --- a/frontend/rollup.config.js +++ b/frontend/rollup.config.js @@ -163,6 +163,7 @@ export default { aliases, replace({ 'process.env.VITE_BACKEND_URL': JSON.stringify(''), + 'process.env.VITE_GRAFANA_URL': JSON.stringify(process.env.VITE_GRAFANA_URL || '/grafana'), preventAssignment: true }), svelte({ diff --git a/frontend/src/components/Footer.svelte b/frontend/src/components/Footer.svelte index 7bfb7531..ff1f9ca2 100644 --- a/frontend/src/components/Footer.svelte +++ b/frontend/src/components/Footer.svelte @@ -35,7 +35,7 @@ Tools & Info