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"
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)
})
}
72 changes: 72 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
services:
volume-init:
image: busybox:1.36-musl
volumes:
- quicknotes-data:/data
command: ["sh", "-c", "chown 65532:65532 /data"]
restart: "no"

quicknotes:
build:
context: ./app
dockerfile: Dockerfile
image: quicknotes:lab6
depends_on:
volume-init:
condition: service_completed_successfully
ports:
- "8080:8080"
environment:
ADDR: ":8080"
DATA_PATH: "/data/notes.json"
SEED_PATH: "/seed.json"
volumes:
- quicknotes-data:/data
restart: unless-stopped
healthcheck:
test: ["CMD", "/busybox", "wget", "-qO-", "http://127.0.0.1:8080/health"]
interval: 5s
timeout: 3s
retries: 3
start_period: 10s
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp

prometheus:
image: prom/prometheus:v3.2.1
ports:
- "9090:9090"
volumes:
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./monitoring/prometheus/alert.rules.yml:/etc/prometheus/alert.rules.yml:ro
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --web.enable-lifecycle
depends_on:
quicknotes:
condition: service_healthy
restart: unless-stopped

grafana:
image: grafana/grafana:13.0.2
ports:
- "3000:3000"
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: lab8-grafana-dev
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
depends_on:
- prometheus
restart: unless-stopped

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

## What this alert means

More than 5% of QuickNotes HTTP responses are 4xx or 5xx for at least five minutes — users are seeing failures at a rate worth paging on.

## Triage steps

1. **Confirm the alert** — open Grafana dashboard *QuickNotes Golden Signals* (or Prometheus `/alerts`) and verify the error panel is elevated, not a scrape glitch. Check `curl -s http://localhost:9090/api/v1/targets` shows `quicknotes` target `up`.
2. **Check QuickNotes health** — `curl -s http://localhost:8080/health | jq` and `docker compose logs quicknotes --tail=50` for panics, permission errors on `/data/notes.json`, or OOM kills.
3. **Identify error type** — in Prometheus/Grafana, break down `quicknotes_http_responses_by_code_total` by `code` label. Dominant `400` suggests bad client payloads; `500` suggests application or storage failure.
4. **Check recent changes** — `docker compose ps`, recent deploys, volume mount issues, or config/env drift (`ADDR`, `DATA_PATH`, `SEED_PATH`).

## Mitigations

1. **Restart the service** — `docker compose restart quicknotes` (fastest way to clear a wedged process; data persists on the named volume).
2. **Roll back bad traffic** — if a client or load script is sending malformed requests, stop or fix it; scale down the offending job.
3. **Restore from known-good state** — if data corruption is suspected, stop QuickNotes, restore `/data/notes.json` from backup or re-seed, then `docker compose up -d quicknotes`.

## Post-incident

After the error rate returns to normal and the alert clears:

- Write a blameless postmortem using the [Lecture 1 postmortem template](https://github.com/inno-devops-labs/DevOps-Intro/blob/main/lectures/lec1.md) (timeline, root cause, action items).
- Add or tune alerts/runbooks if the failure mode was not covered.
- Track follow-up tasks (fix validation, add integration test, improve dashboard panel) in your issue tracker.
Loading