Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file removed .github/workflows/.gitkeep
Empty file.
179 changes: 179 additions & 0 deletions .github/workflows/hello-api.yml
Original file line number Diff line number Diff line change
@@ -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 <<EOF
Host vps
HostName $HOST
Port $PORT
User $USER
IdentityFile ~/.ssh/deploy_key
IdentitiesOnly yes
EOF

- name: Upload stack files
run: |
ssh vps 'mkdir -p ~/opsforge/stacks/hello-api'
scp stacks/hello-api/{compose.yaml,compose.traefik.yaml,compose.deploy.yaml,deploy.sh} \
vps:opsforge/stacks/hello-api/

- name: Deploy
run: ssh vps "~/opsforge/stacks/hello-api/deploy.sh '$TAG'"

- name: Smoke test the live site
run: scripts/smoke-test.sh "$DEPLOY_URL" "$TAG"
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Status: ⬜ todo · 🟨 in progress · ✅ done
### Phase 3 — CI/CD
| # | Lab | Status |
|---|---|---|
| 07 | [CI/CD pipeline](labs/07-cicd-pipeline/) | ⬜ |
| 07 | [CI/CD pipeline](labs/07-cicd-pipeline/) | 🟨 |
| 08 | [Zero-downtime deployment](labs/08-zero-downtime-deploy/) | ⬜ |

### Phase 4 — Infrastructure as Code
Expand Down
7 changes: 5 additions & 2 deletions apps/hello-api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

# ---- Stage 1: build ----------------------------------------------------------
# Full Go toolchain (~300 MB), used only to compile. Not shipped.
FROM golang:1.26-alpine AS build
# --platform=$BUILDPLATFORM: always run the compiler natively and let Go
# cross-compile for the target (fast), instead of emulating it with QEMU (slow).
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
WORKDIR /src

# Download dependencies first: this layer is cached until go.mod/go.sum change,
Expand All @@ -12,9 +14,10 @@ RUN --mount=type=cache,target=/go/pkg/mod go mod download

COPY . .
ARG VERSION=dev
ARG TARGETOS TARGETARCH
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION}" -o /out/hello-api .
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-s -w -X main.version=${VERSION}" -o /out/hello-api .

# ---- Stage 2: run ------------------------------------------------------------
# Distroless: no shell, no package manager, just CA certs, tzdata, and a
Expand Down
6 changes: 5 additions & 1 deletion apps/hello-api/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/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 .

Expand Down
5 changes: 5 additions & 0 deletions apps/hello-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -40,9 +41,13 @@ make build-linux ARCH=amd64 # linux/amd64 → most VPSs
make docker-build # container image hello-api:<version>
```

CI builds multi-arch images (amd64 + arm64) and pushes them to `ghcr.io/0x48core/hello-api:sha-<commit>` 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
46 changes: 25 additions & 21 deletions apps/hello-api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down Expand Up @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions apps/hello-api/main_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading