Skip to content
Open
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
13 changes: 13 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Goal
<!-- What does this PR accomplish? 1 sentence. -->

## Changes
-

## Testing
<!-- How did you verify it? -->

## Checklist
- [ ] Title is a clear sentence (≤ 70 chars)
- [ ] Commits are signed (`git log --show-signature`)
- [ ] `submissions/labN.md` updated
45 changes: 45 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: release

# Task 1 — on a semver tag, build QuickNotes and publish it to ghcr.io.
on:
push:
tags:
- "v*"

# Least privilege: read the repo, write packages (ghcr). Nothing else.
permissions:
contents: read
packages: write

jobs:
build-push:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 # v3.8.0

- name: Log in to ghcr.io
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # auto-issued, packages:write only

- name: Derive image tags (semver + latest)
id: meta
uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5.6.1
with:
images: ghcr.io/${{ github.repository }}/quicknotes
tags: |
type=raw,value=${{ github.ref_name }}
type=raw,value=latest

- name: Build and push
uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0
with:
context: ./app
platforms: linux/amd64 # Hugging Face Spaces runs amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
37 changes: 37 additions & 0 deletions app/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1

# ---- builder ---------------------------------------------------------------
FROM golang:1.25 AS builder
WORKDIR /src

# Copy the module manifest first so `go mod download` is cached independently
# of the source — edits to .go files don't bust the dependency layer.
COPY go.mod ./
RUN go mod download

# Now the source.
COPY . .

# Static, stripped, reproducible binary. CGO off so distroless-static (which has
# no libc / dynamic linker) can run it.
RUN CGO_ENABLED=0 GOOS=linux GOTOOLCHAIN=local \
go build -trimpath -ldflags='-s -w' -o /out/quicknotes .

# A /data dir owned by the nonroot uid, so a fresh named volume mounted here
# inherits writable ownership instead of root-owned.
RUN mkdir -p /data && chown 65532:65532 /data

# ---- runtime ---------------------------------------------------------------
FROM gcr.io/distroless/static:nonroot

COPY --from=builder /out/quicknotes /quicknotes
COPY --from=builder /src/seed.json /seed.json
COPY --from=builder --chown=65532:65532 /data /data

ENV ADDR=:8080 \
DATA_PATH=/data/notes.json \
SEED_PATH=/seed.json

EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/quicknotes"]
21 changes: 19 additions & 2 deletions app/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,32 @@ 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))
mux.HandleFunc("GET /notes", s.wrap(s.handleListNotes))
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
// Security headers applied once, to every route (Lab 9 — ZAP hardening).
return securityHeaders(mux)
}

// securityHeaders wraps the whole router so every response carries a consistent
// set of hardening headers. QuickNotes is a JSON API, so `default-src 'none'`
// (the strictest CSP) is safe — the app serves no HTML/JS/CSS that a CSP could
// break — and no-store keeps note data out of shared caches.
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Content-Security-Policy", "default-src 'none'")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Cache-Control", "no-store")
next.ServeHTTP(w, r)
})
}

type statusWriter struct {
Expand Down
19 changes: 19 additions & 0 deletions app/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,22 @@ func TestMetrics_ExposesPrometheusFormat(t *testing.T) {
}
}

func TestSecurityHeaders_PresentOnEveryResponse(t *testing.T) {
srv := newTestServer(t)
// Hit an ordinary route; the headers come from the router-level middleware,
// so this fails if securityHeaders() is removed from Routes().
rec := do(t, srv, http.MethodGet, "/health", nil)
want := map[string]string{
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
"Cache-Control": "no-store",
}
for k, v := range want {
if got := rec.Header().Get(k); got != v {
t.Errorf("header %s = %q, want %q", k, got, v)
}
}
}

26 changes: 26 additions & 0 deletions app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,19 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
)

func main() {
// Self-healthcheck mode: the distroless runtime image has no shell or curl,
// so the container's HEALTHCHECK re-invokes this same binary (the only one in
// the image) to probe /health. Exit 0 = healthy, 1 = unhealthy.
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")
Expand Down Expand Up @@ -51,6 +59,24 @@ func main() {
}
}

func healthcheck() int {
addr := envOrDefault("ADDR", ":8080")
host := addr
if strings.HasPrefix(addr, ":") {
host = "127.0.0.1" + addr
}
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get("http://" + host + "/health")
if err != nil {
return 1
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return 1
}
return 0
}

func envOrDefault(k, def string) string {
if v, ok := os.LookupEnv(k); ok && v != "" {
return v
Expand Down
10 changes: 10 additions & 0 deletions cloud/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Hugging Face Space (Docker SDK) — serve the exact image the release CI pushed
# to ghcr.io. We PULL the immutable tag rather than rebuild from source, so the
# Space runs the same artifact CI built and Lab 9 scanned (reproducible, no build
# drift). Trade-off discussed in design question (f).
FROM ghcr.io/rikire/devops-intro/quicknotes:v0.1.0

# The base image already: runs as nonroot (uid 65532), ENTRYPOINT ["/quicknotes"],
# listens on :8080 (0.0.0.0), and ships seed data. README.md frontmatter sets
# `app_port: 8080` so HF routes the public URL to this port.
EXPOSE 8080
29 changes: 29 additions & 0 deletions cloud/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
title: QuickNotes
emoji: 📝
colorFrom: blue
colorTo: green
sdk: docker
app_port: 8080
pinned: false
---

# QuickNotes on Hugging Face Spaces

This Space runs the QuickNotes container image published to
`ghcr.io/rikire/devops-intro/quicknotes` by the release CI (Lab 10, Task 1).

The `app_port: 8080` line in the YAML frontmatter above is required: Hugging Face
defaults to port **7860**, but QuickNotes listens on **8080**, so we tell HF to
route the public URL to 8080.

## Endpoints

- `GET /health` → `{"status":"ok","notes":N}`
- `GET /notes` → list notes
- `POST /notes` → create a note
- `GET /metrics` → Prometheus metrics

> This `README.md` and the sibling `Dockerfile` are the two files pushed to the
> Space's own Git repo. They are kept here under `cloud/` for the course
> submission.
17 changes: 17 additions & 0 deletions cloud/teardown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Teardown — Lab 10

Both targets cost $0, but here is how to remove everything.

## Hugging Face Space
- Space → **Settings** → **Delete this Space** (bottom of the page). This removes
the container and the public `*.hf.space` URL. Nothing else is billed.

## ghcr.io image
- Your GitHub profile → **Packages** → `quicknotes` → **Package settings** →
**Delete this package** (or **Manage versions** to delete a single tag).
- To stop future publishes, delete the git tag: `git push origin :refs/tags/v0.1.0`.

## Cloudflare quick tunnel (Bonus)
- Just stop the `cloudflared` process (Ctrl-C). Quick tunnels are ephemeral —
the `*.trycloudflare.com` URL disappears when the process ends. No account,
nothing to clean up.
36 changes: 36 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
services:
quicknotes:
build:
context: ./app
image: quicknotes:lab6
ports:
- "8080:8080"
environment:
ADDR: ":8080"
DATA_PATH: /data/notes.json
SEED_PATH: /seed.json
volumes:
- quicknotes-data:/data
restart: unless-stopped

# --- Hardening: the 6 security defaults (Lecture 6) ---
read_only: true # 4. read-only root filesystem
tmpfs:
- /tmp # ...with a writable tmpfs (the /data volume stays writable)
cap_drop:
- ALL # 3. drop every Linux capability (QuickNotes needs none)
security_opt:
- no-new-privileges:true # 5. block setuid privilege escalation
# (1) USER nonroot and (2) distroless base come from app/Dockerfile.
# (6) Trivy scan documented in submissions/lab6.md.

healthcheck:
# Distroless has no shell/curl — re-invoke the app binary's healthcheck mode.
test: ["CMD", "/quicknotes", "healthcheck"]
interval: 10s
timeout: 3s
retries: 3
start_period: 5s

volumes:
quicknotes-data:
Loading