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

on:
push:
tags: ['v*']

# Least privilege: read the repo, publish the package. Nothing else.
permissions:
contents: read
packages: write

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

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

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

- id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ghcr.io/${{ github.repository }}/quicknotes
# v0.1.0 (immutable, straight from the git tag) + latest (moving alias)
tags: |
type=semver,pattern={{raw}}
type=raw,value=latest
flavor: |
latest=false

- uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
with:
context: app
platforms: linux/amd64 # HF Spaces runs linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# plain manifest, no provenance attestation index — keeps the image
# trivially pullable by HF Spaces and plain `docker pull`
provenance: false
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,4 @@ Thumbs.db
# *.sbom.cdx.json, zap-*.html/json, trivy-*.txt (Lab 9 scan evidence)
# flake.nix, flake.lock (Lab 11)
# wasm/main.go, spin.toml, go.sum (Lab 12)
.env
32 changes: 32 additions & 0 deletions app/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# ─── builder stage ───
# 1.26: Go 1.24 stdlib (1.24.13) carries 10 HIGH CVEs, fixes only land in 1.25.8+/1.26.x
FROM golang:1.26-alpine AS builder
WORKDIR /src


COPY go.mod ./
RUN go mod download

# Then the source.
COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags='-s -w' -o /quicknotes .

RUN mkdir -p /data && chown 65532:65532 /data

# ─── runtime stage ───
FROM gcr.io/distroless/static:nonroot

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

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

USER nonroot
EXPOSE 8080
# exec form: distroless has no shell; the binary self-probes /health (Trivy AVD-DS-0026)
HEALTHCHECK --interval=10s --timeout=3s --retries=5 CMD ["/quicknotes", "healthcheck"]
ENTRYPOINT ["/quicknotes"]
12 changes: 11 additions & 1 deletion app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ func main() {
dataPath := envOrDefault("DATA_PATH", "data/notes.json")
seedPath := envOrDefault("SEED_PATH", "seed.json")

// `quicknotes healthcheck` probes /health on loopback and exits 0/1.
// The distroless image has no shell or curl, so the binary checks itself.
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
resp, err := http.Get("http://127.0.0.1" + addr + "/health")
if err != nil || resp.StatusCode != http.StatusOK {
os.Exit(1)
}
os.Exit(0)
}

if err := ensureSeeded(dataPath, seedPath); err != nil {
log.Fatalf("seed: %v", err)
}
Expand All @@ -28,7 +38,7 @@ func main() {
server := NewServer(store)
srv := &http.Server{
Addr: addr,
Handler: server.Routes(),
Handler: server.Handler(),
ReadHeaderTimeout: 5 * time.Second,
}

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

import "net/http"

// securityHeaders sets baseline security headers on every response.
// QuickNotes is a JSON API: the strictest CSP is safe because responses are
// never rendered as a document that loads sub-resources.
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")
h.Set("Cache-Control", "no-store")
h.Set("Cross-Origin-Resource-Policy", "same-origin")
next.ServeHTTP(w, r)
})
}

// Handler is the full HTTP stack: router wrapped in security middleware.
// main() and the tests must both use this, so the middleware can't be
// silently dropped from production wiring without a test failing.
func (s *Server) Handler() http.Handler {
return securityHeaders(s.Routes())
}
48 changes: 48 additions & 0 deletions app/middleware_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package main

import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
)

// TestSecurityHeaders_PresentOnAllRoutes goes through Server.Handler() — the
// exact stack main() serves. If securityHeaders is removed from Handler(),
// every assertion here fails.
func TestSecurityHeaders_PresentOnAllRoutes(t *testing.T) {
srv := newTestServer(t)
n, err := srv.store.Create("t", "b")
if err != nil {
t.Fatalf("create: %v", err)
}

want := map[string]string{
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
"Cache-Control": "no-store",

"Cross-Origin-Resource-Policy": "same-origin",
}

targets := []struct{ method, path string }{
{http.MethodGet, "/health"},
{http.MethodGet, "/metrics"},
{http.MethodGet, "/notes"},
{http.MethodGet, "/notes/" + strconv.Itoa(n.ID)},
{http.MethodGet, "/no-such-route"}, // 404s must carry headers too
}

for _, tc := range targets {
req := httptest.NewRequest(tc.method, tc.path, nil)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
for header, value := range want {
if got := rec.Header().Get(header); got != value {
t.Errorf("%s %s: header %s = %q, want %q", tc.method, tc.path, header, got, value)
}
}
}
}
1 change: 1 addition & 0 deletions cloud/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FROM ghcr.io/dnau15/devops-intro/quicknotes:v0.1.0
17 changes: 17 additions & 0 deletions cloud/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
title: QuickNotes
emoji: 📝
colorFrom: indigo
colorTo: blue
sdk: docker
app_port: 8080
pinned: false
---

# QuickNotes

Tiny Go notes API from the Innopolis DevOps course, deployed as a Docker Space.
The image is pulled from `ghcr.io/dnau15/devops-intro/quicknotes:v0.1.0`
(built and pushed by the tag-triggered release workflow in the course fork).

Endpoints: `GET /health`, `GET /notes`, `POST /notes`.
18 changes: 18 additions & 0 deletions cloud/teardown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Teardown

## Hugging Face Space
Space settings (https://huggingface.co/spaces/Dnau15/quicknotes/settings)
→ Delete this Space → type the name to confirm.
Costs nothing while running (free CPU tier, sleeps after ~30 min idle), so
leaving it up is also fine.

## ghcr.io image
https://github.com/Dnau15?tab=packages → quicknotes → Package settings
→ Delete this package. (Left up for grading.)

## Cloudflare quick tunnel
Ctrl-C the `cloudflared` process — the ephemeral URL dies with it.
Nothing persists: no account, no config, no DNS records.

## Local container
docker compose down # or: docker stop qn-ghcr
58 changes: 58 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# compose.yaml (repo root)
services:
# ── From Lab 6 (shown for completeness; keep YOUR Lab 6 definition) ──
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:
# Lab 6's healthcheck. If your runtime is distroless-static (no shell, no
# wget) and this never goes "healthy", either keep Lab 6's working check
# or change prometheus' depends_on below to `condition: service_started`.
test: ["CMD", "/quicknotes", "healthcheck"]
interval: 10s
timeout: 3s
retries: 5
start_period: 5s
restart: unless-stopped

# ── NEW: Prometheus (Task 1.4.1) ──
prometheus:
image: prom/prometheus:v3.5.0 # pinned, real version — not :latest
volumes:
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./monitoring/prometheus/rules:/etc/prometheus/rules:ro
command:
- --config.file=/etc/prometheus/prometheus.yml
ports:
- "9090:9090" # browse the Prometheus UI
depends_on:
quicknotes:
condition: service_healthy # Lab 6 healthcheck pays off here
restart: unless-stopped

# ── NEW: Grafana (Task 1.4.2) ──
grafana:
image: grafana/grafana:13.0.2 # pinned, real version
environment:
GF_SECURITY_ADMIN_USER: admin
# Don't ship default creds. Override at runtime; see section 5.
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-changeme-please}
volumes:
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
ports:
- "3000:3000"
depends_on:
- prometheus
restart: unless-stopped

volumes:
quicknotes-data:
34 changes: 34 additions & 0 deletions docs/runbook/high-error-rate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Runbook — QuickNotes High Error Rate

**Alert:** `QuickNotesHighErrorRate` · **Severity:** page

## What this alert means
More than 5% of HTTP responses from QuickNotes have been 4xx/5xx for at least
5 minutes — users are seeing real failures right now, not a one-off blip.

## Triage steps (in order)
1. **Confirm it's real, not a probe artifact.** Open Grafana →
*QuickNotes — Golden Signals* (http://localhost:3000) and check the **Errors**
panel is still above the red 5% line, and **Traffic** is non-zero (a divide on
near-zero traffic can spike the ratio).
2. **Find which status code dominates.** In Prometheus (http://localhost:9090),
run `topk(5, sum by (code) (rate(quicknotes_http_responses_by_code_total[5m])))`.
`5xx` ⇒ server/app fault; mostly `4xx` ⇒ bad client traffic or a broken caller.
3. **Read the logs around the spike.** `docker compose logs --since=15m quicknotes`.
Look for panics, `failed to persist note`, or a flood of `invalid JSON body`.
4. **Check saturation & the host.** Glance at the **Saturation** panel and
`docker compose ps` / `docker stats quicknotes` — is the container restarting,
OOM-killed, or out of disk for `/data`?

## Mitigations (stop the bleeding)
- **Roll back** to the last known-good image: redeploy the previous
`quicknotes` tag and `docker compose up -d quicknotes`.
- **Restart the service** to clear a wedged process: `docker compose restart quicknotes`.
- **Shed bad traffic** if the errors are an abusive/broken client: block the
source upstream (reverse proxy / firewall) until the caller is fixed.

## Post-incident
Once errors are back below 5%, mark the alert resolved and write a blameless
postmortem using the Lecture 1 template (`lectures/` → postmortem template):
timeline, root cause, what detected it (this alert), and the follow-up actions
to prevent recurrence.
20 changes: 16 additions & 4 deletions labs/lab1.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,20 @@ git config --global commit.gpgsign true
git config --global tag.gpgsign true
```

Tell the platform your SSH key is a **signing key**:
- GitHub: Settings → SSH and GPG keys → **New SSH key**, key type **Signing Key**
- GitLab: Profile → SSH Keys → tick "Usage type: Authentication & signing"
Now register the key on the platform. GitHub treats **Authentication** and **Signing** as *separate* roles for the same key, so you add it under both:

- **Authentication Key** — lets you `clone` / `fetch` / `push` over SSH (`git@github.com:…`). If you cloned over HTTPS, or have never seen `ssh -T git@github.com` greet you by name, you don't have one configured yet — add it now or the `upstream` SSH remote will fail in Lab 2.
- **Signing Key** — gives your commits the **Verified** badge.

- 🐙 GitHub: Settings → SSH and GPG keys → **New SSH key** → add the **same** `~/.ssh/id_ed25519.pub` **twice**, once with Key type **Authentication Key** and once with **Signing Key**.
- 🦊 GitLab: Profile → SSH Keys → a single key with **Usage type: Authentication & signing** covers both.

Confirm authentication works before moving on:

```bash
ssh -T git@github.com
# expect: Hi YOUR_USERNAME! You've successfully authenticated...
```

### 1.4: Make a Signed Commit

Expand Down Expand Up @@ -303,7 +314,8 @@ In `submissions/lab1.md`:
## Common Pitfalls

- 🪤 **PR template doesn't auto-populate** — make sure the template is on `main` *before* opening the PR
- 🪤 **Commits show "Unverified"** — the SSH key must be added as a *Signing Key* on GitHub (not just an authentication key)
- 🪤 **Commits show "Unverified"** — the key must also be added as a **Signing Key** on GitHub; an Authentication Key alone won't verify commits (they're separate roles — see §1.3)
- 🪤 **`git@github.com: Permission denied (publickey)` on clone/fetch/push** — the *reverse* gap: your key is registered for signing but not as an **Authentication Key**. Add it as Authentication too (§1.3) and confirm with `ssh -T git@github.com`. Quick unblock for the *public* upstream: `git remote set-url upstream https://github.com/inno-devops-labs/DevOps-Intro.git`
- 🪤 **`git push` rejected on `main`** — that's the bonus rule working as designed; push to `feature/lab1` instead
- 🪤 **`gpg.format=ssh` ignored** — confirm Git ≥ 2.34: `git --version`
- 🪤 **Pushed to the wrong branch** — `git switch feature/lab1` before `git push`
Expand Down
1 change: 1 addition & 0 deletions labs/lab2.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ git bisect reset

## Common Pitfalls

- 🪤 **`git@github.com: Permission denied (publickey)` on `git fetch upstream`** — *not* a remote-config bug (the error is at the SSH layer, before Git reads the repo). Your key isn't registered for **authentication** on GitHub — and a **Signing Key** (Lab 1) does *not* count for auth, they're separate roles. Add the same `~/.ssh/id_ed25519.pub` as an **Authentication Key** (Lab 1 §1.3), verify with `ssh -T git@github.com`, then re-run. To unblock right now, the public upstream fetches over HTTPS with no key: `git remote set-url upstream https://github.com/inno-devops-labs/DevOps-Intro.git`
- 🪤 **`reset --hard` without committing first** — your *uncommitted* edits really *are* gone (reflog only saves committed work). Always check `git status` first
- 🪤 **`tag -v` says "no signature"** — you used `git tag NAME` instead of `git tag -a -s NAME -m "..."`
- 🪤 **Rebase conflicts** — resolve, then `git rebase --continue`. Never `git rebase --skip` unless you know what you're skipping
Expand Down
Loading