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
17 changes: 17 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Pull Request Template

## 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
39 changes: 39 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions app/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
data/
*.md
Makefile
.golangci.yml
Dockerfile
.dockerignore
33 changes: 33 additions & 0 deletions app/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
18 changes: 16 additions & 2 deletions app/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,29 @@ 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
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 {
Expand Down
15 changes: 15 additions & 0 deletions app/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down
25 changes: 25 additions & 0 deletions app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions cloud/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Hugging Face Space (Docker SDK) for QuickNotes

FROM ghcr.io/danielpancake/devops-intro/quicknotes:v0.1.0
16 changes: 16 additions & 0 deletions cloud/README.md
Original file line number Diff line number Diff line change
@@ -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`
29 changes: 29 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -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:
Loading