From 9e374079645e3ac213042ba0963c23e1fbba4d7f Mon Sep 17 00:00:00 2001 From: 0x48core Date: Fri, 9 Oct 2026 15:48:20 +0700 Subject: [PATCH] feat(lab-07): add CI/CD pipeline for hello-api - GitHub Actions workflow: gofmt + vet + race tests, integration test against the Compose stack, multi-arch image (amd64/arm64) pushed to GHCR as sha- on main, and an SSH deploy job gated behind DEPLOY_ENABLED with a pinned host key - hello-api: extract newMux for testability, add unit tests, make test - Dockerfile: cross-compile with $BUILDPLATFORM instead of emulation - scripts/smoke-test.sh: health, readiness, version, and a real write - stacks/hello-api: compose.deploy.yaml and deploy.sh (up --wait) - Lab 07 guide and knowledge base; READMEs and roadmap updated Co-Authored-By: Claude Opus 5.5 --- .github/workflows/.gitkeep | 0 .github/workflows/hello-api.yml | 179 +++++++++++++++++++++++++ README.md | 2 +- apps/hello-api/Dockerfile | 7 +- apps/hello-api/Makefile | 6 +- apps/hello-api/README.md | 5 + apps/hello-api/main.go | 46 ++++--- apps/hello-api/main_test.go | 70 ++++++++++ labs/07-cicd-pipeline/README.md | 193 ++++++++++++++++++++++++++- labs/07-cicd-pipeline/knowledge.md | 161 ++++++++++++++++++++++ scripts/README.md | 6 +- scripts/smoke-test.sh | 36 +++++ stacks/README.md | 2 +- stacks/hello-api/compose.deploy.yaml | 6 + stacks/hello-api/deploy.sh | 28 ++++ 15 files changed, 714 insertions(+), 33 deletions(-) delete mode 100644 .github/workflows/.gitkeep create mode 100644 .github/workflows/hello-api.yml create mode 100644 apps/hello-api/main_test.go create mode 100644 labs/07-cicd-pipeline/knowledge.md create mode 100755 scripts/smoke-test.sh create mode 100644 stacks/hello-api/compose.deploy.yaml create mode 100755 stacks/hello-api/deploy.sh diff --git a/.github/workflows/.gitkeep b/.github/workflows/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.github/workflows/hello-api.yml b/.github/workflows/hello-api.yml new file mode 100644 index 0000000..9df3cb4 --- /dev/null +++ b/.github/workflows/hello-api.yml @@ -0,0 +1,179 @@ +# CI/CD for hello-api (lab 07). +# +# test ──▶ integration ──▶ image ──▶ deploy +# (every push and PR) (main only) (main only, when DEPLOY_ENABLED=true) +# +# Deploy setup: labs/07-cicd-pipeline/README.md, step 6. +name: hello-api + +on: + push: + branches: [main] + paths: + - apps/hello-api/** + - stacks/hello-api/** + - scripts/smoke-test.sh + - .github/workflows/hello-api.yml + pull_request: + paths: + - apps/hello-api/** + - stacks/hello-api/** + - scripts/smoke-test.sh + - .github/workflows/hello-api.yml + workflow_dispatch: + +# Least privilege: jobs that need more ask for it themselves. +permissions: + contents: read + +# A newer push to the same branch cancels the older, still-running pipeline. +concurrency: + group: hello-api-${{ github.ref }} + cancel-in-progress: true + +env: + IMAGE: ghcr.io/${{ github.repository_owner }}/hello-api + +jobs: + test: + name: Lint and unit tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/hello-api + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-go@v6 + with: + go-version-file: apps/hello-api/go.mod + cache-dependency-path: apps/hello-api/go.sum + + - name: Check formatting + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "Run gofmt on:"; echo "$unformatted"; exit 1 + fi + + - name: Vet and test + run: make test + + integration: + name: Integration test (Compose stack) + needs: test + runs-on: ubuntu-latest + defaults: + run: + working-directory: stacks/hello-api + steps: + - uses: actions/checkout@v5 + + - name: Start app, Postgres, and Redis + run: | + cp .env.example .env + sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$(openssl rand -hex 16)/" .env + docker compose up -d --build --wait --wait-timeout 120 + + - name: Smoke test + run: ../../scripts/smoke-test.sh http://localhost:8000 dev + + - name: Show logs + if: failure() + run: docker compose logs + + - name: Stop + if: always() + run: docker compose down -v + + image: + name: Build and push image + needs: integration + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write # push to ghcr.io with GITHUB_TOKEN + outputs: + tag: ${{ steps.vars.outputs.tag }} + steps: + - uses: actions/checkout@v5 + + - name: Compute image tag + id: vars + run: echo "tag=sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + context: apps/hello-api + platforms: linux/amd64,linux/arm64 + push: true + build-args: VERSION=${{ steps.vars.outputs.tag }} + tags: | + ${{ env.IMAGE }}:${{ steps.vars.outputs.tag }} + ${{ env.IMAGE }}:main + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha,scope=hello-api + cache-to: type=gha,mode=max,scope=hello-api + + deploy: + name: Deploy to VPS + needs: image + # Off until a VPS exists: set the repository variable DEPLOY_ENABLED=true. + if: vars.DEPLOY_ENABLED == 'true' + runs-on: ubuntu-latest + environment: production + # Never run two deploys at once, and never cancel one halfway. + concurrency: + group: deploy-production + cancel-in-progress: false + env: + TAG: ${{ needs.image.outputs.tag }} + DEPLOY_URL: ${{ vars.DEPLOY_URL }} + steps: + - uses: actions/checkout@v5 + + - name: Configure SSH + env: + SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} + HOST: ${{ secrets.DEPLOY_HOST }} + PORT: ${{ secrets.DEPLOY_PORT }} + USER: ${{ secrets.DEPLOY_USER }} + run: | + install -m 700 -d ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + # Pinned host key: no StrictHostKeyChecking=no, so a fake server can't + # receive our deploy. + printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts + cat > ~/.ssh/config </dev/null || echo dev) ARCH ?= arm64 LDFLAGS := -s -w -X main.version=$(VERSION) -.PHONY: run build build-linux docker-build clean +.PHONY: run test build build-linux docker-build clean run: go run . +test: + go vet ./... + go test -race -count=1 ./... + build: go build -ldflags "$(LDFLAGS)" -o bin/hello-api . diff --git a/apps/hello-api/README.md b/apps/hello-api/README.md index ce7fdc0..dcfe1ae 100644 --- a/apps/hello-api/README.md +++ b/apps/hello-api/README.md @@ -17,6 +17,7 @@ It shuts down gracefully on `SIGTERM`, so in-flight requests finish when systemd ```bash make run curl localhost:8000/health +make test # go vet + go test -race (same as CI) ``` Config via environment: @@ -40,9 +41,13 @@ make build-linux ARCH=amd64 # linux/amd64 → most VPSs make docker-build # container image hello-api: ``` +CI builds multi-arch images (amd64 + arm64) and pushes them to `ghcr.io/0x48core/hello-api:sha-` on every merge to `main`; see [the workflow](../../.github/workflows/hello-api.yml). + To run with Postgres and Redis, use the Compose stack in [stacks/hello-api](../../stacks/hello-api/). ## Used in - [Lab 02](../../labs/02-nginx-ssl-reverse-proxy/): runs as a `systemd` service ([hello-api.service](hello-api.service)) behind Nginx - [Lab 04](../../labs/04-dockerize-app/): multi-stage [Dockerfile](Dockerfile) (distroless, non-root, ~22 MB) and a Compose stack +- [Lab 05](../../labs/05-multi-service-proxy/): behind Traefik at `api.opsforge.localhost` +- [Lab 07](../../labs/07-cicd-pipeline/): unit tests ([main_test.go](main_test.go)), CI/CD to GHCR, deploy over SSH diff --git a/apps/hello-api/main.go b/apps/hello-api/main.go index 7a03b36..a11ed2a 100644 --- a/apps/hello-api/main.go +++ b/apps/hello-api/main.go @@ -51,6 +51,30 @@ func main() { } defer st.close() + srv := &http.Server{ + Addr: addr, + Handler: logRequests(newMux(st)), + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + log.Printf("hello-api %s listening on %s", version, addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } + }() + + <-ctx.Done() + log.Print("shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Print(err) + } +} + +// newMux wires the routes. Separate from main so tests can call it directly. +func newMux(st *store) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) @@ -87,27 +111,7 @@ func main() { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) }) - - srv := &http.Server{ - Addr: addr, - Handler: logRequests(mux), - ReadHeaderTimeout: 5 * time.Second, - } - - go func() { - log.Printf("hello-api %s listening on %s", version, addr) - if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatal(err) - } - }() - - <-ctx.Done() - log.Print("shutting down") - shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := srv.Shutdown(shutdownCtx); err != nil { - log.Print(err) - } + return mux } // healthcheck lets the container check itself without curl or wget, diff --git a/apps/hello-api/main_test.go b/apps/hello-api/main_test.go new file mode 100644 index 0000000..753a7c6 --- /dev/null +++ b/apps/hello-api/main_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// do sends a request to the app without a real server or network. +func do(t *testing.T, method, path string, header map[string]string) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + req := httptest.NewRequest(method, path, nil) + for k, v := range header { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + newMux(&store{}).ServeHTTP(rec, req) // no Postgres or Redis configured + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("%s %s: response is not JSON: %q", method, path, rec.Body.String()) + } + return rec, body +} + +func TestRoutes(t *testing.T) { + tests := []struct { + name string + method string + path string + wantStatus int + wantKey string + wantValue string + }{ + {"health is ok", "GET", "/health", http.StatusOK, "status", "ok"}, + {"root shows app name", "GET", "/", http.StatusOK, "app", "hello-api"}, + {"ready without backends", "GET", "/ready", http.StatusOK, "postgres", "not configured"}, + {"visits need backends", "GET", "/visits", http.StatusServiceUnavailable, "error", errNotConfigured.Error()}, + {"record visit needs backends", "POST", "/visits", http.StatusServiceUnavailable, "error", errNotConfigured.Error()}, + {"unknown path", "GET", "/nope", http.StatusNotFound, "error", "not found"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec, body := do(t, tt.method, tt.path, nil) + if rec.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus) + } + if got := body[tt.wantKey]; got != tt.wantValue { + t.Errorf("%s = %v, want %q", tt.wantKey, got, tt.wantValue) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Errorf("Content-Type = %q, want application/json", ct) + } + }) + } +} + +func TestClientIP(t *testing.T) { + _, body := do(t, "GET", "/", map[string]string{"X-Real-IP": "203.0.113.7"}) + if got := body["client_ip"]; got != "203.0.113.7" { + t.Errorf("client_ip = %v, want the X-Real-IP header value", got) + } + + _, body = do(t, "GET", "/", nil) + if got := body["client_ip"]; got != "192.0.2.1" { // httptest's default RemoteAddr + t.Errorf("client_ip = %v, want 192.0.2.1 without X-Real-IP", got) + } +} diff --git a/labs/07-cicd-pipeline/README.md b/labs/07-cicd-pipeline/README.md index bb4717b..2f62505 100644 --- a/labs/07-cicd-pipeline/README.md +++ b/labs/07-cicd-pipeline/README.md @@ -1,27 +1,208 @@ # Lab 07 — CI/CD pipeline - **Phase:** 3 — CI/CD -- **Status:** ⬜ todo +- **Status:** 🟨 in progress - **Started / finished:** — / — +- **Environment:** GitHub Actions; deploy target is a VPS (switched off until one exists) + +> 📖 **Learn first:** read [knowledge.md](knowledge.md) before starting the steps below. ## Goal -Every push to `main` tests, builds, publishes, and deploys automatically. +Every change to hello-api is tested automatically; every merge to `main` produces a versioned image in a registry and, once a VPS exists, is deployed and verified without anyone touching the server. ## Done when -- [ ] Pipeline runs tests on every push/PR -- [ ] Builds and pushes a Docker image to a registry (e.g. GHCR), tagged with the commit SHA -- [ ] Deploys to the VPS over SSH with a dedicated, limited deploy user -- [ ] Secrets stored only in CI secrets +**Now (no VPS needed)** +- [ ] Pipeline runs unit tests and an integration test on every push and PR +- [ ] A PR with a failing test shows a red check (and is not merged) +- [ ] Merging to `main` pushes `ghcr.io/0x48core/hello-api:sha-` (amd64 + arm64) +- [ ] You can pull that image on your Mac and run it +- [ ] `deploy.sh` rehearsed locally with two tags + +**With a VPS** +- [ ] Deploys to the VPS over SSH as a dedicated, limited `deploy` user +- [ ] Secrets stored only in GitHub secrets (and the server's `.env`) +- [ ] The deploy smoke-tests the live URL and checks the version - [ ] Stretch: a self-hosted runner on the VPS +## The pipeline + +``` +push / PR ──▶ test ──────────▶ integration ─────────────▶ image ─────────────▶ deploy + gofmt, vet, docker compose up --wait, main only: main only, if + go test -race smoke-test.sh multi-arch build, DEPLOY_ENABLED=true: + push to GHCR ssh → deploy.sh → smoke test +``` + +| File | Role | +|---|---| +| [.github/workflows/hello-api.yml](../../.github/workflows/hello-api.yml) | The pipeline | +| [apps/hello-api/main_test.go](../../apps/hello-api/main_test.go) | Unit tests (`make test`) | +| [scripts/smoke-test.sh](../../scripts/smoke-test.sh) | Checks a running instance: health, readiness, version, a real write | +| [stacks/hello-api/compose.deploy.yaml](../../stacks/hello-api/compose.deploy.yaml) | Override: use the GHCR image instead of building | +| [stacks/hello-api/deploy.sh](../../stacks/hello-api/deploy.sh) | Pull, `up --wait`, report the running image | +| [apps/hello-api/Dockerfile](../../apps/hello-api/Dockerfile) | Now cross-compiles for multi-arch builds (`$BUILDPLATFORM`) | + ## Steps +### 1. Run the checks locally first + +The pipeline should only run what you can also run yourself: + +```bash +cd apps/hello-api +gofmt -l . # no output = formatted +make test # go vet + go test -race +cd ../.. +``` + +Read [main_test.go](../../apps/hello-api/main_test.go). It tests the routes **without a server or network**, using `httptest`. That's why `main.go` now has a `newMux(st)` function. + +Then the integration test, exactly as CI runs it: + +```bash +cd stacks/hello-api +docker compose up -d --build --wait # uses your .env +../../scripts/smoke-test.sh http://localhost:8000 dev +docker compose down -v +cd ../.. +``` + +### 2. Read the workflow + +Open [hello-api.yml](../../.github/workflows/hello-api.yml) and find: +- the **triggers** (`on:`) and their `paths:` filter. Why doesn't a change to `labs/` run the pipeline? +- `needs:` chaining the jobs +- `if:` limiting `image` to pushes on `main`, and `deploy` to when `DEPLOY_ENABLED` is set +- `permissions:`: read-only by default, `packages: write` only for the `image` job +- the two `concurrency:` blocks: one cancels outdated runs, the other never cancels a deploy + +### 3. Watch it run on a PR + +Push this branch and open the PR. On the PR page, the **Checks** section shows `Lint and unit tests` and `Integration test`. Open the **Actions** tab to see each step's logs. + +`image` and `deploy` are **skipped** on a PR. That's on purpose: only reviewed, merged code becomes an image. + +### 4. Break it on purpose + +On a throwaway branch: + +```bash +git checkout -b test/break-ci +sed -i '' 's/"status": "ok"/"status": "fine"/' apps/hello-api/main.go +git commit -am "test: break health response" && git push -u origin test/break-ci +``` + +Open a PR from it and watch `Lint and unit tests` fail. Find **which test** failed in the log. Then close the PR and delete the branch: + +```bash +git checkout lab/07-cicd-pipeline +git push origin --delete test/break-ci && git branch -D test/break-ci +``` + +Optional: in GitHub **Settings → Branches**, add a rule for `main` that requires these checks to pass before merging. That makes a red pipeline actually block the merge. + +### 5. Merge, and pull the image + +After merging, the push to `main` runs all of `test → integration → image`. When it's green: +1. Go to your GitHub profile → **Packages** → `hello-api`, and see the tags `sha-` and `main`. +2. New packages are **private**. Either make it public (**Package settings → Change visibility**), or log in from your Mac with a personal access token that has `read:packages`: + + ```bash + echo | docker login ghcr.io -u 0x48core --password-stdin + ``` + +3. Pull and run it: + + ```bash + docker pull ghcr.io/0x48core/hello-api:sha- + docker image inspect ghcr.io/0x48core/hello-api:sha- --format '{{.Architecture}}' # arm64 on your Mac + docker run --rm -p 127.0.0.1:8000:8000 ghcr.io/0x48core/hello-api:sha- + curl localhost:8000/ # "version": "sha-" + ``` + +The same tag pulls **amd64** on a typical VPS. That's a multi-arch image: one tag, a manifest list pointing to one image per architecture. + +### 6. Rehearse the deploy locally + +`deploy.sh` is what CI runs on the server. Rehearse it on your Mac. You need the `proxy` network from lab 05 and a `.env` in `stacks/hello-api`. + +```bash +docker pull ghcr.io/0x48core/hello-api:sha- # or build: docker build --build-arg VERSION=sha-test -t ghcr.io/0x48core/hello-api:sha-test apps/hello-api +PULL=0 stacks/hello-api/deploy.sh sha- +docker ps --filter name=hello-api-app +``` + +Deploy a second tag and watch `docker ps` during the switch. The container is **recreated**: the old one stops before the new one is healthy. Note how long the gap is. Lab 08 removes it. + +Clean up: + +```bash +(cd stacks/hello-api && APP_VERSION=x docker compose -f compose.yaml -f compose.traefik.yaml -f compose.deploy.yaml down -v) +``` + +### 7. Turn on deploys (when you have a VPS) + +On the VPS, after labs 01, 05 (Traefik with Let's Encrypt), and Docker installed: + +```bash +# a dedicated deploy user that can run Docker, and nothing else special +sudo adduser --disabled-password --gecos "" deploy +sudo usermod -aG docker deploy # ⚠️ docker group = root-equivalent; see knowledge.md +sudo -u deploy mkdir -p ~deploy/opsforge/stacks/hello-api +# create ~deploy/opsforge/stacks/hello-api/.env with a strong POSTGRES_PASSWORD +``` + +On your Mac, create a key used **only** for deploys: + +```bash +ssh-keygen -t ed25519 -f ~/.ssh/opsforge_deploy -C "github-actions-deploy" -N "" +ssh-copy-id -i ~/.ssh/opsforge_deploy.pub -p deploy@ +ssh-keyscan -p # copy the output for DEPLOY_KNOWN_HOSTS +``` + +In GitHub, **Settings → Environments → New environment → `production`**, add: + +| Kind | Name | Value | +|---|---|---| +| Secret | `DEPLOY_SSH_KEY` | Contents of `~/.ssh/opsforge_deploy` (the **private** key) | +| Secret | `DEPLOY_KNOWN_HOSTS` | `ssh-keyscan` output | +| Secret | `DEPLOY_HOST` | `` | +| Secret | `DEPLOY_PORT` | `` | +| Secret | `DEPLOY_USER` | `deploy` | +| Variable | `DEPLOY_URL` | `https://api.` | + +Then **Settings → Variables → Actions → New repository variable**: `DEPLOY_ENABLED` = `true`. + +If the GHCR package is private, log the server in once as `deploy`: `docker login ghcr.io` with a `read:packages` token. + +Push any change under `apps/hello-api/`, and watch the `deploy` job run `deploy.sh` over SSH and smoke-test the live URL. + +Optional: add yourself as a **required reviewer** on the `production` environment, so every deploy waits for your click. + ## Code +- [.github/workflows/hello-api.yml](../../.github/workflows/hello-api.yml) +- [apps/hello-api/main_test.go](../../apps/hello-api/main_test.go), [scripts/smoke-test.sh](../../scripts/smoke-test.sh) +- [stacks/hello-api/compose.deploy.yaml](../../stacks/hello-api/compose.deploy.yaml), [stacks/hello-api/deploy.sh](../../stacks/hello-api/deploy.sh) + ## What broke +_Write down errors and fixes here as you go._ + +Verified while preparing this lab: +- `actionlint` and `shellcheck` report no issues; unit tests pass with `-race`. +- Integration job rehearsed locally: stack healthy, smoke test passes, and a wrong expected version makes it fail. +- Multi-arch build works locally (amd64 + arm64); the amd64 variant runs and passes its healthcheck. +- `deploy.sh` rehearsed with two tags; the running version matched each time. The switch has a short outage (→ lab 08). + ## Lessons learned ## References + +- [GitHub Actions: workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax) +- [GitHub Actions: security hardening](https://docs.github.com/en/actions/reference/security/secure-use) +- [Publishing Docker images to GHCR](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry) +- [Docker: multi-platform builds](https://docs.docker.com/build/building/multi-platform/) +- [Docker build cache in GitHub Actions](https://docs.docker.com/build/ci/github-actions/cache/) diff --git a/labs/07-cicd-pipeline/knowledge.md b/labs/07-cicd-pipeline/knowledge.md new file mode 100644 index 0000000..d739043 --- /dev/null +++ b/labs/07-cicd-pipeline/knowledge.md @@ -0,0 +1,161 @@ +# Lab 07 — Knowledge base + +Study this **before** the hands-on part in [README.md](README.md). Add your own notes as you learn. + +## Why this matters + +Until now, every build and deploy was you typing commands. That's slow, easy to get wrong, and impossible to audit. **CI/CD** turns the path from "commit" to "running in production" into code that runs the same way every time, and that stops bad changes early. + +| Term | Meaning | +|---|---| +| **CI** (Continuous Integration) | Every change is built and tested automatically, so problems show up in minutes, not at release time | +| **CD** (Continuous Delivery) | Every change that passes is *ready* to deploy, as a versioned artifact | +| **CD** (Continuous Deployment) | Every change that passes *is* deployed, with no human step | + +This lab builds CI plus continuous delivery (the GHCR image). The `deploy` job is continuous deployment, which you can gate with a required reviewer. + +## Core concepts + +### GitHub Actions vocabulary + +``` +Workflow (.github/workflows/hello-api.yml) + └── triggered by events (push, pull_request, workflow_dispatch) + └── Jobs (test, integration, image, deploy): each on a fresh VM ("runner") + └── Steps: a shell command (run:) or a reusable action (uses:) +``` + +- **Jobs run in parallel** by default. `needs:` makes them a chain, and a failed job skips everything that needs it. +- **Each job gets a clean VM.** Nothing is shared unless you pass it on (`outputs:`, artifacts, or a registry). +- **`if:`** conditions skip jobs (`github.ref == 'refs/heads/main'`). +- **`paths:`** filters skip the whole workflow when unrelated files change. +- **Contexts** like `${{ github.sha }}`, `${{ secrets.X }}`, and `${{ vars.X }}` are filled in before the step runs. + +### The test pyramid + +``` + ▲ few, slow, realistic + ╱ ╲ smoke test on the live site (after deploy) + ╱───╲ integration: real Postgres + Redis via Compose + ╱─────╲ unit tests: handlers with httptest, no network + ▔▔▔▔▔▔▔▔▔ many, fast, isolated +``` + +Unit tests catch logic errors in milliseconds. Integration tests catch "works alone, breaks together" problems: wrong env vars, SQL errors, startup order. The smoke test answers "is the thing we just deployed actually working, and is it the right version?" **Fail fast:** cheap checks run first, so expensive ones don't waste time. + +### Build once, deploy the same artifact + +- The image is built **once**, in CI, tagged with the **commit SHA** (`sha-1a2b3c4`), and that exact image goes to every environment. +- **Never rebuild on the server.** A rebuild could pull different base images or dependencies, so what you tested isn't what you run. +- SHA tags are **immutable and traceable**: from a running container you can find the exact commit. `main` is a convenience tag that **moves**, like `latest`. Deploy by SHA, not by `main`. +- The version is baked in (`-X main.version=sha-…`), so `/` reports it, and the smoke test checks the deploy really switched. + +### Container registries and GHCR + +- A registry stores images. GHCR is `ghcr.io//:`. +- **`GITHUB_TOKEN`** is a short-lived token GitHub creates for each run. With `permissions: packages: write`, it can push to GHCR, so no long-lived password is needed. +- New packages are private. The server needs a `read:packages` token, or the package must be public. + +### Multi-architecture images + +- Your Mac is **arm64**, and most VPSs are **amd64**. One tag can serve both: a **manifest list** points to one image per platform, and `docker pull` picks the right one automatically. +- Building arm64 on an amd64 runner the slow way means **QEMU emulation**. For Go there's a fast way: + + ```dockerfile + FROM --platform=$BUILDPLATFORM golang:… AS build # compiler runs natively + ARG TARGETOS TARGETARCH + RUN GOOS=$TARGETOS GOARCH=$TARGETARCH go build … # Go cross-compiles + ``` + + The final stage (distroless) is pulled for the *target* platform. No emulation is needed. + +### Build cache in CI + +Each runner is fresh, so Docker's layer cache is empty every time. `cache-from/cache-to: type=gha` stores layers in GitHub's cache between runs, so the `go mod download` layer from lab 04 stays cached in CI too. + +### Secrets and least privilege + +| Rule | In this workflow | +|---|---| +| Secrets live in the CI system, never in git | `DEPLOY_SSH_KEY` and others in the `production` environment | +| Least privilege by default | `permissions: contents: read` at the top; `packages: write` only for `image` | +| A key per purpose | A deploy-only SSH key, a dedicated `deploy` user | +| Pin what you trust | `DEPLOY_KNOWN_HOSTS` pins the server's host key. **Never** `StrictHostKeyChecking=no`, which lets anyone pretend to be your server | +| Don't paste untrusted input into scripts | Values go through `env:` instead of `${{ … }}` inside `run:`, which avoids script injection | +| Forked PRs get no secrets | GitHub's default, so a stranger's PR can't read your deploy key | + +**Environments** (`production`) add their own secrets, optional **required reviewers**, and a deploy history. + +### The `docker` group is root + +Adding `deploy` to the `docker` group lets it run `docker run -v /:/host …`, which is root on the server (as with Portainer in lab 06). It's the common, pragmatic setup, but know the trade-off. Stricter options are rootless Docker, or a `sudo` rule that allows only `deploy.sh`. + +### Supply-chain safety for actions + +`uses: actions/checkout@v5` runs someone else's code with access to your workflow. Tags like `v5` can be moved. Hardening options: +- Use few third-party actions; this workflow uses only `actions/*` and `docker/*`. +- Pin actions to a **full commit SHA** (`uses: actions/checkout@ # v5`), and let **Dependabot** propose updates. + +### Concurrency + +- `cancel-in-progress: true` per branch: if you push twice quickly, the first run is cancelled, so you don't waste minutes. +- The deploy uses its own group with `cancel-in-progress: false`: **never kill a deploy halfway**, and never run two at once. + +### Self-hosted runners (stretch) + +A runner is an agent that runs jobs. GitHub-hosted runners are fresh VMs. A **self-hosted** runner runs on your own machine (e.g. the VPS), so a deploy needs no SSH from outside. ⚠️ **Never use self-hosted runners on a public repo**: anyone's PR could run code on your server. + +## Key terms + +| Term | Meaning | +|---|---| +| Workflow / job / step | Pipeline file / a unit that runs on one runner / one command or action | +| Runner | The machine executing a job | +| Artifact | The build output you deploy (here: the image) | +| Immutable tag | A tag that never points to anything else (`sha-…`) | +| Manifest list | One tag → several images, one per platform | +| `GITHUB_TOKEN` | Per-run token with permissions set in the workflow | +| Environment (GitHub) | A deploy target with its own secrets and protection rules | +| Smoke test | A quick check that a deployed system basically works | + +## Commands to know + +| Command | What it does | +|---|---| +| `make test` | `go vet` + `go test -race` for hello-api | +| `scripts/smoke-test.sh [version]` | Check a running instance | +| `stacks/hello-api/deploy.sh ` | Deploy a tag on a server | +| `docker buildx build --platform linux/amd64,linux/arm64 …` | Multi-arch build | +| `docker buildx imagetools inspect ` | Show a tag's platforms | +| `docker compose up -d --wait` | Start and wait until healthy (fails otherwise) | +| `ssh-keyscan -p ` | Fetch a server's host key for `known_hosts` | +| `actionlint` | Lint GitHub Actions workflows | + +## Before you start, can you answer these? + +1. Why does the `image` job run only on pushes to `main`, not on PRs? +2. Why tag images with the commit SHA instead of only `latest` or `main`? +3. What would go wrong if the server ran `docker compose build` instead of pulling the CI image? +4. Why is `StrictHostKeyChecking=no` dangerous in a deploy job? +5. What's the difference between the unit tests and the integration test here? + +## After the lab, check yourself + +1. Draw the pipeline, and say what each job proves. +2. Which permissions does each job have, and why? +3. A deploy succeeded but users still see the old version. Which part of this pipeline would catch that? +4. How does one image tag serve both your Mac and an amd64 VPS? +5. What's the risk of the `deploy` user being in the `docker` group, and what alternatives exist? +6. What happened to the running app during `deploy.sh` switching tags, and why is that a problem? + +## My notes + +_Your own explanations, analogies, and "aha" moments._ + +## Further reading + +- [GitHub Actions: understanding workflows](https://docs.github.com/en/actions/get-started/understand-github-actions) +- [GitHub Actions: secure use reference](https://docs.github.com/en/actions/reference/security/secure-use) +- [Martin Fowler: Continuous Integration](https://martinfowler.com/articles/continuousIntegration.html) +- [Docker: multi-platform builds](https://docs.docker.com/build/building/multi-platform/) +- [The test pyramid](https://martinfowler.com/articles/practical-test-pyramid.html) diff --git a/scripts/README.md b/scripts/README.md index 20df565..c9142fa 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,3 +1,7 @@ # scripts -Standalone shell scripts (backup, maintenance helpers). Each script starts with a usage comment and uses `set -euo pipefail`. First one arrives in lab 03. +Standalone shell scripts. Each starts with a usage comment and uses `set -euo pipefail`. + +| Script | What | Lab | +|---|---|---| +| [smoke-test.sh](smoke-test.sh) | Checks a running hello-api: health, readiness, version, a real write | 07 | diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100755 index 0000000..f5d5c87 --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Smoke-test a running hello-api: health, readiness, and a real write. +# Used by CI after starting the stack, and after each deploy. +# +# Usage: scripts/smoke-test.sh [expected-version] +# scripts/smoke-test.sh http://localhost:8000 +# scripts/smoke-test.sh https://api.example.com sha-1a2b3c4 +set -euo pipefail + +BASE_URL="${1:?usage: $0 [expected-version]}" +EXPECTED_VERSION="${2:-}" +CURL=(curl --silent --show-error --max-time 5 --retry 5 --retry-delay 2 --retry-all-errors) + +fail() { echo "FAIL: $*" >&2; exit 1; } + +echo "Smoke-testing $BASE_URL" + +"${CURL[@]}" --fail "$BASE_URL/health" >/dev/null || fail "/health" +echo " ok /health" + +ready=$("${CURL[@]}" --fail "$BASE_URL/ready") || fail "/ready: $ready" +echo " ok /ready $ready" + +if [[ -n "$EXPECTED_VERSION" ]]; then + version=$("${CURL[@]}" --fail "$BASE_URL/" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p') + [[ "$version" == "$EXPECTED_VERSION" ]] || fail "version is '$version', expected '$EXPECTED_VERSION'" + echo " ok version $version" +fi + +before=$("${CURL[@]}" --fail "$BASE_URL/visits" | sed -n 's/.*"db_total":\([0-9]*\).*/\1/p') +"${CURL[@]}" --fail -X POST "$BASE_URL/visits" >/dev/null || fail "POST /visits" +after=$("${CURL[@]}" --fail "$BASE_URL/visits" | sed -n 's/.*"db_total":\([0-9]*\).*/\1/p') +(( after == before + 1 )) || fail "db_total went from $before to $after, expected +1" +echo " ok POST /visits ($before -> $after)" + +echo "All smoke tests passed." diff --git a/stacks/README.md b/stacks/README.md index 6a15bc8..b7f485f 100644 --- a/stacks/README.md +++ b/stacks/README.md @@ -5,7 +5,7 @@ Docker Compose stacks, one folder per stack (`stacks//compose.yaml`, plus | Stack | What | Lab | |---|---|---| | [traefik](traefik/) | Reverse proxy and single entry point (ports 80/443) | 05 | -| [hello-api](hello-api/) | hello-api + PostgreSQL + Redis; `compose.traefik.yaml` puts it behind Traefik | 04, 05 | +| [hello-api](hello-api/) | hello-api + PostgreSQL + Redis; `compose.traefik.yaml` puts it behind Traefik; `compose.deploy.yaml` + `deploy.sh` run the GHCR image on a server | 04, 05, 07 | | [whoami](whoami/) | Echoes request headers; for testing the proxy | 05 | | [gitea](gitea/) | Self-hosted Git server (`git.`), SSH on 2222 | 06 | | [uptime-kuma](uptime-kuma/) | Uptime monitoring (`status.`) | 06 | diff --git a/stacks/hello-api/compose.deploy.yaml b/stacks/hello-api/compose.deploy.yaml new file mode 100644 index 0000000..451d858 --- /dev/null +++ b/stacks/hello-api/compose.deploy.yaml @@ -0,0 +1,6 @@ +# Override for servers: run the image CI pushed to GHCR instead of building (lab 07). +# Usage (normally via deploy.sh): +# APP_VERSION=sha-1a2b3c4 docker compose -f compose.yaml -f compose.traefik.yaml -f compose.deploy.yaml up -d --no-build +services: + app: + image: ghcr.io/0x48core/hello-api:${APP_VERSION:?set APP_VERSION to an image tag} diff --git a/stacks/hello-api/deploy.sh b/stacks/hello-api/deploy.sh new file mode 100755 index 0000000..7e3389a --- /dev/null +++ b/stacks/hello-api/deploy.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Deploy a hello-api image tag on this server (lab 07). +# CI runs this over SSH; you can also run it by hand. +# +# Usage: ./deploy.sh e.g. ./deploy.sh sha-1a2b3c4 +# PULL=0 ./deploy.sh skip the pull (image already local, e.g. a rehearsal) +# +# Needs: .env next to this file (DB credentials), the proxy network, and Traefik. +set -euo pipefail + +TAG="${1:?usage: $0 }" +cd "$(dirname "$0")" +export APP_VERSION="$TAG" + +compose() { docker compose -f compose.yaml -f compose.traefik.yaml -f compose.deploy.yaml "$@"; } + +[[ -f .env ]] || { echo "missing .env (copy .env.example and set a password)" >&2; exit 1; } + +echo "==> Deploying hello-api $TAG" +if [[ "${PULL:-1}" == "1" ]]; then + compose pull app +fi + +# --wait blocks until the containers are healthy, and fails if they never are. +compose up -d --no-build --wait --wait-timeout 60 + +echo "==> Running: $(docker inspect --format '{{.Config.Image}}' "$(compose ps -q app)")" +echo "==> Deployed $TAG"