diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..619f3cb40 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,17 @@ +# Pull Request Template + +## Goal + + +## Changes + +- + +## Testing + + +## Checklist + +- [ ] Title is a clear sentence (≤ 70 chars) +- [ ] Commits are signed (`git log --show-signature`) +- [ ] `submissions/labN.md` updated diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..e8fa70bba --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: read + packages: write + +jobs: + publish: + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 + + - name: Image name + id: img + run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}/quicknotes" >> "$GITHUB_OUTPUT" + + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c + + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a + with: + context: ./app + platforms: linux/amd64 + push: true + provenance: false + tags: | + ${{ steps.img.outputs.name }}:${{ github.ref_name }} + ${{ steps.img.outputs.name }}:latest \ No newline at end of file diff --git a/app/.dockerignore b/app/.dockerignore new file mode 100644 index 000000000..9e3ba3cdc --- /dev/null +++ b/app/.dockerignore @@ -0,0 +1,6 @@ +data/ +*.md +Makefile +.golangci.yml +Dockerfile +.dockerignore diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 000000000..b5f1a2026 --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.26-alpine AS builder +WORKDIR /src + +COPY go.mod ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux \ + go build -trimpath -ldflags='-s -w' -o /out/quicknotes . + +RUN mkdir -p /out/data && chown 65532:65532 /out/data + +FROM gcr.io/distroless/static-debian12:nonroot +WORKDIR / + +COPY --from=builder /out/quicknotes /quicknotes +COPY --from=builder /src/seed.json /seed.json +COPY --from=builder --chown=65532:65532 /out/data /data + +ENV ADDR=:8080 \ + DATA_PATH=/data/notes.json \ + SEED_PATH=/seed.json + +USER nonroot +EXPOSE 8080 + +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ + CMD ["/quicknotes", "healthcheck"] + +ENTRYPOINT ["/quicknotes"] diff --git a/app/handlers.go b/app/handlers.go index c534979c5..1984f0046 100644 --- a/app/handlers.go +++ b/app/handlers.go @@ -26,7 +26,7 @@ func NewServer(store *Store) *Server { return &Server{store: store, requestsByCode: by} } -func (s *Server) Routes() *http.ServeMux { +func (s *Server) Routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /health", s.wrap(s.handleHealth)) mux.HandleFunc("GET /metrics", s.wrap(s.handleMetrics)) @@ -34,7 +34,21 @@ func (s *Server) Routes() *http.ServeMux { mux.HandleFunc("POST /notes", s.wrap(s.handleCreateNote)) mux.HandleFunc("GET /notes/{id}", s.wrap(s.handleGetNote)) mux.HandleFunc("DELETE /notes/{id}", s.wrap(s.handleDeleteNote)) - return mux + return securityHeaders(mux) +} + +// securityHeaders wraps the router so every response carries a baseline set of +// hardening headers. QuickNotes is a JSON API with no browser-rendered HTML, so +// the strictest CSP (default-src 'none') is safe and locks down the whole surface. +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("Referrer-Policy", "no-referrer") + next.ServeHTTP(w, r) + }) } type statusWriter struct { diff --git a/app/handlers_test.go b/app/handlers_test.go index 9dff2e3e5..00c3b84ae 100644 --- a/app/handlers_test.go +++ b/app/handlers_test.go @@ -112,6 +112,21 @@ func TestDeleteNote_RemovesAndReturns204(t *testing.T) { } } +func TestSecurityHeaders_SetOnEveryRoute(t *testing.T) { + srv := newTestServer(t) + // Every route flows through the securityHeaders middleware. If that wrapper + // is removed from Routes(), these headers vanish and this test fails. + for _, target := range []string{"/health", "/notes", "/metrics"} { + rec := do(t, srv, http.MethodGet, target, nil) + if got := rec.Header().Get("Content-Security-Policy"); got != "default-src 'none'; frame-ancestors 'none'" { + t.Errorf("%s: Content-Security-Policy = %q", target, got) + } + if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" { + t.Errorf("%s: X-Content-Type-Options = %q, want nosniff", target, got) + } + } +} + func TestMetrics_ExposesPrometheusFormat(t *testing.T) { srv := newTestServer(t) _ = do(t, srv, http.MethodPost, "/notes", map[string]string{"title": "x"}) diff --git a/app/main.go b/app/main.go index e258ffcfe..0ef16930a 100644 --- a/app/main.go +++ b/app/main.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "fmt" "log" "net/http" "os" @@ -12,6 +13,10 @@ import ( ) func main() { + if len(os.Args) > 1 && os.Args[1] == "healthcheck" { + os.Exit(healthcheck()) + } + addr := envOrDefault("ADDR", ":8080") dataPath := envOrDefault("DATA_PATH", "data/notes.json") seedPath := envOrDefault("SEED_PATH", "seed.json") @@ -51,6 +56,26 @@ func main() { } } +func healthcheck() int { + addr := envOrDefault("ADDR", ":8080") + host := addr + if len(addr) > 0 && addr[0] == ':' { + host = "127.0.0.1" + addr + } + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get("http://" + host + "/health") + if err != nil { + fmt.Fprintln(os.Stderr, "healthcheck:", err) + return 1 + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + fmt.Fprintln(os.Stderr, "healthcheck: status", resp.StatusCode) + return 1 + } + return 0 +} + func envOrDefault(k, def string) string { if v, ok := os.LookupEnv(k); ok && v != "" { return v diff --git a/cloud/Dockerfile b/cloud/Dockerfile new file mode 100644 index 000000000..56874358d --- /dev/null +++ b/cloud/Dockerfile @@ -0,0 +1,3 @@ +# Hugging Face Space (Docker SDK) for QuickNotes + +FROM ghcr.io/danielpancake/devops-intro/quicknotes:v0.1.0 diff --git a/cloud/README.md b/cloud/README.md new file mode 100644 index 000000000..67aec3065 --- /dev/null +++ b/cloud/README.md @@ -0,0 +1,16 @@ +--- +title: QuickNotes +emoji: 📝 +colorFrom: blue +colorTo: green +sdk: docker +app_port: 8080 +pinned: false +--- +# QuickNotes on Hugging Face Spaces + +Runs the hardened QuickNotes container published to GitHub Container Registry and served using Hugging Face's Docker SDK. + +- **Port:** `8080` (`app_port: 8080` is required because HF defaults to `7860`) +- **Image:** `ghcr.io/danielpancake/devops-intro/quicknotes:v0.1.0` (must be public) +- **Endpoints:** `/health`, `/notes`, `/notes/{id}`, `/metrics` diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..8d8082535 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,29 @@ +services: + quicknotes: + build: ./app + image: quicknotes:lab6 + ports: + - "8080:8080" + environment: + ADDR: ":8080" + DATA_PATH: /data/notes.json + SEED_PATH: /seed.json + volumes: + - quicknotes-data:/data + healthcheck: + test: ["CMD", "/quicknotes", "healthcheck"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 5s + restart: unless-stopped + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + +volumes: + quicknotes-data: diff --git a/submissions/lab10.md b/submissions/lab10.md new file mode 100644 index 000000000..3d063c2ef --- /dev/null +++ b/submissions/lab10.md @@ -0,0 +1,152 @@ +# Lab 10: Cloud Computing – Ship QuickNotes to a Real Cloud + +## Task 1: CI Push to GHCR + +### Release workflow + +Workflow: `.github/workflows/release.yml` + +- Triggers on `v*` tags +- Builds QuickNotes from `app/` +- Pushes both `:` and `:latest` +- Uses only `packages: write` +- All third-party actions are pinned to 40-character SHAs + +```yaml +on: + push: + tags: ['v*'] + +permissions: + contents: read + packages: write + +jobs: + publish: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 + - id: img + run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}/quicknotes" >> "$GITHUB_OUTPUT" + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a + with: + context: ./app + push: true + tags: | + ${{ steps.img.outputs.name }}:${{ github.ref_name }} + ${{ steps.img.outputs.name }}:latest +``` + +### Registry + +Package: + +``` +ghcr.io/danielpancake/devops-intro/quicknotes +``` + +Clean pull: + +```text +docker pull ghcr.io/danielpancake/devops-intro/quicknotes:v0.1.0 + +Status: Downloaded newer image +Digest: sha256:5b456746231d865812b6a72dd2341a94b7c4bb60ce5464929989a2c534b01bbf +``` + +### CI Run + +[https://github.com/danielpancake/DevOps-Intro/actions/runs/28894208488](https://github.com/danielpancake/DevOps-Intro/actions/runs/28894208488) + +### Design Questions + +#### a) OIDC vs `GITHUB_TOKEN` + +`GITHUB_TOKEN` with `packages: write` is enough for pushing to GHCR from the same repository. OIDC is mainly used when authenticating to external cloud providers without storing long-lived credentials. + +#### b) Why publish both `latest` and `v0.1.0`? + +The version tag is immutable and should be used for deployments. `latest` is a convenience tag that always points to the newest release. + +#### c) Why only `packages: write`? + +It follows the principle of least privilege. Restricting permissions limits the damage if the workflow or token is compromised. + +# Task 2: Hugging Face Spaces + +## Space URL + +Repository: + +[https://huggingface.co/spaces/danielpancake/quicknotes](https://huggingface.co/spaces/danielpancake/quicknotes) + +Application: + +[https://danielpancake-quicknotes.hf.space](https://danielpancake-quicknotes.hf.space) + +Health check: + +```text +curl -si https://danielpancake-quicknotes.hf.space/health + +HTTP/1.1 200 OK +Content-Type: application/json + +{"notes":4,"status":"ok"} +``` + +## Space Files + +### Dockerfile + +```dockerfile +FROM ghcr.io/danielpancake/devops-intro/quicknotes:v0.1.0 +``` + +### README.md + +```yaml +--- +title: QuickNotes +emoji: 📝 +sdk: docker +app_port: 8080 +--- +``` + +## Latency + +| Measurement | Time (s) | +| ------------------------------- | -------: | +| Warm p50 (5 back-to-back) | 0.571 | +| First request after 35 min idle | 0.853 | + +Warm samples: + +``` +0.731 +0.724 +0.564 +0.554 +0.571 +``` + +## Design Questions + +#### d) Why is HF sleep slower than Cloud Run? + +HF Spaces prioritizes free hosting and shared resources, so waking a container takes longer. Cloud Run is optimized for fast autoscaling. + +#### e) Why `app_port: 8080`? + +HF Docker Spaces default to port **7860**. QuickNotes listens on **8080**, so `app_port: 8080` is required. + +#### f) Pull image vs build inside the Space? + +Pulling from GHCR deploys the same tested image produced by CI. Building inside the Space is more independent but duplicates the build process.