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

on:
push:
tags:
- "v*"

permissions:
contents: read
packages: write

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/devops-intro/quicknotes

jobs:
build-and-push:
name: Build and push QuickNotes image
runs-on: ubuntu-24.04

steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f

- name: Log in to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Docker metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=tag
type=raw,value=latest

- name: Build and push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
with:
context: ./app
file: ./app/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
10 changes: 0 additions & 10 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
# ONLY contain:
# (a) instructor-only paths (refs/), and
# (b) machine-generated junk that NOBODY should ever commit.
#
# Do NOT add lab DELIVERABLES here (scan reports, SBOMs, go.sum, k8s
# manifests, CI workflows, Dockerfiles, playbooks, dashboards, …). Students
# are told to commit those in their submission PRs β€” ignoring them upstream
Expand All @@ -15,43 +14,34 @@
# Reference submissions (dry-run worked examples). Never pushed upstream;
# students never see these. This is the one path that is intentionally hidden.
refs/

# ── Machine-generated junk (no one commits these) ───────────────
# Compiled binaries / local runtime state
app/quicknotes
app/data/
/quicknotes
*.exe

# Vagrant runtime state (Lab 5) β€” the Vagrantfile IS committed; .vagrant/ is not
.vagrant/

# Nix build symlinks (Lab 11) β€” flake.nix + flake.lock ARE committed; result is not
result
result-*

# Terraform state β€” MUST never be committed (can contain secrets)
*.tfstate
*.tfstate.backup
.terraform/

# Python virtualenvs / caches
.venv/
__pycache__/
*.pyc

# Editor / IDE
.vscode/
.idea/
*.swp

# OS noise
.DS_Store
Thumbs.db

# Local agent config (not part of the course)
.claude/

# NOTE: deliberately NOT ignored, because students commit them as lab evidence:
# submissions/labN.md (lab reports)
# .github/workflows/*.yml (Lab 3 CI)
Expand Down
58 changes: 58 additions & 0 deletions app/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# syntax=docker/dockerfile:1

FROM golang:1.26.4-alpine AS builder

WORKDIR /src

COPY go.mod ./
RUN go mod download

COPY . .

RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o /out/quicknotes .

RUN cat > /tmp/healthcheck.go <<'EOF'
package main

import (
"net/http"
"os"
"time"
)

func main() {
client := http.Client{Timeout: 2 * time.Second}
resp, err := client.Get("http://127.0.0.1:8080/health")
if err != nil {
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
os.Exit(1)
}
}
EOF

RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o /out/healthcheck /tmp/healthcheck.go

# Create an empty /data directory that will be copied into the runtime image
RUN mkdir -p /out/data

FROM gcr.io/distroless/static:nonroot

WORKDIR /

COPY --from=builder /out/quicknotes /quicknotes
COPY --from=builder /out/healthcheck /healthcheck

# Make /data exist in the image and be owned by distroless nonroot UID/GID 65532.
# When Docker creates a fresh named volume, it initializes it from this directory.
COPY --from=builder --chown=65532:65532 /out/data /data

USER nonroot:nonroot

EXPOSE 8080

ENTRYPOINT ["/quicknotes"]
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: securityHeaders(server.Routes()),
ReadHeaderTimeout: 5 * time.Second,
}

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

import "net/http"

func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Security-Policy", "default-src 'none'")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Frame-Options", "DENY")

next.ServeHTTP(w, r)
})
}
34 changes: 34 additions & 0 deletions app/security_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package main

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

func TestSecurityHeadersMiddleware(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})

handler := securityHeaders(next)

req := httptest.NewRequest(http.MethodGet, "/health", nil)
rr := httptest.NewRecorder()

handler.ServeHTTP(rr, req)

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

for header, want := range tests {
if got := rr.Header().Get(header); got != want {
t.Fatalf("%s = %q, want %q", header, got, want)
}
}
}
3 changes: 3 additions & 0 deletions cloud/hf-space/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FROM ghcr.io/minimaxc/devops-intro/quicknotes:v0.1.0

EXPOSE 8080
23 changes: 23 additions & 0 deletions cloud/hf-space/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
title: QuickNotes
emoji: πŸ“
sdk: docker
app_port: 8080
pinned: false
---

# QuickNotes

This Hugging Face Space runs the QuickNotes Docker image published by the Lab 10 GitHub Actions release workflow.

Image:

ghcr.io/minimaxc/devops-intro/quicknotes:v0.1.0

Health endpoint:

/health

Notes endpoint:

/notes
15 changes: 15 additions & 0 deletions cloud/teardown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Lab 10 teardown

## Hugging Face Space

Delete the Space from the Hugging Face Space settings page after the lab if it is no longer needed.

## Local containers

Run:

docker compose down

## Cloudflare quick tunnel

Stop the cloudflared process with Ctrl+C. The trycloudflare URL is ephemeral and stops working when the process exits.
33 changes: 33 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
services:
quicknotes:
build:
context: ./app
dockerfile: Dockerfile
image: quicknotes:lab6
ports:
- "8080:8080"
environment:
ADDR: ":8080"
DATA_PATH: "/data/notes.json"
SEED_PATH: "/app/seed.json"
volumes:
- quicknotes-data:/data
restart: unless-stopped
healthcheck:
test: ["CMD", "/healthcheck"]
interval: 10s
timeout: 3s
retries: 3
start_period: 5s

# Bonus hardening defaults
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true

volumes:
quicknotes-data:
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
5 changes: 4 additions & 1 deletion labs/lab11.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ By the end:
- Read [Reading 11](../lectures/reading11.md)
- Install Nix with Flakes enabled:
- [Determinate Nix Installer](https://determinate.systems/posts/determinate-nix-installer/) (recommended)
- If `install.determinate.systems` is unreachable or times out from your network, use the [official installer](https://nixos.org/download/) (`sh <(curl -L https://nixos.org/nix/install) --daemon`) β€” it is served from a different CDN. Enable flakes afterwards: add `experimental-features = nix-command flakes` to `~/.config/nix/nix.conf`
- β‰₯ 8 GB free disk
- A second machine, fresh Docker container (`docker run -it nixos/nix bash`), or a colleague β€” for verifying reproducibility

Expand All @@ -47,7 +48,7 @@ By the end:

Your `flake.nix` at the **repo root** MUST:

1. Pin **nixpkgs** to a specific channel revision in `inputs:` (e.g. `nixos-24.11`)
1. Pin **nixpkgs** to a specific channel revision in `inputs:` (e.g. `nixos-25.11`) β€” note that `app/go.mod` requires **Go β‰₯ 1.24**, so the channel's default `buildGoModule` must ship at least that (see Common Pitfalls)
2. Expose a package `quicknotes` (and `default`) that **builds the QuickNotes Go source from `app/`**
3. Use `buildGoModule` (or `buildGoApplication`, etc. β€” your choice; document why)
4. Set **`CGO_ENABLED = 0`** so the binary is static
Expand Down Expand Up @@ -248,6 +249,8 @@ In `submissions/lab11.md`:
- πŸͺ€ **Different hashes on two machines** β€” usually means `flake.lock` is not committed. The lockfile pins nixpkgs to a specific revision
- πŸͺ€ **Out of disk** β€” Nix store grows. `nix store gc` reclaims unreferenced paths
- πŸͺ€ **`nix build` requires internet on first run** β€” downloads pre-built artifacts from cache.nixos.org. Subsequent builds are mostly local
- πŸͺ€ **`go.mod requires go >= 1.24` from `buildGoModule`** β€” your pinned nixpkgs ships an older default Go (e.g. `nixos-24.11` β†’ Go 1.23). Fix it **in the flake**: pin `nixos-25.11` or newer, or use `buildGo124Module` / `buildGoModule.override { go = pkgs.go_1_24; }`. Don't downgrade `app/go.mod` β€” the app source is not yours to edit
- πŸͺ€ **Installer or build times out on `install.determinate.systems`** β€” the host may be unreachable from your network even when a plain `curl -I` returns 200. Check from the *same terminal* where you run nix (a browser VPN does not cover WSL2 traffic): `curl -I https://install.determinate.systems` vs `curl -I https://cache.nixos.org/nix-cache-info`. Fall back to the official nixos.org installer (different CDN); builds themselves only need cache.nixos.org and github.com. If cache.nixos.org is also blocked, use a mirror substituter: `--option substituters "https://mirrors.tuna.tsinghua.edu.cn/nix-channels/store"`
- πŸͺ€ **WSL2 multi-user Nix is finicky** β€” use the Determinate installer; or single-user on WSL2

---
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