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

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

permissions:
contents: read

env:
GOFLAGS: -buildvcs=false

jobs:
vet:
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1

- name: Setup Go compiler
uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0
with:
go-version: '1.24'
cache: true
cache-dependency-path: app/go.sum

- name: Run go vet
run: go vet ./...
working-directory: app

test:
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
go: ['1.23', '1.24']
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1

- name: Setup Go compiler
uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0
with:
go-version: ${{ matrix.go }}
cache: true
cache-dependency-path: app/go.sum

- name: Run unit tests
run: go test -race -count=1 ./...
working-directory: app

lint:
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1

- name: Setup Go compiler
uses: actions/setup-go@3041bf56c941b39c61721a86cd11f3bb1338122a # v5.2.0
with:
go-version: '1.24'
cache: true
cache-dependency-path: app/go.sum

- name: Run golangci-lint
uses: golangci/golangci-lint-action@1481404843c368bc19ca9406f87d6e0fc97bdcfd # v7.0.0
with:
version: v2.5.0
working-directory: app

ci-ok:
if: always()
needs: [vet, test, lint]
runs-on: ubuntu-24.04
steps:
- name: Check status of dependent jobs
run: |
if ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}; then
echo "One or more dependent jobs failed or were cancelled."
exit 1
fi
echo "All dependent jobs completed successfully."
36 changes: 36 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Release

on:
push:
tags:
- 'v*'

permissions:
contents: read
packages: write

jobs:
publish:
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1

- name: Log in to GitHub Container Registry
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push image
uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0
with:
context: ./app
push: true
platforms: linux/amd64
tags: |
ghcr.io/hns2112/devops-intro/quicknotes:${{ github.ref_name }}
ghcr.io/hns2112/devops-intro/quicknotes:latest
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,5 @@ 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)
data/
app/data/
24 changes: 24 additions & 0 deletions app/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# syntax=docker/dockerfile:1

FROM golang:1.24.6-bookworm AS builder
WORKDIR /src
COPY go.mod go.su[m] ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build \
-trimpath \
-ldflags='-s -w' \
-o /out/quicknotes .
RUN mkdir -p /data-empty

FROM busybox:1.37-uclibc AS busybox

FROM gcr.io/distroless/static:nonroot
WORKDIR /app
COPY --from=builder /out/quicknotes /app/quicknotes
COPY --from=builder /src/seed.json /app/seed.json
COPY --from=busybox /bin/wget /bin/wget
COPY --from=builder --chown=65532:65532 /data-empty /data
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/quicknotes"]
3 changes: 3 additions & 0 deletions app/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ func (sw *statusWriter) WriteHeader(code int) {
func (s *Server) wrap(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sw := &statusWriter{ResponseWriter: w, code: 200}
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
w.Header().Set("Cache-Control", "no-store")
h(sw, r)
s.requestsTotal.Add(1)
if c, ok := s.requestsByCode[sw.code]; ok {
Expand Down
1 change: 0 additions & 1 deletion app/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,3 @@ func TestMetrics_ExposesPrometheusFormat(t *testing.T) {
}
}
}

42 changes: 42 additions & 0 deletions cloud/render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Cloud deployment — Render (substitute for Hugging Face Spaces)

## Why not Hugging Face

The lab specifies Hugging Face Spaces with the Docker SDK, on the stated grounds
that it is "truly free, no card required". That is no longer true. As of
approximately July 2026 the Spaces documentation states:

> Gradio and Docker Spaces run on compute and require a paid plan to create:
> PRO for personal accounts, Team or Enterprise for organizations.
> Static Spaces are free for everyone.

Creating a Docker Space now requires PRO at $9/month. Static Spaces remain free
but cannot run a compiled Go binary.

## What was used instead

Render.com free web service:

- Deploys directly from an OCI registry — the same ghcr.io image built in Task 1
- Public HTTPS URL, no credit card
- Free instances spin down after ~15 minutes of inactivity and take 50+ seconds
to wake, which is the same scale-to-zero behaviour Task 2.2 asks to measure

## Configuration

| Setting | Value |
|---|---|
| Image | `ghcr.io/hns2112/devops-intro/quicknotes:v0.1.0` |
| Region | Frankfurt (EU Central) |
| Instance | Free — 512 MB RAM, 0.1 CPU |
| Health check path | `/health` |

Environment variables:

| Key | Value | Why |
|---|---|---|
| `ADDR` | `0.0.0.0:10000` | Render expects the service on port 10000; binding to `0.0.0.0` is required for the platform proxy to reach it |
| `DATA_PATH` | `/tmp/notes.json` | the image runs as nonroot with a read-only root filesystem |
| `SEED_PATH` | `/app/seed.json` | absolute path, independent of working directory |

Public URL: https://quicknotes-e5cq.onrender.com
27 changes: 27 additions & 0 deletions cloud/teardown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Teardown

## Render web service

1. Open https://dashboard.render.com
2. Select the `quicknotes` service
3. Settings → scroll to the bottom → Delete Web Service
4. Confirm by typing the service name

Cost if left running: $0. Free instances are billed at nothing and spin down
when idle; the 750 free instance-hours per month are not exceeded by a single
mostly-idle service.

## ghcr.io package

The container image stays in GitHub Packages. To remove it:

1. https://github.com/users/HNS2112/packages/container/devops-intro%2Fquicknotes/settings
2. Danger Zone → Delete this package

Cost if left in place: $0 for public packages.

## Git tag

`git push --delete origin v0.1.0` removes the remote tag; `git tag -d v0.1.0`
removes it locally. Deleting the tag does not delete the already-published
image.
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: /app/seed.json
healthcheck:
test: ["CMD", "/bin/wget", "-q", "-O", "-", "http://127.0.0.1:8080/health"]
interval: 10s
timeout: 3s
retries: 3
start_period: 5s
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp
security_opt:
- no-new-privileges:true
volumes:
- quicknotes-data:/data
restart: unless-stopped

volumes:
quicknotes-data:
15 changes: 15 additions & 0 deletions evidence/lab10/01-ghcr-pull.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
=== Release run (green) ===
https://github.com/HNS2112/DevOps-Intro/actions/runs/31361528580

=== Anonymous pull token obtained from ghcr.io (no credentials) ===
GET https://ghcr.io/token?scope=repository:hns2112/devops-intro/quicknotes:pull

=== Manifest fetched anonymously for tag v0.1.0 ===
mediaType: application/vnd.docker.distribution.manifest.v2+json
config digest: sha256:4a8ed45d3d35ac26ed9b4eea769c86f12035e155ffa585a7e6bb39fc7184ef85
layers: 18
total layer size: ~4.07 MB

=== Image URL ===
ghcr.io/hns2112/devops-intro/quicknotes:v0.1.0
ghcr.io/hns2112/devops-intro/quicknotes:latest
33 changes: 33 additions & 0 deletions evidence/lab10/02-render-warm.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
=== Render service ===
https://quicknotes-e5cq.onrender.com
Service ID: srv-d9sn2rmgekts73900hd0
Image: ghcr.io/hns2112/devops-intro/quicknotes:v0.1.0
Region: Frankfurt (EU Central), Free instance (512 MB RAM, 0.1 CPU)

=== Health check ===
$ curl -s https://quicknotes-e5cq.onrender.com/health
{"notes":4,"status":"ok"}

=== Warm latency, 5 consecutive requests (time_total, seconds) ===
1.252934
1.156695
1.150823
1.144736
1.165416
p50 = 1.156695

=== Latency breakdown (single warm request) ===
dns=0.000000 connect=0.056976 tls=0.902002 ttfb=1.209832 total=1.209883
TLS handshake accounts for 845 ms of the 1210 ms total (70%).

=== /notes endpoint ===
Returns 4 seeded notes (ids 1-4), HTTP 200.

=== Cold start measurements (Render free tier, spins down after ~15 min idle) ===
cold-1 total=25.263881
cold-2 total=14.207056
cold-3 total=14.270426

Note: cold-1 (25.3 s) was measurably slower than cold-2 and cold-3 (~14.2 s).
The first wake included pulling the image onto the node; subsequent wakes only
had to start the container from a cached image.
39 changes: 39 additions & 0 deletions evidence/lab10/03-tunnel.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
=== Cloudflare quick tunnel ===
https://subsequent-workstation-foster-again.trycloudflare.com
Connector ID: c5968e56-8743-4b6c-aeec-ff8fc466a7d8
Edge location: dfw11 (Dallas), protocol: quic
Local origin: http://localhost:8080 (go run, Windows 11)

=== Health check via tunnel ===
{"notes":4,"status":"ok"}

=== Warm latency, 50 requests (time_total, seconds) ===
p50 = 1.4166
p95 = 2.4306
min = 1.3742
max = 2.9096

=== Latency breakdown (single request) ===
dns=0.012468 connect=0.215458 tls=1.029152 ttfb=1.385472 total=1.385518
TLS handshake: 814 ms of 1386 ms total (59%)

=== Pre-checks (from cloudflared startup) ===
DNS Resolution region1/2.v2.argotunnel.com PASS
UDP Connectivity region1/2.v2.argotunnel.com PASS (QUIC)
TCP Connectivity region1/2.v2.argotunnel.com PASS (HTTP/2)
Cloudflare API api.cloudflare.com:443 PASS

=== Verified from a different network path ===
Opened https://subsequent-workstation-foster-again.trycloudflare.com/health
on an Android phone. Response: {"notes": 4, "status": "ok"}

Caveat, stated plainly: the lab asks to verify from cellular data. Cellular was
not available on this network, so the phone used the same Wi-Fi as the host —
but without the VPN the host runs through, so the request left from a different
public IP and took a different route.

This still demonstrates public routability rather than LAN reachability. A quick
tunnel has no LAN path by construction: cloudflared holds an outbound QUIC
connection to a Cloudflare edge (logged as location=dfw11, Dallas) and the
public hostname resolves to Cloudflare's anycast addresses. Any request to that
hostname is served through the edge regardless of where the client sits.
Loading