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
128 changes: 128 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
name: ci

on:
push:
branches: [main]
paths:
- 'app/**'
- '.github/workflows/**'
pull_request:
branches: [main]
paths:
- 'app/**'
- '.github/workflows/**'

permissions:
contents: read

env:
GOFLAGS: -buildvcs=false

jobs:
changes:
name: changes
runs-on: ubuntu-24.04
outputs:
app-go: ${{ steps.filter.outputs.app-go }}
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.2.2
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: filter
with:
filters: |
app-go:
- 'app/**/*.go'
- 'app/go.mod'
- 'app/go.sum'
- 'app/.golangci.yml'

vet:
name: vet
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
go: ['1.23', '1.24']
defaults:
run:
working-directory: app
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.2.2
with:
fetch-depth: 1
- uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0
with:
go-version: ${{ matrix.go }}
cache: true
cache-dependency-path: app/go.mod
- run: go vet ./...

test:
name: test
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
go: ['1.23', '1.24']
defaults:
run:
working-directory: app
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.2.2
with:
fetch-depth: 1
- uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0
with:
go-version: ${{ matrix.go }}
cache: true
cache-dependency-path: app/go.mod
- run: go test -race -count=1 ./...

lint:
name: lint
needs: changes
if: needs.changes.outputs.app-go == 'true'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.2.2
with:
fetch-depth: 1
- uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4.2.2
with:
path: |
~/.cache/golangci-lint
~/go/bin
key: golangci-lint-${{ runner.os }}-v2.5.0-${{ hashFiles('app/.golangci.yml') }}
restore-keys: |
golangci-lint-${{ runner.os }}-v2.5.0-
- uses: golangci/golangci-lint-action@1481404843c368bc19ca9406f87d6e0fc97bdcfd # v7.0.0
with:
version: v2.5.0
working-directory: app

govulncheck:
name: govulncheck
runs-on: ubuntu-24.04
defaults:
run:
working-directory: app
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.2.2
with:
fetch-depth: 1
- uses: actions/setup-go@0aaccfd150d50ccaeb58ebd88d36e91967a5f35b # v5.4.0
with:
go-version: '1.25.11'
cache: true
cache-dependency-path: app/go.mod
- run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
- run: govulncheck ./...

ci-ok:
name: ci-ok
if: always()
needs: [vet, test, lint, govulncheck]
runs-on: ubuntu-24.04
steps:
- run: |
test "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "false"
43 changes: 43 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: release

on:
push:
tags:
- 'v*'

permissions:
contents: read
packages: write

env:
REGISTRY: ghcr.io

jobs:
release:
name: release
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.2.2

- id: image
run: |
repo="$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')"
echo "name=ghcr.io/${repo}/quicknotes" >> "$GITHUB_OUTPUT"

- uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0

- uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: app
file: app/Dockerfile
platforms: linux/amd64
push: true
tags: |
${{ steps.image.outputs.name }}:${{ github.ref_name }}
${{ steps.image.outputs.name }}:latest
21 changes: 21 additions & 0 deletions app/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1

FROM golang:1.24-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 /quicknotes .

FROM busybox:1.36-musl AS busybox

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /quicknotes /quicknotes
COPY --from=builder /src/seed.json /seed.json
COPY --from=busybox /bin/busybox /busybox
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/quicknotes"]
4 changes: 4 additions & 0 deletions app/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ func NewServer(store *Store) *Server {
return &Server{store: store, requestsByCode: by}
}

func (s *Server) Handler() http.Handler {
return securityHeaders(s.Routes())
}

func (s *Server) Routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.wrap(s.handleHealth))
Expand Down
25 changes: 24 additions & 1 deletion app/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func do(t *testing.T, srv *Server, method, target string, body any) *httptest.Re
}
req := httptest.NewRequest(method, target, &buf)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
srv.Handler().ServeHTTP(rec, req)
return rec
}

Expand Down Expand Up @@ -131,3 +131,26 @@ func TestMetrics_ExposesPrometheusFormat(t *testing.T) {
}
}

func TestSecurityHeaders_PresentOnAllRoutes(t *testing.T) {
srv := newTestServer(t)
rec := do(t, srv, http.MethodGet, "/health", nil)
for _, key := range []string{
"X-Content-Type-Options",
"X-Frame-Options",
"Content-Security-Policy",
"Referrer-Policy",
"Permissions-Policy",
"Cache-Control",
} {
if got := rec.Header().Get(key); got == "" {
t.Errorf("missing %s header", key)
}
}
if got := rec.Header().Get("X-Frame-Options"); got != "DENY" {
t.Errorf("X-Frame-Options: got %q want DENY", got)
}
if got := rec.Header().Get("Content-Security-Policy"); got != "default-src 'none'" {
t.Errorf("CSP: got %q want default-src 'none'", got)
}
}

2 changes: 1 addition & 1 deletion app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func main() {
server := NewServer(store)
srv := &http.Server{
Addr: addr,
Handler: server.Routes(),
Handler: server.Handler(),
ReadHeaderTimeout: 5 * time.Second,
}

Expand Down
18 changes: 18 additions & 0 deletions app/security.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package main

import "net/http"

// securityHeaders applies baseline HTTP security headers to every response.
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("X-Frame-Options", "DENY")
h.Set("Content-Security-Policy", "default-src 'none'")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
h.Set("Cache-Control", "no-cache, no-store, must-revalidate, private")
h.Set("Pragma", "no-cache")
next.ServeHTTP(w, r)
})
}
4 changes: 4 additions & 0 deletions cloud/hf-space/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Pull the CI-built image (linux/amd64) instead of rebuilding in HF.
# Trade-off: faster Space builds and identical artifact to ghcr.io release;
# requires public package visibility on first push.
FROM ghcr.io/markovav-official/devops-intro/quicknotes:v0.1.0
19 changes: 19 additions & 0 deletions cloud/hf-space/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
title: QuickNotes Lab 10
emoji: 📝
colorFrom: blue
colorTo: green
sdk: docker
app_port: 8080
pinned: false
license: mit
---

# QuickNotes on Hugging Face Spaces

DevOps Intro Lab 10 — same container image as [`ghcr.io/markovav-official/devops-intro/quicknotes`](https://github.com/markovav-official/DevOps-Intro/pkgs/container/devops-intro%2Fquicknotes).

- `/health` — liveness
- `/notes` — JSON API

Built from the course fork; image pushed by `.github/workflows/release.yml` on `v*` tags.
9 changes: 9 additions & 0 deletions cloud/scripts/measure-cold.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Single request after idle sleep — use between runs when Space is sleeping.
set -euo pipefail

URL="${1:?usage: $0 <base-url>}"
PATH_SUFFIX="${2:-/health}"
TARGET="${URL%/}${PATH_SUFFIX}"

curl -w 'time_total=%{time_total}s http_code=%{http_code}\n' -o /dev/null -s "$TARGET"
20 changes: 20 additions & 0 deletions cloud/scripts/measure-warm.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Warm latency: 5 consecutive requests, print times and p50 (median).
set -euo pipefail

URL="${1:?usage: $0 <base-url>}"
PATH_SUFFIX="${2:-/health}"
TARGET="${URL%/}${PATH_SUFFIX}"

times=()
for _ in 1 2 3 4 5; do
t=$(curl -w '%{time_total}' -o /dev/null -s "$TARGET")
times+=("$t")
printf '%s\n' "$t"
done

python3 - <<'PY' "${times[@]}"
import statistics, sys
vals = [float(x) for x in sys.argv[1:]]
print(f"p50: {statistics.median(vals):.4f}s")
PY
21 changes: 21 additions & 0 deletions cloud/scripts/tunnel-hyperfine.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Bonus: warm p50/p95 via hyperfine (50 runs) against a public tunnel URL.
set -euo pipefail

URL="${1:?usage: $0 <https://xxx.trycloudflare.com>}"
TARGET="${URL%/}/health"

command -v hyperfine >/dev/null || { echo "install hyperfine: brew install hyperfine"; exit 1; }

hyperfine --warmup 5 --runs 50 --export-json /dev/stdout \
"curl -fsS -o /dev/null $TARGET" | python3 -c "
import json, sys, statistics
d = json.load(sys.stdin)
t = [r['times'][0] for r in d['results']]
t.sort()
n = len(t)
p50 = t[n//2]
p95 = t[int(n*0.95)-1]
print(f'p50: {p50:.4f}s')
print(f'p95: {p95:.4f}s')
"
22 changes: 22 additions & 0 deletions cloud/teardown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Teardown

## Hugging Face Space

1. Open [huggingface.co/spaces](https://huggingface.co/spaces) → your Space → **Settings**.
2. Scroll to **Delete this Space** → confirm.

## ghcr.io package

The image stays in GitHub Packages (free). To remove:

1. Repo → **Packages** → `devops-intro/quicknotes` → **Package settings** → **Delete package**.

## Cloudflare quick tunnel

Stop the `cloudflared` process (`Ctrl+C`). The `*.trycloudflare.com` URL is discarded automatically.

## Local containers

```bash
docker compose down
```
Loading