Compare commits

...

13 commits

Author SHA1 Message Date
26529a837b docs: record clean lint run
Some checks failed
CI and deploy / verify (push) Successful in 2m37s
CI and deploy / publish (push) Failing after 1s
CI and deploy / deploy (push) Has been skipped
2026-08-26 18:50:55 +02:00
99fde46ed3 ci: publish and deploy verified releases 2026-08-24 23:36:36 +02:00
3fa278d447 feat(deploy): add blue-green release manifests 2026-08-24 23:36:28 +02:00
0d8d8c43be fix(security): harden release images 2026-08-24 23:36:18 +02:00
3547d0bf3a docs: record deployment checks 2026-08-24 23:18:16 +02:00
fd7608227d feat(provision): automate the Fedora lab 2026-08-24 23:18:09 +02:00
0dd6f003f3 test: add local rollback lab 2026-08-24 23:18:01 +02:00
a622739053 feat(obs): add telemetry stack 2026-08-24 23:17:53 +02:00
10c8e2c884 build: package local and mini pc services 2026-08-24 23:17:49 +02:00
3355f8b7ef feat(web): add ocean telemetry dashboard 2026-08-24 23:17:43 +02:00
e1f728ff9b feat(loadgen): sustain rollback traffic 2026-08-24 23:17:13 +02:00
6c1329c2a9 feat(api): serve buoy telemetry 2026-08-24 23:17:08 +02:00
af050c9751 chore: keep local build artifacts out of git 2026-08-24 23:17:03 +02:00
82 changed files with 5547 additions and 0 deletions

25
.dockerignore Normal file
View file

@ -0,0 +1,25 @@
.git
.gitignore
.forgejo
.github
.codex
.claude
.cursor
.ci
.idea
.vscode
AGENTS.md
PLAN.md
docs
terraform
deploy
**/.env
**/.env.*
**/*_test.go
**/testdata
**/fixtures
bin
dist
apps/api/api
apps/loadgen/loadgen
coverage.*

3
.forgejo/actionlint.yaml Normal file
View file

@ -0,0 +1,3 @@
self-hosted-runner:
labels:
- docker

View file

@ -0,0 +1,181 @@
name: CI and deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
verify:
runs-on: docker
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Scan secrets
run: |
docker run --rm -v "$PWD:/repo" \
ghcr.io/gitleaks/gitleaks:v8.30.1 \
detect --source /repo --redact -v
- name: Test API
run: |
docker run --rm -v "$PWD/apps/api:/src" -w /src golang:1.26 \
bash -ec 'go vet ./... && go test -race ./...'
- name: Test load generator
run: |
docker run --rm -v "$PWD/apps/loadgen:/src" -w /src golang:1.26 \
bash -ec 'go vet ./... && go test -race ./... && test "$(wc -l < main.go)" -lt 200'
- name: Lint Go
run: |
docker run --rm -v "$PWD:/repo" -w /repo/apps/api \
golangci/golangci-lint:v2.12.2 golangci-lint run
docker run --rm -v "$PWD:/repo" -w /repo/apps/loadgen \
golangci/golangci-lint:v2.12.2 golangci-lint run
- name: Check reachable Go vulnerabilities
run: |
docker run --rm -v "$PWD/apps/api:/src" -w /src golang:1.26 \
bash -ec 'go run golang.org/x/vuln/cmd/govulncheck@v1.7.0 ./...'
docker run --rm -v "$PWD/apps/loadgen:/src" -w /src golang:1.26 \
bash -ec 'go run golang.org/x/vuln/cmd/govulncheck@v1.7.0 ./...'
- name: Check frontend and shell syntax
run: |
docker run --rm -v "$PWD:/repo" -w /repo node:24-alpine \
node --check apps/web/app.js
docker run --rm -v "$PWD:/repo" -w /repo bash:5.3 \
bash -ec 'for file in scripts/k3d/lab.sh scripts/provision/bootstrap.sh scripts/provision/lab.sh; do bash -n "$file"; done'
- name: Validate Compose and Kubernetes configuration
env:
CLUSTER_API_UPSTREAM: 192.0.2.1:8080
NEREUS_HOST: nereus.example.test
run: |
docker compose -f compose.yaml config -q
docker compose -f build/compose.mini.yaml config -q
for path in \
deploy/base \
deploy/overlays/dev \
deploy/overlays/prod \
deploy/rollouts \
deploy/observability
do
docker run --rm -v "$PWD:/work" -w /work \
registry.k8s.io/kubectl:v1.33.4 kustomize "$path" >/dev/null
done
- name: Scan configuration
run: |
docker run --rm -v "$PWD:/repo" -w /repo aquasec/trivy:0.74.0 \
config --severity HIGH,CRITICAL --exit-code 1 \
--skip-dirs .git --skip-dirs terraform .
publish:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: verify
runs-on: docker
env:
API_IMAGE: git.fiwlabs.dev/fiwdev/nereus-api:${{ github.sha }}
LOADGEN_IMAGE: git.fiwlabs.dev/fiwdev/nereus-loadgen:${{ github.sha }}
WEB_IMAGE: git.fiwlabs.dev/fiwdev/nereus-web:${{ github.sha }}
DOCKER_CONFIG: ${{ github.workspace }}/.ci/docker
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Log in to registry
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
install -d -m 0700 "$DOCKER_CONFIG"
printf '%s' "$REGISTRY_PASSWORD" | \
docker login git.fiwlabs.dev --username "$REGISTRY_USERNAME" --password-stdin
- name: Build and push images
run: |
docker build -f build/api.Dockerfile \
--build-arg "APP_VERSION=${{ github.sha }}" \
-t "$API_IMAGE" .
docker build -f build/loadgen.Dockerfile -t "$LOADGEN_IMAGE" .
docker build -f build/web.Dockerfile -t "$WEB_IMAGE" .
for image in "$API_IMAGE" "$LOADGEN_IMAGE" "$WEB_IMAGE"
do
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:0.74.0 image --severity HIGH,CRITICAL \
--ignore-unfixed --exit-code 1 "$image"
done
docker push "$API_IMAGE"
docker push "$LOADGEN_IMAGE"
docker push "$WEB_IMAGE"
- name: Remove registry credentials
if: always()
run: rm -rf .ci
deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: publish
runs-on: docker
env:
API_IMAGE: git.fiwlabs.dev/fiwdev/nereus-api:${{ github.sha }}
LOADGEN_IMAGE: git.fiwlabs.dev/fiwdev/nereus-loadgen:${{ github.sha }}
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Prepare cluster access
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
run: |
install -d -m 0700 .ci
printf '%s' "$KUBECONFIG_B64" | base64 -d >.ci/kubeconfig
chmod 0600 .ci/kubeconfig
printf '%s' "$REGISTRY_PASSWORD" | \
docker --config "$PWD/.ci/docker" login git.fiwlabs.dev \
--username "$REGISTRY_USERNAME" --password-stdin
- name: Apply and verify rollout
run: |
kube() {
docker run --rm --network host \
-v "$PWD:/work" -w /work \
registry.k8s.io/kubectl:v1.33.4 \
--kubeconfig=/work/.ci/kubeconfig "$@"
}
kube create namespace nereus --dry-run=client -o yaml | kube apply -f -
kube create secret generic nereus-registry \
--namespace nereus \
--type kubernetes.io/dockerconfigjson \
--from-file=.dockerconfigjson=/work/.ci/docker/config.json \
--dry-run=client -o yaml | kube apply -f -
kube get secret nereus-db --namespace nereus >/dev/null
kube apply -k deploy/observability
kube apply -k deploy/overlays/prod
kube patch rollout.argoproj.io nereus-api --namespace nereus --type merge \
--patch "{\"spec\":{\"template\":{\"spec\":{\"containers\":[{\"name\":\"api\",\"image\":\"$API_IMAGE\"}]}}}}"
kube set image deployment/nereus-loadgen --namespace nereus \
"loadgen=$LOADGEN_IMAGE"
kube set image deployment/nereus-loadgen-preview --namespace nereus \
"loadgen=$LOADGEN_IMAGE"
kube rollout status deployment/nereus-loadgen --namespace nereus --timeout=5m
kube rollout status deployment/nereus-loadgen-preview --namespace nereus --timeout=5m
kube wait rollout.argoproj.io/nereus-api --namespace nereus \
--for=jsonpath='{.status.phase}'=Healthy --timeout=10m
kube get rollout.argoproj.io/nereus-api --namespace nereus
- name: Remove cluster credentials
if: always()
run: rm -rf .ci

7
.gitignore vendored
View file

@ -75,6 +75,7 @@ CLAUDE.local.md
.direnv/
.envrc
.ci/
# ---------------------------------------------------------------------------
# Terraform. The lock file is committed on purpose; state never is.
@ -94,11 +95,17 @@ override.tf.json
# ---------------------------------------------------------------------------
bin/
dist/
apps/api/api
apps/loadgen/loadgen
node_modules/
*.test
*.out
coverage.*
vendor/
!apps/web/vendor/
!apps/web/vendor/leaflet.css
!apps/web/vendor/leaflet.js
!apps/web/vendor/LEAFLET-LICENSE
# ---------------------------------------------------------------------------
# OS noise

11
apps/api/context.go Normal file
View file

@ -0,0 +1,11 @@
package main
import (
"context"
"net/http"
"time"
)
func contextWithTimeout(r *http.Request, timeout time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(r.Context(), timeout)
}

45
apps/api/go.mod Normal file
View file

@ -0,0 +1,45 @@
module git.fiwlabs.dev/fiwdev/nereus/apps/api
go 1.26
require (
github.com/go-chi/chi/v5 v5.2.3
github.com/jackc/pgx/v5 v5.10.0
github.com/prometheus/client_golang v1.23.2
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.39.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
google.golang.org/grpc v1.82.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

109
apps/api/go.sum Normal file
View file

@ -0,0 +1,109 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

341
apps/api/http.go Normal file
View file

@ -0,0 +1,341 @@
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math"
mathrand "math/rand/v2"
"net/http"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel/trace"
)
type api struct {
store store
metrics *metrics
logger *slog.Logger
version string
chaosRate float64
migrated *atomic.Bool
prometheus prometheus.Gatherer
}
func (a *api) routes() http.Handler {
r := chi.NewRouter()
r.Use(a.versionHeader)
r.Use(a.observeRequest)
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
r.Get("/readyz", a.ready)
r.Handle("/metrics", promhttp.HandlerFor(a.prometheus, promhttp.HandlerOpts{}))
r.Route("/api/v1", func(r chi.Router) {
r.Use(a.chaos)
r.Get("/buoys", a.listBuoys)
r.Post("/buoys", a.createBuoy)
r.Get("/buoys/{id}", a.getBuoy)
r.Delete("/buoys/{id}", a.deleteBuoy)
r.Get("/readings", a.listReadings)
r.Post("/readings", a.createReading)
r.Get("/readings/aggregate", a.aggregateReadings)
})
return otelhttp.NewHandler(r, "http.request")
}
func (a *api) versionHeader(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Nereus-Version", a.version)
next.ServeHTTP(w, r)
})
}
type responseRecorder struct {
http.ResponseWriter
status int
}
func (w *responseRecorder) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
func (a *api) observeRequest(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
recorder := &responseRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(recorder, r)
path := chi.RouteContext(r.Context()).RoutePattern()
if path == "" {
path = "unmatched"
}
status := strconv.Itoa(recorder.status)
a.metrics.requests.WithLabelValues(r.Method, path, status, a.version).Inc()
a.metrics.duration.WithLabelValues(r.Method, path, a.version).Observe(time.Since(started).Seconds())
span := trace.SpanFromContext(r.Context()).SpanContext()
a.logger.InfoContext(r.Context(), "request", "method", r.Method, "path", path, "status", recorder.status, "duration_ms", time.Since(started).Milliseconds(), "trace_id", span.TraceID().String())
})
}
func (a *api) chaos(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if a.chaosRate > 0 && mathrand.Float64() < a.chaosRate {
writeError(w, http.StatusInternalServerError, "injected failure")
return
}
next.ServeHTTP(w, r)
})
}
func (a *api) ready(w http.ResponseWriter, r *http.Request) {
ctx, cancel := contextWithTimeout(r, 2*time.Second)
defer cancel()
if !a.migrated.Load() || a.store.Ping(ctx) != nil {
writeError(w, http.StatusServiceUnavailable, "database unavailable")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}
func (a *api) listBuoys(w http.ResponseWriter, r *http.Request) {
items, err := a.store.ListBuoys(r.Context())
if err != nil {
a.internalError(w, r, err)
return
}
if items == nil {
items = []buoy{}
}
a.metrics.active.Set(float64(len(items)))
writeJSON(w, http.StatusOK, items)
}
func (a *api) createBuoy(w http.ResponseWriter, r *http.Request) {
var input buoy
if !decodeJSON(w, r, &input) {
return
}
input.Name = strings.TrimSpace(input.Name)
if input.Name == "" || len(input.Name) > 120 || input.Latitude < -90 || input.Latitude > 90 || input.Longitude < -180 || input.Longitude > 180 {
writeError(w, http.StatusBadRequest, "invalid buoy")
return
}
id, err := newUUID()
if err != nil {
a.internalError(w, r, err)
return
}
input.ID = id
created, err := a.store.CreateBuoy(r.Context(), input)
if err != nil {
a.internalError(w, r, err)
return
}
a.metrics.active.Inc()
writeJSON(w, http.StatusCreated, created)
}
func (a *api) getBuoy(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !validUUID(id) {
writeError(w, http.StatusBadRequest, "invalid buoy ID")
return
}
item, err := a.store.GetBuoy(r.Context(), id)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "buoy not found")
return
}
if err != nil {
a.internalError(w, r, err)
return
}
writeJSON(w, http.StatusOK, item)
}
func (a *api) deleteBuoy(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !validUUID(id) {
writeError(w, http.StatusBadRequest, "invalid buoy ID")
return
}
err := a.store.DeleteBuoy(r.Context(), id)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "buoy not found")
return
}
if err != nil {
a.internalError(w, r, err)
return
}
a.metrics.active.Dec()
w.WriteHeader(http.StatusNoContent)
}
func (a *api) listReadings(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
from, ok := optionalTime(w, query.Get("from"))
if !ok {
return
}
to, ok := optionalTime(w, query.Get("to"))
if !ok {
return
}
if from != nil && to != nil && from.After(*to) {
writeError(w, http.StatusBadRequest, "from must not be after to")
return
}
if id := query.Get("buoy_id"); id != "" && !validUUID(id) {
writeError(w, http.StatusBadRequest, "invalid buoy ID")
return
}
limit, ok := boundedInt(query.Get("limit"), 100, 1, 1000)
if !ok {
writeError(w, http.StatusBadRequest, "invalid limit")
return
}
offset, ok := boundedInt(query.Get("offset"), 0, 0, math.MaxInt)
if !ok {
writeError(w, http.StatusBadRequest, "invalid offset")
return
}
items, err := a.store.ListReadings(r.Context(), query.Get("buoy_id"), from, to, limit, offset)
if err != nil {
a.internalError(w, r, err)
return
}
if items == nil {
items = []reading{}
}
writeJSON(w, http.StatusOK, items)
}
func (a *api) createReading(w http.ResponseWriter, r *http.Request) {
var input reading
if !decodeJSON(w, r, &input) {
return
}
if !validUUID(input.BuoyID) || input.WaveHeight < 0 || input.Salinity != nil && *input.Salinity < 0 {
writeError(w, http.StatusBadRequest, "invalid reading")
return
}
id, err := newUUID()
if err != nil {
a.internalError(w, r, err)
return
}
input.ID = id
created, err := a.store.CreateReading(r.Context(), input)
if err != nil {
a.internalError(w, r, err)
return
}
a.metrics.ingested.Inc()
writeJSON(w, http.StatusCreated, created)
}
func (a *api) aggregateReadings(w http.ResponseWriter, r *http.Request) {
window, err := time.ParseDuration(r.URL.Query().Get("window"))
if err != nil || window < time.Minute || window > 24*time.Hour {
writeError(w, http.StatusBadRequest, "window must be between 1m and 24h")
return
}
items, err := a.store.AggregateReadings(r.Context(), window)
if err != nil {
a.internalError(w, r, err)
return
}
if items == nil {
items = []aggregate{}
}
writeJSON(w, http.StatusOK, items)
}
func (a *api) internalError(w http.ResponseWriter, r *http.Request, err error) {
a.logger.ErrorContext(r.Context(), "request failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(dst); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "request body must contain one JSON object")
return false
}
return true
}
func optionalTime(w http.ResponseWriter, value string) (*time.Time, bool) {
if value == "" {
return nil, true
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid timestamp")
return nil, false
}
return &parsed, true
}
func boundedInt(value string, fallback, min, max int) (int, bool) {
if value == "" {
return fallback, true
}
parsed, err := strconv.Atoi(value)
return parsed, err == nil && parsed >= min && parsed <= max
}
func validUUID(value string) bool {
if len(value) != 36 || value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-' {
return false
}
compact := strings.ReplaceAll(value, "-", "")
_, err := hex.DecodeString(compact)
return err == nil
}
func newUUID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("generate UUID: %w", err)
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
encoded := make([]byte, 36)
hex.Encode(encoded[0:8], b[0:4])
encoded[8] = '-'
hex.Encode(encoded[9:13], b[4:6])
encoded[13] = '-'
hex.Encode(encoded[14:18], b[6:8])
encoded[18] = '-'
hex.Encode(encoded[19:23], b[8:10])
encoded[23] = '-'
hex.Encode(encoded[24:36], b[10:16])
return string(encoded), nil
}

256
apps/api/http_test.go Normal file
View file

@ -0,0 +1,256 @@
package main
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/prometheus/client_golang/prometheus"
)
type fakeStore struct{ pingErr error }
const (
testBuoyID = "00000000-0000-4000-8000-000000000001"
missingBuoyID = "00000000-0000-4000-8000-000000000002"
)
func (f *fakeStore) Ping(context.Context) error { return f.pingErr }
func (f *fakeStore) Migrate(context.Context) error { return nil }
func (f *fakeStore) Close() {}
func (f *fakeStore) ListBuoys(context.Context) ([]buoy, error) {
return []buoy{{ID: testBuoyID, Name: "Atlantic"}}, nil
}
func (f *fakeStore) CreateBuoy(_ context.Context, b buoy) (buoy, error) {
b.CreatedAt = time.Unix(1, 0)
return b, nil
}
func (f *fakeStore) GetBuoy(_ context.Context, id string) (buoy, error) {
if id == missingBuoyID {
return buoy{}, pgx.ErrNoRows
}
return buoy{ID: id}, nil
}
func (f *fakeStore) DeleteBuoy(_ context.Context, id string) error {
if id == missingBuoyID {
return pgx.ErrNoRows
}
return nil
}
func (f *fakeStore) ListReadings(context.Context, string, *time.Time, *time.Time, int, int) ([]reading, error) {
return []reading{}, nil
}
func (f *fakeStore) CreateReading(_ context.Context, r reading) (reading, error) {
r.RecordedAt = time.Unix(1, 0)
return r, nil
}
func (f *fakeStore) AggregateReadings(context.Context, time.Duration) ([]aggregate, error) {
return []aggregate{}, nil
}
type failingStore struct {
fakeStore
operation string
}
var errDatabaseUnavailable = errors.New("database unavailable")
func (f *failingStore) ListBuoys(context.Context) ([]buoy, error) {
if f.operation == "list buoys" {
return nil, errDatabaseUnavailable
}
return f.fakeStore.ListBuoys(context.Background())
}
func (f *failingStore) CreateBuoy(ctx context.Context, b buoy) (buoy, error) {
if f.operation == "create buoy" {
return buoy{}, errDatabaseUnavailable
}
return f.fakeStore.CreateBuoy(ctx, b)
}
func (f *failingStore) GetBuoy(ctx context.Context, id string) (buoy, error) {
if f.operation == "get buoy" {
return buoy{}, errDatabaseUnavailable
}
return f.fakeStore.GetBuoy(ctx, id)
}
func (f *failingStore) DeleteBuoy(ctx context.Context, id string) error {
if f.operation == "delete buoy" {
return errDatabaseUnavailable
}
return f.fakeStore.DeleteBuoy(ctx, id)
}
func (f *failingStore) ListReadings(ctx context.Context, buoyID string, from, to *time.Time, limit, offset int) ([]reading, error) {
if f.operation == "list readings" {
return nil, errDatabaseUnavailable
}
return f.fakeStore.ListReadings(ctx, buoyID, from, to, limit, offset)
}
func (f *failingStore) CreateReading(ctx context.Context, r reading) (reading, error) {
if f.operation == "create reading" {
return reading{}, errDatabaseUnavailable
}
return f.fakeStore.CreateReading(ctx, r)
}
func (f *failingStore) AggregateReadings(ctx context.Context, window time.Duration) ([]aggregate, error) {
if f.operation == "aggregate readings" {
return nil, errDatabaseUnavailable
}
return f.fakeStore.AggregateReadings(ctx, window)
}
func testAPI(s store, chaos float64) http.Handler {
handler, _ := testAPIWithMetrics(s, chaos)
return handler
}
func testAPIWithMetrics(s store, chaos float64) (http.Handler, *metrics) {
reg := prometheus.NewRegistry()
ready := &atomic.Bool{}
ready.Store(true)
m := newMetrics(reg)
a := &api{store: s, metrics: m, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), version: "test", chaosRate: chaos, migrated: ready, prometheus: reg}
return a.routes(), m
}
func TestHandlers(t *testing.T) {
tests := []struct {
name, method, path, body string
want int
}{
{"health", http.MethodGet, "/healthz", "", http.StatusOK},
{"ready", http.MethodGet, "/readyz", "", http.StatusOK},
{"metrics", http.MethodGet, "/metrics", "", http.StatusOK},
{"list buoys", http.MethodGet, "/api/v1/buoys", "", http.StatusOK},
{"create buoy", http.MethodPost, "/api/v1/buoys", `{"name":"North","latitude":42,"longitude":-8}`, http.StatusCreated},
{"invalid buoy", http.MethodPost, "/api/v1/buoys", `{"name":"","latitude":42,"longitude":-8}`, http.StatusBadRequest},
{"get buoy", http.MethodGet, "/api/v1/buoys/" + testBuoyID, "", http.StatusOK},
{"missing buoy", http.MethodGet, "/api/v1/buoys/" + missingBuoyID, "", http.StatusNotFound},
{"invalid buoy ID", http.MethodGet, "/api/v1/buoys/not-a-uuid", "", http.StatusBadRequest},
{"delete buoy", http.MethodDelete, "/api/v1/buoys/" + testBuoyID, "", http.StatusNoContent},
{"list readings", http.MethodGet, "/api/v1/readings?limit=10", "", http.StatusOK},
{"bad timestamp", http.MethodGet, "/api/v1/readings?from=yesterday", "", http.StatusBadRequest},
{"create reading", http.MethodPost, "/api/v1/readings", `{"buoy_id":"` + testBuoyID + `","water_temp":14,"wave_height":2}`, http.StatusCreated},
{"aggregate", http.MethodGet, "/api/v1/readings/aggregate?window=1h", "", http.StatusOK},
{"bad aggregate", http.MethodGet, "/api/v1/readings/aggregate?window=bad", "", http.StatusBadRequest},
}
handler := testAPI(&fakeStore{}, 0)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
if recorder.Code != tt.want {
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, tt.want, recorder.Body.String())
}
if got := recorder.Header().Get("X-Nereus-Version"); got != "test" {
t.Errorf("X-Nereus-Version = %q, want test", got)
}
})
}
}
func TestReadinessFailure(t *testing.T) {
recorder := httptest.NewRecorder()
testAPI(&fakeStore{pingErr: context.DeadlineExceeded}, 0).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable)
}
}
func TestChaosMiddleware(t *testing.T) {
recorder := httptest.NewRecorder()
testAPI(&fakeStore{}, 1).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/buoys", nil))
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusInternalServerError)
}
recorder = httptest.NewRecorder()
testAPI(&fakeStore{}, 1).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("health status = %d, want %d", recorder.Code, http.StatusOK)
}
}
func TestDataEndpointsReturnInternalErrorOnDatabaseFailure(t *testing.T) {
tests := []struct {
name, method, path, body string
}{
{"list buoys", http.MethodGet, "/api/v1/buoys", ""},
{"create buoy", http.MethodPost, "/api/v1/buoys", `{"name":"North","latitude":42,"longitude":-8}`},
{"get buoy", http.MethodGet, "/api/v1/buoys/" + testBuoyID, ""},
{"delete buoy", http.MethodDelete, "/api/v1/buoys/" + testBuoyID, ""},
{"list readings", http.MethodGet, "/api/v1/readings", ""},
{"create reading", http.MethodPost, "/api/v1/readings", `{"buoy_id":"` + testBuoyID + `","water_temp":14,"wave_height":2}`},
{"aggregate readings", http.MethodGet, "/api/v1/readings/aggregate?window=1h", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
testAPI(&failingStore{operation: tt.name}, 0).ServeHTTP(recorder, httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body)))
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusInternalServerError, recorder.Body.String())
}
if recorder.Body.String() != "{\"error\":\"internal error\"}\n" {
t.Fatalf("body = %q", recorder.Body.String())
}
})
}
}
func TestJSONBodyValidation(t *testing.T) {
tests := []struct {
name, body string
}{
{"malformed", `{"name":`},
{"multiple values", `{"name":"North","latitude":42,"longitude":-8} {}`},
{"oversized", `{"name":"` + strings.Repeat("x", 1<<20) + `","latitude":42,"longitude":-8}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
testAPI(&fakeStore{}, 0).ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/buoys", strings.NewReader(tt.body)))
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadRequest)
}
})
}
}
func TestMetricNamesAndRouteTemplateLabels(t *testing.T) {
handler, metrics := testAPIWithMetrics(&fakeStore{}, 0)
metrics.db.WithLabelValues("test").Observe(0)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/buoys/"+testBuoyID, nil))
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/metrics", nil))
body := recorder.Body.String()
for _, name := range []string{
"nereus_http_requests_total",
"nereus_http_request_duration_seconds",
"nereus_db_query_duration_seconds",
"nereus_readings_ingested_total",
"nereus_buoys_active",
} {
if !strings.Contains(body, "# HELP "+name+" ") {
t.Errorf("metrics output does not contain %q", name)
}
}
if !strings.Contains(body, `nereus_http_requests_total{method="GET",path="/api/v1/buoys/{id}",status="200",version="test"} 1`) {
t.Error("request counter does not contain the exact labels and buoy route template")
}
if !strings.Contains(body, `nereus_http_request_duration_seconds_count{method="GET",path="/api/v1/buoys/{id}",version="test"} 1`) {
t.Error("request histogram does not contain the exact labels and buoy route template")
}
if strings.Contains(body, `path="/api/v1/buoys/`+testBuoyID+`"`) {
t.Error("metrics output contains a resolved resource path")
}
}

View file

@ -0,0 +1,31 @@
package main
import (
"context"
"os"
"testing"
"github.com/prometheus/client_golang/prometheus"
)
func TestPostgresIntegration(t *testing.T) {
if testing.Short() {
t.Skip("integration test disabled in short mode")
}
dsn, configured := os.LookupEnv("DATABASE_URL")
if !configured {
t.Skip("DATABASE_URL is not configured")
}
m := newMetrics(prometheus.NewRegistry())
db, err := newPostgresStore(context.Background(), dsn, m)
if err != nil {
t.Fatalf("new store: %v", err)
}
defer db.Close()
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := db.Ping(context.Background()); err != nil {
t.Fatalf("ping: %v", err)
}
}

165
apps/api/main.go Normal file
View file

@ -0,0 +1,165 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"sync/atomic"
"syscall"
"time"
"github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.34.0"
)
var appVersion = "dev"
type config struct {
port string
database string
chaosRate float64
logLevel slog.Level
traceReady bool
}
func main() {
if err := run(); err != nil {
slog.Error("startup failed", "error", err)
os.Exit(1)
}
}
func run() error {
cfg, err := loadConfig()
if err != nil {
return err
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.logLevel}))
slog.SetDefault(logger)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
shutdownTrace, err := configureTracing(ctx, cfg.traceReady)
if err != nil {
return err
}
defer func() {
if err := shutdownTrace(context.Background()); err != nil {
logger.Error("trace shutdown failed", "error", err)
}
}()
registry := prometheus.NewRegistry()
m := newMetrics(registry)
db, err := newPostgresStore(ctx, cfg.database, m)
if err != nil {
return err
}
defer db.Close()
var migrated atomic.Bool
go migrateUntilReady(ctx, db, &migrated, logger)
application := &api{store: db, metrics: m, logger: logger, version: appVersion, chaosRate: cfg.chaosRate, migrated: &migrated, prometheus: registry}
server := &http.Server{
Addr: ":" + cfg.port,
Handler: application.routes(),
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
errCh := make(chan error, 1)
go func() { errCh <- server.ListenAndServe() }()
logger.Info("server started", "port", cfg.port, "version", appVersion)
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown server: %w", err)
}
return nil
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return fmt.Errorf("serve HTTP: %w", err)
}
}
func loadConfig() (config, error) {
cfg := config{port: envOr("PORT", "8080"), database: os.Getenv("DATABASE_URL"), traceReady: os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != ""}
if cfg.database == "" {
return config{}, errors.New("DATABASE_URL is required")
}
chaos, err := strconv.ParseFloat(envOr("CHAOS_ERROR_RATE", "0.0"), 64)
if err != nil || chaos < 0 || chaos > 1 {
return config{}, errors.New("CHAOS_ERROR_RATE must be between 0.0 and 1.0")
}
cfg.chaosRate = chaos
switch envOr("LOG_LEVEL", "info") {
case "debug":
cfg.logLevel = slog.LevelDebug
case "info":
cfg.logLevel = slog.LevelInfo
case "warn":
cfg.logLevel = slog.LevelWarn
case "error":
cfg.logLevel = slog.LevelError
default:
return config{}, errors.New("LOG_LEVEL must be debug, info, warn, or error")
}
return cfg, nil
}
func envOr(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func configureTracing(ctx context.Context, enabled bool) (func(context.Context) error, error) {
if !enabled {
return func(context.Context) error { return nil }, nil
}
exporter, err := otlptracegrpc.New(ctx)
if err != nil {
return nil, fmt.Errorf("create trace exporter: %w", err)
}
res, err := resource.New(ctx, resource.WithAttributes(semconv.ServiceName("nereus-api")))
if err != nil {
return nil, fmt.Errorf("create trace resource: %w", err)
}
provider := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter), sdktrace.WithResource(res))
otel.SetTracerProvider(provider)
return provider.Shutdown, nil
}
func migrateUntilReady(ctx context.Context, db store, ready *atomic.Bool, logger *slog.Logger) {
for {
attempt, cancel := context.WithTimeout(ctx, 5*time.Second)
err := db.Migrate(attempt)
cancel()
if err == nil {
ready.Store(true)
logger.Info("database migrations applied")
return
}
logger.Warn("database unavailable; migration will retry", "error", err)
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
}
}
}

23
apps/api/metrics.go Normal file
View file

@ -0,0 +1,23 @@
package main
import "github.com/prometheus/client_golang/prometheus"
type metrics struct {
requests *prometheus.CounterVec
duration *prometheus.HistogramVec
db *prometheus.HistogramVec
ingested prometheus.Counter
active prometheus.Gauge
}
func newMetrics(reg prometheus.Registerer) *metrics {
m := &metrics{
requests: prometheus.NewCounterVec(prometheus.CounterOpts{Name: "nereus_http_requests_total", Help: "HTTP requests processed."}, []string{"method", "path", "status", "version"}),
duration: prometheus.NewHistogramVec(prometheus.HistogramOpts{Name: "nereus_http_request_duration_seconds", Help: "HTTP request duration."}, []string{"method", "path", "version"}),
db: prometheus.NewHistogramVec(prometheus.HistogramOpts{Name: "nereus_db_query_duration_seconds", Help: "Database query duration."}, []string{"operation"}),
ingested: prometheus.NewCounter(prometheus.CounterOpts{Name: "nereus_readings_ingested_total", Help: "Readings successfully ingested."}),
active: prometheus.NewGauge(prometheus.GaugeOpts{Name: "nereus_buoys_active", Help: "Current number of buoys."}),
}
reg.MustRegister(m.requests, m.duration, m.db, m.ingested, m.active)
return m
}

View file

@ -0,0 +1,25 @@
BEGIN;
-- Replicas start together during a rollout, so serialize schema creation.
SELECT pg_advisory_xact_lock(hashtext('nereus_schema_migration'));
CREATE TABLE IF NOT EXISTS buoys (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
latitude DOUBLE PRECISION NOT NULL,
longitude DOUBLE PRECISION NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS readings (
id UUID PRIMARY KEY,
buoy_id UUID NOT NULL REFERENCES buoys(id) ON DELETE CASCADE,
water_temp DOUBLE PRECISION NOT NULL,
wave_height DOUBLE PRECISION NOT NULL,
salinity DOUBLE PRECISION,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_readings_buoy_time ON readings (buoy_id, recorded_at DESC);
COMMIT;

28
apps/api/model.go Normal file
View file

@ -0,0 +1,28 @@
package main
import "time"
type buoy struct {
ID string `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Latitude float64 `db:"latitude" json:"latitude"`
Longitude float64 `db:"longitude" json:"longitude"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
type reading struct {
ID string `db:"id" json:"id"`
BuoyID string `db:"buoy_id" json:"buoy_id"`
WaterTemp float64 `db:"water_temp" json:"water_temp"`
WaveHeight float64 `db:"wave_height" json:"wave_height"`
Salinity *float64 `db:"salinity" json:"salinity,omitempty"`
RecordedAt time.Time `db:"recorded_at" json:"recorded_at"`
}
type aggregate struct {
Bucket time.Time `db:"bucket" json:"bucket"`
AverageTemp float64 `db:"average_water_temp" json:"average_water_temp"`
AverageWave float64 `db:"average_wave_height" json:"average_wave_height"`
AverageSaline *float64 `db:"average_salinity" json:"average_salinity"`
Count int64 `db:"count" json:"count"`
}

159
apps/api/store.go Normal file
View file

@ -0,0 +1,159 @@
package main
import (
"context"
_ "embed"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"go.opentelemetry.io/otel"
)
//go:embed migrations/001_init.sql
var migrationSQL string
type store interface {
Ping(context.Context) error
Migrate(context.Context) error
ListBuoys(context.Context) ([]buoy, error)
CreateBuoy(context.Context, buoy) (buoy, error)
GetBuoy(context.Context, string) (buoy, error)
DeleteBuoy(context.Context, string) error
ListReadings(context.Context, string, *time.Time, *time.Time, int, int) ([]reading, error)
CreateReading(context.Context, reading) (reading, error)
AggregateReadings(context.Context, time.Duration) ([]aggregate, error)
Close()
}
type postgresStore struct {
pool *pgxpool.Pool
metrics *metrics
}
func newPostgresStore(ctx context.Context, dsn string, m *metrics) (*postgresStore, error) {
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("create database pool: %w", err)
}
return &postgresStore{pool: pool, metrics: m}, nil
}
func (s *postgresStore) observe(ctx context.Context, operation string) (context.Context, func()) {
ctx, span := otel.Tracer("nereus-api/database").Start(ctx, operation)
started := time.Now()
return ctx, func() {
s.metrics.db.WithLabelValues(operation).Observe(time.Since(started).Seconds())
span.End()
}
}
func (s *postgresStore) Ping(ctx context.Context) error { return s.pool.Ping(ctx) }
func (s *postgresStore) Close() { s.pool.Close() }
func (s *postgresStore) Migrate(ctx context.Context) error {
ctx, done := s.observe(ctx, "migrate")
defer done()
if _, err := s.pool.Exec(ctx, migrationSQL); err != nil {
return fmt.Errorf("apply migrations: %w", err)
}
return nil
}
func (s *postgresStore) ListBuoys(ctx context.Context) ([]buoy, error) {
ctx, done := s.observe(ctx, "list_buoys")
defer done()
rows, err := s.pool.Query(ctx, `SELECT id, name, latitude, longitude, created_at FROM buoys ORDER BY created_at`)
if err != nil {
return nil, fmt.Errorf("query buoys: %w", err)
}
defer rows.Close()
items, err := pgx.CollectRows(rows, pgx.RowToStructByName[buoy])
if err != nil {
return nil, fmt.Errorf("collect buoys: %w", err)
}
return items, nil
}
func (s *postgresStore) CreateBuoy(ctx context.Context, b buoy) (buoy, error) {
ctx, done := s.observe(ctx, "create_buoy")
defer done()
err := s.pool.QueryRow(ctx, `INSERT INTO buoys (id,name,latitude,longitude) VALUES ($1,$2,$3,$4) RETURNING created_at`, b.ID, b.Name, b.Latitude, b.Longitude).Scan(&b.CreatedAt)
if err != nil {
return buoy{}, fmt.Errorf("insert buoy: %w", err)
}
return b, nil
}
func (s *postgresStore) GetBuoy(ctx context.Context, id string) (buoy, error) {
ctx, done := s.observe(ctx, "get_buoy")
defer done()
var b buoy
err := s.pool.QueryRow(ctx, `SELECT id, name, latitude, longitude, created_at FROM buoys WHERE id=$1`, id).Scan(&b.ID, &b.Name, &b.Latitude, &b.Longitude, &b.CreatedAt)
if err != nil {
return buoy{}, fmt.Errorf("select buoy: %w", err)
}
return b, nil
}
func (s *postgresStore) DeleteBuoy(ctx context.Context, id string) error {
ctx, done := s.observe(ctx, "delete_buoy")
defer done()
tag, err := s.pool.Exec(ctx, `DELETE FROM buoys WHERE id=$1`, id)
if err != nil {
return fmt.Errorf("delete buoy: %w", err)
}
if tag.RowsAffected() == 0 {
return pgx.ErrNoRows
}
return nil
}
func (s *postgresStore) ListReadings(ctx context.Context, buoyID string, from, to *time.Time, limit, offset int) ([]reading, error) {
ctx, done := s.observe(ctx, "list_readings")
defer done()
rows, err := s.pool.Query(ctx, `SELECT id, buoy_id, water_temp, wave_height, salinity, recorded_at FROM readings WHERE ($1::text='' OR buoy_id=NULLIF($1,'')::uuid) AND ($2::timestamptz IS NULL OR recorded_at >= $2) AND ($3::timestamptz IS NULL OR recorded_at <= $3) ORDER BY recorded_at DESC LIMIT $4 OFFSET $5`, buoyID, from, to, limit, offset)
if err != nil {
return nil, fmt.Errorf("query readings: %w", err)
}
defer rows.Close()
items, err := pgx.CollectRows(rows, pgx.RowToStructByName[reading])
if err != nil {
return nil, fmt.Errorf("collect readings: %w", err)
}
return items, nil
}
func (s *postgresStore) CreateReading(ctx context.Context, r reading) (reading, error) {
ctx, done := s.observe(ctx, "create_reading")
defer done()
err := s.pool.QueryRow(ctx, `INSERT INTO readings (id,buoy_id,water_temp,wave_height,salinity,recorded_at) VALUES ($1,$2,$3,$4,$5,COALESCE($6,now())) RETURNING recorded_at`, r.ID, r.BuoyID, r.WaterTemp, r.WaveHeight, r.Salinity, nullableTime(r.RecordedAt)).Scan(&r.RecordedAt)
if err != nil {
return reading{}, fmt.Errorf("insert reading: %w", err)
}
return r, nil
}
func nullableTime(value time.Time) any {
if value.IsZero() {
return nil
}
return value
}
func (s *postgresStore) AggregateReadings(ctx context.Context, window time.Duration) ([]aggregate, error) {
ctx, done := s.observe(ctx, "aggregate_readings")
defer done()
seconds := int64(window.Seconds())
rows, err := s.pool.Query(ctx, `SELECT to_timestamp(floor(extract(epoch FROM recorded_at)/$1)*$1) AS bucket, avg(water_temp) AS average_water_temp, avg(wave_height) AS average_wave_height, avg(salinity) AS average_salinity, count(*) AS count FROM readings GROUP BY bucket ORDER BY bucket DESC`, seconds)
if err != nil {
return nil, fmt.Errorf("aggregate readings: %w", err)
}
defer rows.Close()
items, err := pgx.CollectRows(rows, pgx.RowToStructByName[aggregate])
if err != nil {
return nil, fmt.Errorf("collect aggregates: %w", err)
}
return items, nil
}

3
apps/loadgen/go.mod Normal file
View file

@ -0,0 +1,3 @@
module git.fiwlabs.dev/fiwdev/nereus/apps/loadgen
go 1.26

199
apps/loadgen/main.go Normal file
View file

@ -0,0 +1,199 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math"
"math/rand/v2"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
type generator struct {
client *http.Client
target string
buoys []string
mu sync.Mutex
counts map[string]int
}
type buoy struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Latitude float64 `json:"latitude,omitempty"`
Longitude float64 `json:"longitude,omitempty"`
}
func main() {
if err := run(); err != nil {
slog.Error("load generator stopped", "error", err)
os.Exit(1)
}
}
func run() error {
target := strings.TrimRight(os.Getenv("TARGET_URL"), "/")
if target == "" {
return errors.New("TARGET_URL is required")
}
rps, err := strconv.Atoi(envOr("RPS", "5"))
if err != nil || rps < 1 || rps > 100 {
return errors.New("RPS must be between 1 and 100")
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
g := &generator{client: &http.Client{Timeout: 5 * time.Second}, target: target, counts: make(map[string]int)}
if err := g.seed(ctx); err != nil {
logger.Warn("initial buoy seed failed; traffic will continue", "error", err)
}
go g.seedUntilReady(ctx, logger)
go g.summarize(ctx, logger)
ticker := time.NewTicker(time.Second / time.Duration(rps))
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
go g.send(ctx)
}
}
}
func (g *generator) seed(ctx context.Context) error {
var existing []buoy
if err := g.request(ctx, http.MethodGet, "/api/v1/buoys", nil, &existing); err != nil {
return err
}
g.mu.Lock()
for _, item := range existing {
g.buoys = append(g.buoys, item.ID)
}
ready := len(g.buoys) > 0
g.mu.Unlock()
if ready {
return nil
}
positions := [][2]float64{{44.5, -9}, {44.2, -8}, {44.3, -7}, {44.4, -6}, {44.5, -5}, {44.6, -4}, {44.7, -3}, {44.5, -2}, {43.8, -2}, {43.5, -9.8}, {42.5, -10.2}, {41.5, -10.5}, {40.5, -10.8}, {39.5, -11}, {38.5, -11}, {37.5, -10.8}, {36.5, -10}, {35.9, -9}, {35.8, -8}, {35.7, -7}, {35.2, -7.5}, {35, -8.5}, {35.8, -4.5}, {36, -3.5}, {36.2, -2.5}, {36.5, -1.5}, {36.8, -.5}, {37.2, .5}, {37.7, 1.2}, {38.1, 2}, {38, 3}, {38.5, 4}, {39.5, 1}, {40, 1.5}, {40.5, 2}, {41, 2.7}, {41.5, 3.3}, {42, 3.8}, {42.5, 4.5}, {43, -9.8}, {41, -10.5}, {39, -10.8}}
for i, position := range positions {
input := buoy{Name: fmt.Sprintf("Synthetic %d", i+1), Latitude: position[0], Longitude: position[1]}
var created buoy
if err := g.request(ctx, http.MethodPost, "/api/v1/buoys", input, &created); err != nil {
return err
}
g.mu.Lock()
g.buoys = append(g.buoys, created.ID)
g.mu.Unlock()
}
return nil
}
func (g *generator) send(ctx context.Context) { g.sendRoll(ctx, rand.IntN(100)) }
func (g *generator) sendRoll(ctx context.Context, roll int) {
path, method, body := "/api/v1/readings", http.MethodGet, any(nil)
if roll >= 60 && roll < 85 {
g.mu.Lock()
ids := append([]string(nil), g.buoys...)
g.mu.Unlock()
if len(ids) == 0 {
_ = g.request(ctx, method, path, nil, nil)
return
}
method = http.MethodPost
phase := float64(time.Now().UnixMilli()) / 60000
body = map[string]any{"buoy_id": ids[rand.IntN(len(ids))], "water_temp": 19 + 2*math.Sin(phase) + rand.Float64()*.4, "wave_height": 1.8 + .8*math.Sin(phase*.7+1) + rand.Float64()*.25, "salinity": 35.5 + .5*math.Sin(phase*.3) + rand.Float64()*.15}
} else if roll >= 85 {
path = "/api/v1/readings/aggregate?window=1h"
}
_ = g.request(ctx, method, path, body, nil)
}
func (g *generator) seedUntilReady(ctx context.Context, logger *slog.Logger) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
g.mu.Lock()
ready := len(g.buoys) > 0
g.mu.Unlock()
if ready {
return
}
if err := g.seed(ctx); err != nil {
logger.Warn("buoy seed failed; traffic continues", "error", err)
}
}
}
}
func (g *generator) request(ctx context.Context, method, path string, body, output any) error {
var payload *bytes.Reader
if body == nil {
payload = bytes.NewReader(nil)
} else {
encoded, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("encode request: %w", err)
}
payload = bytes.NewReader(encoded)
}
req, err := http.NewRequestWithContext(ctx, method, g.target+path, payload)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := g.client.Do(req)
if err != nil {
g.record("network_error")
return fmt.Errorf("send request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
g.record(strconv.Itoa(resp.StatusCode))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("API returned %s", resp.Status)
}
if output != nil && json.NewDecoder(resp.Body).Decode(output) != nil {
return errors.New("decode response")
}
return nil
}
func (g *generator) record(status string) { g.mu.Lock(); defer g.mu.Unlock(); g.counts[status]++ }
func (g *generator) summarize(ctx context.Context, logger *slog.Logger) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
g.mu.Lock()
counts := g.counts
g.counts = make(map[string]int)
g.mu.Unlock()
logger.Info("traffic summary", "status_counts", counts)
}
}
}
func envOr(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}

106
apps/loadgen/main_test.go Normal file
View file

@ -0,0 +1,106 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
)
func TestRequestWeighting(t *testing.T) {
var mu sync.Mutex
counts := make(map[string]int)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
counts[r.Method+" "+r.URL.RequestURI()]++
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
g := &generator{client: server.Client(), target: server.URL, buoys: []string{"buoy-1"}, counts: make(map[string]int)}
for roll := range 100 {
g.sendRoll(context.Background(), roll)
}
want := map[string]int{
"GET /api/v1/readings": 60,
"POST /api/v1/readings": 25,
"GET /api/v1/readings/aggregate?window=1h": 15,
}
if fmt.Sprint(counts) != fmt.Sprint(want) {
t.Fatalf("request weighting = %v, want %v", counts, want)
}
}
func TestSeedEmptyAPI(t *testing.T) {
var mu sync.Mutex
created := 0
var posted []buoy
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/buoys":
_ = json.NewEncoder(w).Encode([]buoy{})
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/buoys":
var input buoy
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid buoy", http.StatusBadRequest)
return
}
mu.Lock()
created++
posted = append(posted, input)
id := fmt.Sprintf("buoy-%d", created)
mu.Unlock()
_ = json.NewEncoder(w).Encode(buoy{ID: id})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
g := &generator{client: server.Client(), target: server.URL, counts: make(map[string]int)}
if err := g.seed(context.Background()); err != nil {
t.Fatalf("seed: %v", err)
}
if created != 42 || len(g.buoys) != 42 {
t.Fatalf("created %d buoys and retained %d IDs, want 42 and 42", created, len(g.buoys))
}
want := [][2]float64{{44.5, -9}, {44.2, -8}, {44.3, -7}, {44.4, -6}, {44.5, -5}, {44.6, -4}, {44.7, -3}, {44.5, -2}, {43.8, -2}, {43.5, -9.8}, {42.5, -10.2}, {41.5, -10.5}, {40.5, -10.8}, {39.5, -11}, {38.5, -11}, {37.5, -10.8}, {36.5, -10}, {35.9, -9}, {35.8, -8}, {35.7, -7}, {35.2, -7.5}, {35, -8.5}, {35.8, -4.5}, {36, -3.5}, {36.2, -2.5}, {36.5, -1.5}, {36.8, -.5}, {37.2, .5}, {37.7, 1.2}, {38.1, 2}, {38, 3}, {38.5, 4}, {39.5, 1}, {40, 1.5}, {40.5, 2}, {41, 2.7}, {41.5, 3.3}, {42, 3.8}, {42.5, 4.5}, {43, -9.8}, {41, -10.5}, {39, -10.8}}
for i, position := range want {
if posted[i].Latitude != position[0] || posted[i].Longitude != position[1] {
t.Errorf("buoy %d position = (%v, %v), want (%v, %v)", i, posted[i].Latitude, posted[i].Longitude, position[0], position[1])
}
}
}
func TestTrafficContinuesThroughHTTP500(t *testing.T) {
const requests = 30
var mu sync.Mutex
received := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
received++
mu.Unlock()
http.Error(w, "unavailable", http.StatusInternalServerError)
}))
defer server.Close()
g := &generator{client: server.Client(), target: server.URL, buoys: []string{"buoy-1"}, counts: make(map[string]int)}
for roll := range requests {
g.sendRoll(context.Background(), roll)
}
mu.Lock()
got := received
mu.Unlock()
if got != requests {
t.Fatalf("received %d requests, want %d", got, requests)
}
if g.counts["500"] != requests {
t.Fatalf("recorded %d HTTP 500 responses, want %d", g.counts["500"], requests)
}
}

242
apps/web/app.js Normal file
View file

@ -0,0 +1,242 @@
(() => {
"use strict";
const CACHE_KEY = "nereus.telemetry.v1";
const state = {
buoys: [],
readings: [],
markers: [],
responses: [],
totalErrors: 0,
connectivity: "offline",
version: null,
lastDataAt: null,
healthPolling: false,
dataPolling: false,
probes: {
liveness: {ok: null, changedAt: null},
readiness: {ok: null, changedAt: null}
}
};
const elements = Object.fromEntries([
"clock", "version-value", "version-note", "liveness-dot", "liveness-time",
"readiness-dot", "readiness-time", "error-total", "error-rate", "error-state",
"buoy-count", "data-age", "map-state", "telemetry-state", "latest-temp",
"latest-wave", "latest-salinity", "readings-chart"
].map((id) => [id, document.getElementById(id)]));
const map = L.map("map", {zoomControl: false, attributionControl: true, scrollWheelZoom: false}).setView([39.3, -4.2], 5);
map.attributionControl.addAttribution("Natural Earth");
if (window.NEREUS_LAND) {
L.geoJSON(window.NEREUS_LAND, {
interactive: false,
style: {className: "landmass", fillColor: "#183640", fillOpacity: .72, color: "#41636a", weight: .7}
}).addTo(map);
}
function restoreCache() {
try {
const cached = JSON.parse(localStorage.getItem(CACHE_KEY));
if (!cached || !Array.isArray(cached.buoys) || !Array.isArray(cached.readings)) return;
state.buoys = cached.buoys;
state.readings = cached.readings;
state.lastDataAt = cached.savedAt || null;
renderTelemetry(true);
} catch (_) {
// A damaged cache is equivalent to no cache and should stay invisible.
}
}
function saveCache() {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({buoys: state.buoys, readings: state.readings, savedAt: state.lastDataAt}));
} catch (_) {
// Private browsing and storage quotas must not affect the dashboard.
}
}
async function apiFetch(path) {
const startedAt = Date.now();
try {
const response = await fetch(path, {headers: {Accept: "application/json"}, cache: "no-store"});
state.responses.push({at: startedAt, error: !response.ok});
if (!response.ok) {
state.totalErrors += 1;
throw Object.assign(new Error("API response was not successful"), {httpFailure: true});
}
return response.json();
} catch (error) {
if (!error.httpFailure) state.connectivity = "offline";
throw error;
} finally {
renderErrors();
}
}
async function probe(path, includeVersion = false) {
try {
const response = await fetch(path, {cache: "no-store"});
return {ok: response.status === 200, version: includeVersion ? response.headers.get("X-Nereus-Version") : null};
} catch (_) {
return {ok: false, version: null};
}
}
function updateProbe(name, ok) {
const probeState = state.probes[name];
if (probeState.ok !== ok) {
probeState.ok = ok;
probeState.changedAt = new Date();
}
elements[`${name}-dot`].className = `indicator ${ok ? "ok" : "bad"}`;
elements[`${name}-time`].textContent = probeState.changedAt ? `Changed ${formatTime(probeState.changedAt)}` : "No transition yet";
}
async function pollHealth() {
if (state.healthPolling) return;
state.healthPolling = true;
try {
const [live, ready] = await Promise.all([probe("/healthz", true), probe("/readyz")]);
if (live.version) state.version = live.version;
updateProbe("liveness", live.ok);
updateProbe("readiness", ready.ok);
if (!live.ok) state.connectivity = "offline";
renderConnection();
} finally {
state.healthPolling = false;
}
}
async function pollData() {
if (state.dataPolling) return;
state.dataPolling = true;
try {
const [buoys, readings] = await Promise.all([
apiFetch("/api/v1/buoys"),
apiFetch("/api/v1/readings?limit=500")
]);
state.buoys = Array.isArray(buoys) ? buoys : [];
state.readings = Array.isArray(readings) ? readings : [];
state.lastDataAt = new Date().toISOString();
state.connectivity = "online";
saveCache();
renderTelemetry(false);
} catch (error) {
if (error.httpFailure) state.connectivity = "degraded";
renderTelemetry(true);
} finally {
state.dataPolling = false;
renderConnection();
}
}
function renderConnection() {
const online = state.connectivity === "online";
const reachable = state.connectivity !== "offline";
elements["telemetry-state"].className = `connection-pill ${state.connectivity}`;
elements["telemetry-state"].textContent = state.connectivity === "online" ? "Live" : state.connectivity === "degraded" ? "API errors" : "Offline";
elements["version-value"].textContent = reachable ? state.version || "unavailable" : "offline";
elements["version-note"].textContent = reachable ? (state.version ? "Reported by API" : "Version unavailable") : "Cluster unavailable";
document.querySelector(".map-card").classList.toggle("stale", !online);
document.querySelector(".telemetry-card").classList.toggle("stale", !online);
renderErrors();
}
function renderErrors() {
const cutoff = Date.now() - 60000;
state.responses = state.responses.filter((event) => event.at >= cutoff);
const failed = state.responses.filter((event) => event.error).length;
const rate = state.responses.length ? failed / state.responses.length : 0;
elements["error-total"].textContent = state.totalErrors.toLocaleString();
elements["error-rate"].textContent = `${(rate * 100).toFixed(1)}%`;
elements["error-state"].textContent = state.connectivity === "offline"
? "API unreachable"
: failed > 0 ? "API responding with errors" : "API responses healthy";
}
function renderTelemetry(stale) {
renderMap(stale);
drawChart();
const latest = [...state.readings].sort((a, b) => new Date(b.recorded_at) - new Date(a.recorded_at))[0];
elements["latest-temp"].textContent = numberOrDash(latest?.water_temp, 1);
elements["latest-wave"].textContent = numberOrDash(latest?.wave_height, 1);
elements["latest-salinity"].textContent = numberOrDash(latest?.salinity, 1);
elements["buoy-count"].textContent = `${state.buoys.length} ${state.buoys.length === 1 ? "buoy" : "buoys"}`;
elements["data-age"].textContent = state.lastDataAt ? `Updated ${formatTime(new Date(state.lastDataAt))}` : "No telemetry";
elements["map-state"].textContent = state.buoys.length ? (stale ? "Showing last known positions" : "Live positions") : "Waiting for buoy positions";
}
function renderMap() {
state.markers.forEach((marker) => marker.remove());
const latest = new Map();
state.readings.forEach((reading) => {
const previous = latest.get(reading.buoy_id);
if (!previous || new Date(reading.recorded_at) > new Date(previous.recorded_at)) latest.set(reading.buoy_id, reading);
});
state.markers = state.buoys.filter((buoy) => Number.isFinite(buoy.latitude) && Number.isFinite(buoy.longitude)).map((buoy) => {
const reading = latest.get(buoy.id);
const details = reading ? `${numberOrDash(reading.water_temp, 1)} °C · ${numberOrDash(reading.wave_height, 1)} m · ${numberOrDash(reading.salinity, 1)} PSU<br><small>${formatTime(new Date(reading.recorded_at))}</small>` : "Awaiting first sample";
const icon = L.divIcon({className: "", html: '<div class="buoy-marker"></div>', iconSize: [13, 13], iconAnchor: [6, 6]});
return L.marker([buoy.latitude, buoy.longitude], {icon}).bindTooltip(`<strong>${escapeText(buoy.name)}</strong><br>${buoy.latitude.toFixed(2)}, ${buoy.longitude.toFixed(2)}<br>${details}`, {direction: "top"}).addTo(map);
});
if (state.markers.length) {
const bounds = L.featureGroup(state.markers).getBounds();
if (bounds.isValid()) map.fitBounds(bounds.pad(.22), {maxZoom: 7, animate: false});
}
}
function drawChart() {
const canvas = elements["readings-chart"];
const rect = canvas.getBoundingClientRect();
const ratio = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.floor(rect.width * ratio));
canvas.height = Math.max(1, Math.floor(rect.height * ratio));
const context = canvas.getContext("2d");
context.scale(ratio, ratio);
const width = rect.width, height = rect.height, pad = {top: 13, right: 13, bottom: 23, left: 34};
const values = [...state.readings].sort((a, b) => new Date(a.recorded_at) - new Date(b.recorded_at)).slice(-45);
context.clearRect(0, 0, width, height);
context.strokeStyle = "rgba(121, 173, 180, .12)";
context.lineWidth = 1;
for (let i = 0; i < 5; i += 1) {
const y = pad.top + ((height - pad.top - pad.bottom) * i / 4);
context.beginPath(); context.moveTo(pad.left, y); context.lineTo(width - pad.right, y); context.stroke();
}
if (values.length < 2) {
context.fillStyle = "#789aa1"; context.font = "11px system-ui"; context.textAlign = "center";
context.fillText("Telemetry will appear when the cluster is online", width / 2, height / 2);
return;
}
const plot = (field, color, min, max) => {
const spread = max - min;
context.beginPath(); context.strokeStyle = color; context.lineWidth = 2; context.lineJoin = "round";
values.forEach((item, index) => {
const x = pad.left + ((width - pad.left - pad.right) * index / (values.length - 1));
const y = pad.top + (height - pad.top - pad.bottom) * (1 - (Number(item[field]) - min) / spread);
if (index === 0) context.moveTo(x, y); else context.lineTo(x, y);
});
context.stroke();
};
plot("water_temp", "#55e4d7", 10, 30);
plot("wave_height", "#55a9ff", 0, 6);
context.fillStyle = "#668d95"; context.font = "9px system-ui"; context.textAlign = "left";
context.fillText(formatTime(new Date(values[0].recorded_at)), pad.left, height - 5);
context.textAlign = "right"; context.fillText(formatTime(new Date(values.at(-1).recorded_at)), width - pad.right, height - 5);
}
function formatTime(date) { return new Intl.DateTimeFormat(undefined, {hour: "2-digit", minute: "2-digit", second: "2-digit"}).format(date); }
function numberOrDash(value, digits) { return Number.isFinite(Number(value)) ? Number(value).toFixed(digits) : "--"; }
function escapeText(value) { const node = document.createElement("span"); node.textContent = String(value || "Unnamed buoy"); return node.innerHTML; }
restoreCache();
renderConnection();
pollHealth();
pollData();
setInterval(pollHealth, 2000);
setInterval(pollData, 5000);
setInterval(renderErrors, 1000);
setInterval(() => { elements.clock.textContent = formatTime(new Date()); }, 1000);
elements.clock.textContent = formatTime(new Date());
window.addEventListener("resize", () => { map.invalidateSize(false); drawChart(); });
})();

File diff suppressed because one or more lines are too long

101
apps/web/index.html Normal file
View file

@ -0,0 +1,101 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Nereus Ocean Telemetry</title>
<link rel="stylesheet" href="vendor/leaflet.css">
<link rel="stylesheet" href="styles.css?v=20260824-2">
</head>
<body>
<main class="dashboard">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 48 48"><path d="M8 29c7-8 14-8 21 0 5 5 9 5 13 1M10 20c5-6 10-6 15 0 4 4 8 4 12 0"/></svg>
</span>
<div>
<p class="eyebrow">Ocean intelligence</p>
<h1>Nereus</h1>
</div>
</div>
<a class="dev-link" href="https://fiwlabs.dev" target="_blank" rel="noopener noreferrer"><span>fiwlabs.dev</span><b aria-hidden="true"></b></a>
<div class="clock-block">
<span>Local time</span>
<strong id="clock">--:--:--</strong>
</div>
</header>
<section class="status-grid" aria-label="System status">
<article class="glass status-card version-card">
<div class="status-icon">V</div>
<div>
<p class="card-label">API version</p>
<strong id="version-value">offline</strong>
<span id="version-note">Cluster unavailable</span>
</div>
</article>
<article class="glass status-card health-card">
<div>
<p class="card-label">Cluster health</p>
<strong>Service probes</strong>
</div>
<div class="probes">
<div class="probe">
<span id="liveness-dot" class="indicator unknown"></span>
<div><b>Liveness</b><time id="liveness-time">No transition yet</time></div>
</div>
<div class="probe">
<span id="readiness-dot" class="indicator unknown"></span>
<div><b>Readiness</b><time id="readiness-time">No transition yet</time></div>
</div>
</div>
</article>
<article class="glass status-card error-card">
<div>
<p class="card-label">Client-side API errors</p>
<strong id="error-total">0</strong>
<span>non-2xx responses</span>
</div>
<div class="error-rate">
<span id="error-rate">0.0%</span>
<small>rolling 60s</small>
<em id="error-state">Waiting for API</em>
</div>
</article>
</section>
<section class="workspace">
<article class="glass map-card">
<div class="panel-heading">
<div><p class="card-label">Active network</p><h2>Buoy positions</h2></div>
<div class="panel-meta"><span id="buoy-count">0 buoys</span><span id="data-age">No telemetry</span></div>
</div>
<div id="map" aria-label="Map of buoy positions"></div>
<div id="map-state" class="quiet-state">Waiting for buoy positions</div>
</article>
<article class="glass telemetry-card">
<div class="panel-heading">
<div><p class="card-label">Latest samples</p><h2>Water conditions</h2></div>
<span id="telemetry-state" class="connection-pill offline">Offline</span>
</div>
<div class="legend" aria-hidden="true"><span class="temperature">Water temperature</span><span class="waves">Wave height</span></div>
<div class="chart-wrap"><canvas id="readings-chart" aria-label="Recent water temperature and wave height chart"></canvas></div>
<div class="telemetry-summary">
<div><span>Temperature</span><strong id="latest-temp">--</strong><small>°C</small></div>
<div><span>Wave height</span><strong id="latest-wave">--</strong><small>m</small></div>
<div><span>Salinity</span><strong id="latest-salinity">--</strong><small>PSU</small></div>
</div>
</article>
</section>
</main>
<script src="vendor/leaflet.js"></script>
<script src="assets/ne_110m_land.js"></script>
<script src="app.js?v=20260824-3"></script>
</body>
</html>

103
apps/web/styles.css Normal file
View file

@ -0,0 +1,103 @@
:root {
color-scheme: dark;
font-family: Arial, Helvetica, sans-serif;
background: #07151d;
color: #d8e3e3;
--ink: #07151d;
--panel: #0b202a;
--panel-deep: #081a23;
--rule: #27434b;
--rule-strong: #3c6269;
--muted: #7f999d;
--signal: #52c8bc;
--blue: #5aa9d6;
--warn: #d6a85a;
--bad: #db7770;
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body {
min-width: 1280px;
background-color: var(--ink);
background-image:
linear-gradient(rgba(85, 126, 134, .045) 1px, transparent 1px),
linear-gradient(90deg, rgba(85, 126, 134, .045) 1px, transparent 1px);
background-size: 32px 32px;
}
.ocean-background { display: none; }
.dashboard { height: 100vh; padding: 0 26px 24px; display: grid; grid-template-rows: 74px 102px minmax(0, 1fr); gap: 12px; }
.topbar { position: relative; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--rule-strong); }
.dev-link { position: absolute; top: 50%; left: 50%; display: flex; align-items: center; gap: 9px; padding: 9px 13px 8px 15px; transform: translate(-50%, -50%); border: 1px solid var(--rule-strong); color: #9cb3b6; background: #0a1c25; box-shadow: inset 2px 0 0 var(--signal); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .16em; text-decoration: none; text-transform: uppercase; transition: color .2s ease, border-color .2s ease, background-color .2s ease; }
.dev-link b { color: var(--signal); font-size: 13px; font-weight: 400; line-height: .7; }
.dev-link:hover, .dev-link:focus-visible { border-color: var(--signal); color: #e0f5f2; background: #102b34; outline: none; }
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark { width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid var(--rule-strong); }
.brand-mark svg { width: 24px; fill: none; stroke: var(--signal); stroke-width: 1.7; stroke-linecap: square; }
.eyebrow, .card-label { margin: 0 0 4px; color: var(--muted); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .15em; text-transform: uppercase; }
h1 { margin: 0; font-size: 22px; line-height: 1; letter-spacing: .08em; text-transform: uppercase; }
.clock-block { text-align: right; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.clock-block span { display: block; color: var(--muted); font-size: 8px; letter-spacing: .14em; text-transform: uppercase; }
.clock-block strong { font-size: 16px; font-weight: 500; font-variant-numeric: tabular-nums; }
.glass { border: 1px solid var(--rule); background: var(--panel); box-shadow: none; backdrop-filter: none; }
.status-grid { display: grid; grid-template-columns: .72fr 1.48fr 1.1fr; gap: 12px; }
.status-card { position: relative; min-width: 0; padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; border-radius: 0; }
.status-card::before { content: ""; position: absolute; top: -1px; left: -1px; width: 54px; height: 2px; background: var(--signal); }
.status-card strong { display: block; font: 600 16px ui-monospace, SFMono-Regular, Consolas, monospace; }
.status-card > div > span:not(.indicator) { color: var(--muted); font-size: 10px; }
.status-icon { display: none; }
.version-card { justify-content: flex-start; border-left-color: var(--rule-strong); }
.version-card strong { color: var(--bad); text-transform: uppercase; }
.probes { display: flex; align-items: center; gap: 34px; }
.probe { display: flex; align-items: center; gap: 10px; }
.probe b { display: block; font: 600 11px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
.probe time { display: block; width: 132px; margin-top: 4px; color: var(--muted); font: 8px ui-monospace, SFMono-Regular, Consolas, monospace; }
.indicator { width: 8px; height: 8px; border-radius: 50%; background: #64747a; transition: background-color .2s ease; }
.indicator.ok { background: var(--signal); box-shadow: none; }
.indicator.bad { background: var(--bad); box-shadow: none; }
.error-card strong { font-size: 27px; font-weight: 500; font-variant-numeric: tabular-nums; }
.error-rate { min-width: 144px; padding-left: 18px; border-left: 1px solid var(--rule); text-align: left; }
.error-rate > span { display: block; color: var(--signal); font: 500 20px ui-monospace, SFMono-Regular, Consolas, monospace; font-variant-numeric: tabular-nums; }
.error-rate small { color: var(--muted); font: 8px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
.error-rate em { display: block; margin-top: 7px; color: #9cb0b2; font-size: 9px; font-style: normal; }
.workspace { min-height: 0; display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(400px, .85fr); gap: 12px; }
.map-card, .telemetry-card { position: relative; min-height: 0; overflow: hidden; border-radius: 0; }
.panel-heading { height: 66px; padding: 14px 16px 11px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--rule); background: var(--panel-deep); }
.panel-heading h2 { margin: 0; font-size: 15px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; }
.panel-meta { display: flex; gap: 18px; }
.panel-meta span, .connection-pill { padding: 0; border: 0; border-radius: 0; color: var(--muted); background: transparent; font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
.panel-meta span + span { padding-left: 18px; border-left: 1px solid var(--rule); }
#map { height: calc(100% - 66px); background: #091b24; transition: opacity .2s ease; }
#map::before { content: ""; position: absolute; inset: 0; z-index: 200; pointer-events: none; background-image: linear-gradient(rgba(96,137,143,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(96,137,143,.1) 1px, transparent 1px); background-size: 64px 64px; }
.map-card.stale #map { filter: saturate(.3) brightness(.72); }
.quiet-state { position: absolute; left: 16px; bottom: 14px; z-index: 500; padding: 6px 9px; border: 1px solid var(--rule-strong); color: #9db0b2; background: rgba(7, 21, 29, .92); font: 8px ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .08em; text-transform: uppercase; pointer-events: none; }
.quiet-state.hidden { opacity: 0; }
.leaflet-container { font-family: Arial, Helvetica, sans-serif; }
.leaflet-control-attribution { color: #6b8589 !important; background: rgba(7,21,29,.84) !important; font-size: 8px !important; }
.leaflet-control-attribution a { color: #83a5a8 !important; }
.landmass { fill: #183640; fill-opacity: .72; stroke: #41636a; stroke-width: .7; }
.buoy-marker { width: 9px; height: 9px; border: 1px solid #d9fffa; border-radius: 50%; background: var(--signal); animation: pulse 2s steps(2, end) infinite; }
@keyframes pulse { 50% { outline: 4px solid rgba(82, 200, 188, .22); } }
.telemetry-card { display: grid; grid-template-rows: 66px 28px minmax(0, 1fr) 72px; }
.connection-pill::before { content: "●"; margin-right: 6px; }
.connection-pill.online { color: var(--signal); }
.connection-pill.degraded { color: var(--warn); }
.connection-pill.offline { color: var(--bad); }
.legend { display: flex; align-items: center; gap: 24px; padding: 0 16px; border-bottom: 1px solid rgba(39,67,75,.65); color: var(--muted); font: 8px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
.legend span::before { content: ""; display: inline-block; width: 14px; height: 2px; margin: 0 7px 2px 0; background: var(--signal); }
.legend .waves::before { background: var(--blue); }
.chart-wrap { min-height: 0; padding: 8px 10px 0; transition: opacity .2s ease; }
.telemetry-card.stale .chart-wrap { opacity: .35; }
#readings-chart { width: 100%; height: 100%; }
.telemetry-summary { margin: 0; display: grid; grid-template-columns: repeat(3, 1fr); overflow: hidden; border-top: 1px solid var(--rule); background: var(--panel-deep); }
.telemetry-summary div { padding: 11px 13px; border-right: 1px solid var(--rule); }
.telemetry-summary div:last-child { border: 0; }
.telemetry-summary span { display: block; color: var(--muted); font: 8px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
.telemetry-summary strong { display: inline-block; margin-top: 5px; font: 500 17px ui-monospace, SFMono-Regular, Consolas, monospace; font-variant-numeric: tabular-nums; }
.telemetry-summary small { margin-left: 3px; color: var(--muted); font: 8px ui-monospace, SFMono-Regular, Consolas, monospace; }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }

26
apps/web/vendor/LEAFLET-LICENSE vendored Normal file
View file

@ -0,0 +1,26 @@
BSD 2-Clause License
Copyright (c) 2010-2023, Volodymyr Agafonkin
Copyright (c) 2010-2011, CloudMade
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

661
apps/web/vendor/leaflet.css vendored Normal file
View file

@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

6
apps/web/vendor/leaflet.js vendored Normal file

File diff suppressed because one or more lines are too long

19
build/api.Dockerfile Normal file
View file

@ -0,0 +1,19 @@
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY apps/api/go.mod apps/api/go.sum ./
RUN go mod download
COPY apps/api/ ./
ARG APP_VERSION=dev
RUN CGO_ENABLED=0 go build \
-trimpath \
-ldflags "-s -w -X main.appVersion=${APP_VERSION}" \
-o /out/nereus-api .
FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/nereus-api /nereus-api
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/nereus-api"]

33
build/compose.mini.yaml Normal file
View file

@ -0,0 +1,33 @@
name: nereus-web
services:
web:
build:
context: ..
dockerfile: build/web.Dockerfile
args:
WEB_ASSET_PATH: ${WEB_ASSET_PATH:-apps/web}
environment:
CLUSTER_API_UPSTREAM: ${CLUSTER_API_UPSTREAM:?set CLUSTER_API_UPSTREAM to the cluster host and port}
read_only: true
tmpfs:
- /etc/nginx/conf.d:size=1m,uid=101,gid=101,mode=0755
- /tmp:size=16m,mode=1777
- /var/cache/nginx:size=16m,mode=0755
security_opt:
- no-new-privileges:true
labels:
- traefik.enable=true
- traefik.http.routers.nereus-web.rule=Host(`${WEB_HOST:-nereus.fiwlabs.dev}`)
- traefik.http.routers.nereus-web.entrypoints=websecure
- traefik.http.routers.nereus-web.tls=true
- traefik.http.routers.nereus-web.tls.certresolver=porkbun
- traefik.http.services.nereus-web.loadbalancer.server.port=8080
- traefik.docker.network=proxy
networks:
- proxy
restart: unless-stopped
networks:
proxy:
external: true

17
build/loadgen.Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY apps/loadgen/go.mod ./
RUN go mod download
COPY apps/loadgen/ ./
RUN CGO_ENABLED=0 go build \
-trimpath \
-ldflags "-s -w" \
-o /out/nereus-loadgen .
FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/nereus-loadgen /nereus-loadgen
USER nonroot:nonroot
ENTRYPOINT ["/nereus-loadgen"]

View file

@ -0,0 +1,15 @@
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
exporters:
debug:
verbosity: basic
service:
pipelines:
traces:
receivers: [otlp]
exporters: [debug]

10
build/web.Dockerfile Normal file
View file

@ -0,0 +1,10 @@
FROM nginxinc/nginx-unprivileged:1.31.4-alpine
COPY build/web.conf.template /etc/nginx/templates/default.conf.template
ARG WEB_ASSET_PATH=apps/web
COPY ${WEB_ASSET_PATH}/index.html ${WEB_ASSET_PATH}/styles.css ${WEB_ASSET_PATH}/app.js /usr/share/nginx/html/
COPY ${WEB_ASSET_PATH}/assets/ /usr/share/nginx/html/assets/
COPY ${WEB_ASSET_PATH}/vendor/ /usr/share/nginx/html/vendor/
USER 101
EXPOSE 8080

33
build/web.conf.template Normal file
View file

@ -0,0 +1,33 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/v1/ {
proxy_pass http://${CLUSTER_API_UPSTREAM};
proxy_connect_timeout 1s;
proxy_read_timeout 5s;
proxy_send_timeout 5s;
}
location = /healthz {
proxy_pass http://${CLUSTER_API_UPSTREAM};
proxy_connect_timeout 1s;
proxy_read_timeout 5s;
proxy_send_timeout 5s;
}
location = /readyz {
proxy_pass http://${CLUSTER_API_UPSTREAM};
proxy_connect_timeout 1s;
proxy_read_timeout 5s;
proxy_send_timeout 5s;
}
}

66
compose.yaml Normal file
View file

@ -0,0 +1,66 @@
name: nereus
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: nereus
POSTGRES_USER: nereus
POSTGRES_HOST_AUTH_METHOD: trust
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nereus -d nereus"]
interval: 2s
timeout: 3s
retries: 15
volumes:
- postgres-data:/var/lib/postgresql/data
otel-collector:
image: otel/opentelemetry-collector-contrib:0.159.0
command: ["--config=/etc/otelcol-contrib/config.yaml"]
read_only: true
security_opt:
- no-new-privileges:true
volumes:
- ./build/otel-collector.local.yaml:/etc/otelcol-contrib/config.yaml:ro
api:
build:
context: .
dockerfile: build/api.Dockerfile
args:
APP_VERSION: local
environment:
DATABASE_URL: postgres://nereus@postgres:5432/nereus?sslmode=disable
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
LOG_LEVEL: info
depends_on:
postgres:
condition: service_healthy
otel-collector:
condition: service_started
ports:
- "${API_PORT:-18080}:8080"
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
security_opt:
- no-new-privileges:true
loadgen:
build:
context: .
dockerfile: build/loadgen.Dockerfile
environment:
TARGET_URL: http://api:8080
RPS: "5"
depends_on:
- api
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
security_opt:
- no-new-privileges:true
volumes:
postgres-data:

View file

@ -0,0 +1,41 @@
# Active and preview Services for the blue-green Rollout.
#
# Argo Rollouts rewrites both selectors at runtime to pin them to the right
# ReplicaSet. What's below is just the starting state.
#
# Same app.kubernetes.io/name on both so one ServiceMonitor covers them, which
# is what gives the analysis query a `service` label to filter on.
---
apiVersion: v1
kind: Service
metadata:
name: nereus-api-active
labels:
app.kubernetes.io/name: nereus-api
app.kubernetes.io/part-of: nereus
app.kubernetes.io/component: active
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: nereus-api
ports:
- name: http
port: 8080
targetPort: http
---
apiVersion: v1
kind: Service
metadata:
name: nereus-api-preview
labels:
app.kubernetes.io/name: nereus-api
app.kubernetes.io/part-of: nereus
app.kubernetes.io/component: preview
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: nereus-api
ports:
- name: http
port: 8080
targetPort: http

View file

@ -0,0 +1,18 @@
# Non-secret API config. The DSN lives in the nereus-db Secret.
#
# Values must be quoted -- data is map[string]string, and an unquoted 8080
# gets rejected as an integer.
apiVersion: v1
kind: ConfigMap
metadata:
name: nereus-api-config
labels:
app.kubernetes.io/name: nereus-api
app.kubernetes.io/part-of: nereus
data:
PORT: "8080"
LOG_LEVEL: "info"
# main.go only enables tracing when this is set.
OTEL_EXPORTER_OTLP_ENDPOINT: "http://nereus-otel-collector.observability.svc.cluster.local:4317"
# Patched to a nonzero value on the preview to trigger the rollback demo.
CHAOS_ERROR_RATE: "0.0"

40
deploy/base/ingress.yaml Normal file
View file

@ -0,0 +1,40 @@
# Traefik ingress. Reachable at http://localhost:8080 in the k3d lab.
#
# Active Service only -- the preview stays internal so real traffic can't hit
# an unpromoted version. /metrics is not exposed either.
#
# No host, so any Host header matches. A real hostname belongs in the prod
# overlay.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: nereus-api
labels:
app.kubernetes.io/name: nereus-api
app.kubernetes.io/part-of: nereus
spec:
ingressClassName: traefik
rules:
- http:
paths:
- path: /api/v1
pathType: Prefix
backend:
service:
name: nereus-api-active
port:
name: http
- path: /healthz
pathType: Exact
backend:
service:
name: nereus-api-active
port:
name: http
- path: /readyz
pathType: Exact
backend:
service:
name: nereus-api-active
port:
name: http

View file

@ -0,0 +1,18 @@
# Plain Kubernetes only -- no Rollouts CRDs, so this still applies on a
# cluster without Argo installed.
#
# The nereus-db Secret is not here; see deploy/secrets/README.md.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# Sets the namespace on everything below, so the files don't repeat it.
namespace: nereus
resources:
- namespace.yaml
- configmap.yaml
- postgres.yaml
- api-services.yaml
- servicemonitor.yaml
- loadgen.yaml
- ingress.yaml

100
deploy/base/loadgen.yaml Normal file
View file

@ -0,0 +1,100 @@
# Load generator. A plain Deployment -- nothing to promote, and it has to keep
# running straight through an API rollout.
#
# Points at the active Service so its traffic never lands on the preview.
apiVersion: apps/v1
kind: Deployment
metadata:
name: nereus-loadgen
labels:
app.kubernetes.io/name: nereus-loadgen
app.kubernetes.io/part-of: nereus
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: nereus-loadgen
template:
metadata:
labels:
app.kubernetes.io/name: nereus-loadgen
app.kubernetes.io/part-of: nereus
spec:
imagePullSecrets:
- name: nereus-registry
securityContext:
# distroless nonroot.
runAsUser: 65532
runAsGroup: 65532
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: loadgen
image: nereus-loadgen:0.1.0
# Side-loaded with `k3d image import`; there's no registry.
imagePullPolicy: IfNotPresent
env:
- name: TARGET_URL
value: http://nereus-api-active.nereus.svc.cluster.local:8080
- name: RPS
value: "5"
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
memory: 128Mi
---
# Keeps request samples flowing through the preview while analysis runs.
apiVersion: apps/v1
kind: Deployment
metadata:
name: nereus-loadgen-preview
labels:
app.kubernetes.io/name: nereus-loadgen-preview
app.kubernetes.io/part-of: nereus
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: nereus-loadgen-preview
template:
metadata:
labels:
app.kubernetes.io/name: nereus-loadgen-preview
app.kubernetes.io/part-of: nereus
spec:
imagePullSecrets:
- name: nereus-registry
securityContext:
runAsUser: 65532
runAsGroup: 65532
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: loadgen
image: nereus-loadgen:0.1.0
imagePullPolicy: IfNotPresent
env:
- name: TARGET_URL
value: http://nereus-api-preview.nereus.svc.cluster.local:8080
- name: RPS
value: "5"
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
memory: 128Mi

View file

@ -0,0 +1,7 @@
apiVersion: v1
kind: Namespace
metadata:
name: nereus
labels:
app.kubernetes.io/name: nereus
app.kubernetes.io/part-of: nereus

113
deploy/base/postgres.yaml Normal file
View file

@ -0,0 +1,113 @@
# Postgres 17, StatefulSet + headless Service.
#
# StatefulSet rather than Deployment for the stable pod name and a PVC that
# survives the pod.
---
apiVersion: v1
kind: Service
metadata:
name: nereus-postgres
labels:
app.kubernetes.io/name: nereus-postgres
app.kubernetes.io/part-of: nereus
spec:
clusterIP: None
selector:
app.kubernetes.io/name: nereus-postgres
ports:
- name: postgres
port: 5432
targetPort: postgres
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: nereus-postgres
labels:
app.kubernetes.io/name: nereus-postgres
app.kubernetes.io/part-of: nereus
spec:
serviceName: nereus-postgres
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: nereus-postgres
template:
metadata:
labels:
app.kubernetes.io/name: nereus-postgres
app.kubernetes.io/part-of: nereus
spec:
securityContext:
# postgres:17-alpine runs as uid 70. The Debian tags use 999.
runAsUser: 70
runAsGroup: 70
fsGroup: 70
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: postgres
image: postgres:17-alpine
ports:
- name: postgres
containerPort: 5432
env:
- name: POSTGRES_DB
value: nereus
- name: POSTGRES_USER
value: nereus
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: nereus-db
key: POSTGRES_PASSWORD
# Subdirectory, or initdb trips over lost+found on the volume.
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
- name: runtime
mountPath: /var/run/postgresql
- name: tmp
mountPath: /tmp
readinessProbe:
exec:
command: ["pg_isready", "-U", "nereus", "-d", "nereus"]
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 6
livenessProbe:
exec:
command: ["pg_isready", "-U", "nereus", "-d", "nereus"]
initialDelaySeconds: 30
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 3
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 1Gi
volumes:
- name: runtime
emptyDir: {}
- name: tmp
emptyDir:
sizeLimit: 64Mi
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 5Gi

View file

@ -0,0 +1,24 @@
# Prometheus Operator CRD, not core Kubernetes. Works because the k3d values
# set serviceMonitorSelectorNilUsesHelmValues: false.
#
# Matches both Services, so each pod is scraped once per Service. That's the
# point -- the `service` label is how the analysis isolates the preview.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: nereus-api
labels:
app.kubernetes.io/name: nereus-api
app.kubernetes.io/part-of: nereus
spec:
selector:
matchLabels:
app.kubernetes.io/name: nereus-api
namespaceSelector:
matchNames:
- nereus
endpoints:
- port: http
path: /metrics
interval: 15s
scrapeTimeout: 10s

View file

@ -0,0 +1,9 @@
# Lands in `observability`, alongside kube-prometheus-stack.
# kubectl apply -k deploy/observability
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: observability
resources:
- otel-collector.yaml

View file

@ -0,0 +1,109 @@
# OTLP collector for the cluster. The API's OTEL_EXPORTER_OTLP_ENDPOINT
# points here.
#
# Traces only. The log pipeline in observability/otel-collector/config.yaml
# needs a DaemonSet with hostPath access to /var/log/pods plus RBAC, and Loki
# isn't in the cluster yet.
---
apiVersion: v1
kind: ConfigMap
metadata:
name: nereus-otel-collector
labels:
app.kubernetes.io/name: nereus-otel-collector
data:
config.yaml: |
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
processors:
memory_limiter:
check_interval: 1s
limit_mib: 256
batch:
timeout: 5s
exporters:
otlp/tempo:
endpoint: tempo.observability.svc.cluster.local:4317
tls:
insecure: true
# Keeps traces visible in `kubectl logs` until Tempo is deployed.
debug:
verbosity: basic
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlp/tempo, debug]
---
apiVersion: v1
kind: Service
metadata:
name: nereus-otel-collector
labels:
app.kubernetes.io/name: nereus-otel-collector
spec:
selector:
app.kubernetes.io/name: nereus-otel-collector
ports:
- name: otlp-grpc
port: 4317
targetPort: otlp-grpc
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nereus-otel-collector
labels:
app.kubernetes.io/name: nereus-otel-collector
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: nereus-otel-collector
template:
metadata:
labels:
app.kubernetes.io/name: nereus-otel-collector
annotations:
# Restarts the pod when the config changes; the collector doesn't reload.
checksum/config: placeholder
spec:
securityContext:
runAsUser: 10001
runAsGroup: 10001
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: otel-collector
image: otel/opentelemetry-collector-contrib:0.159.0
args: ["--config=/conf/config.yaml"]
ports:
- name: otlp-grpc
containerPort: 4317
volumeMounts:
- name: config
mountPath: /conf
readOnly: true
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 384Mi
volumes:
- name: config
configMap:
name: nereus-otel-collector

View file

@ -0,0 +1,30 @@
# dev -- the local k3d lab.
# kubectl apply -k deploy/overlays/dev
#
# Needs the images imported and the nereus-db Secret created first.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: nereus
resources:
- ../../base
- ../../rollouts
patches:
# One replica is easier to watch swap over, and enough on a laptop.
- target:
kind: Rollout
name: nereus-api
patch: |
- op: replace
path: /spec/replicas
value: 1
- target:
kind: StatefulSet
name: nereus-postgres
patch: |
- op: replace
path: /spec/volumeClaimTemplates/0/spec/resources/requests/storage
value: 2Gi

View file

@ -0,0 +1,51 @@
# prod overlay -- the two Fedora nodes.
# kubectl apply -k deploy/overlays/prod
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: nereus
resources:
- ../../base
- ../../rollouts
images:
- name: nereus-api
newName: git.fiwlabs.dev/fiwdev/nereus-api
newTag: 0.1.0
- name: nereus-loadgen
newName: git.fiwlabs.dev/fiwdev/nereus-loadgen
newTag: 0.1.0
patches:
# 3 replicas so a node can go down mid-rollout.
- target:
kind: Rollout
name: nereus-api
patch: |
- op: replace
path: /spec/replicas
value: 3
# Spread the API across both nodes.
- target:
kind: Rollout
name: nereus-api
patch: |
- op: add
path: /spec/template/spec/topologySpreadConstraints
value:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: nereus-api
- target:
kind: ConfigMap
name: nereus-api-config
patch: |
- op: replace
path: /data/LOG_LEVEL
value: "warn"

View file

@ -0,0 +1,36 @@
# The real error-rate gate, replacing the vector(0.0) harness in
# scripts/k3d/analysis-harness/.
#
# Runs before promotion. Every sample has to pass; one failure aborts and the
# active Service never moves.
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: nereus-api-error-rate
labels:
app.kubernetes.io/name: nereus-api
app.kubernetes.io/part-of: nereus
spec:
args:
- name: service
metrics:
- name: error-rate
# Let the new pods take traffic first.
initialDelay: 30s
interval: 20s
count: 5
failureLimit: 0
# Empty result means no traffic yet -- not a failure.
successCondition: "len(result) == 0 || result[0] < 0.05"
provider:
prometheus:
address: http://kube-prometheus-stack-prometheus.observability.svc.cluster.local:9090
# 5xx as a fraction of all responses on the preview.
# `or vector(0)` keeps the division defined when there are no errors.
query: |
(
sum(rate(nereus_http_requests_total{service="{{args.service}}",status=~"5.."}[1m]))
or vector(0)
)
/
sum(rate(nereus_http_requests_total{service="{{args.service}}"}[1m]))

View file

@ -0,0 +1,8 @@
# Argo Rollouts resources, kept separate from base/ so the plain-Kubernetes
# manifests stay applicable on a cluster without the Rollouts CRDs installed.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- rollout.yaml
- analysistemplate.yaml

View file

@ -0,0 +1,100 @@
# The API, as a blue-green Rollout.
#
# Same shape as a Deployment, but the new version comes up alongside the old,
# gets checked against Prometheus, and only then takes over the active Service.
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: nereus-api
labels:
app.kubernetes.io/name: nereus-api
app.kubernetes.io/part-of: nereus
spec:
replicas: 2
revisionHistoryLimit: 3
selector:
matchLabels:
app.kubernetes.io/name: nereus-api
template:
metadata:
labels:
app.kubernetes.io/name: nereus-api
app.kubernetes.io/part-of: nereus
spec:
imagePullSecrets:
- name: nereus-registry
securityContext:
# distroless nonroot.
runAsUser: 65532
runAsGroup: 65532
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: api
image: nereus-api:0.1.0
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
envFrom:
- configMapRef:
name: nereus-api-config
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: nereus-db
key: DATABASE_URL
# /healthz ignores Postgres, so a database outage makes pods unready
# without restarting them.
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 3
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
# /readyz waits for migrations.
readinessProbe:
httpGet:
path: /readyz
port: http
initialDelaySeconds: 3
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true
volumeMounts:
- name: tmp
mountPath: /tmp
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
volumes:
- name: tmp
emptyDir:
medium: Memory
sizeLimit: 16Mi
strategy:
blueGreen:
activeService: nereus-api-active
previewService: nereus-api-preview
# Auto-promote only if the analysis passes; a failure aborts instead.
autoPromotionEnabled: true
# Keep the old ReplicaSet warm so an abort falls straight back.
scaleDownDelaySeconds: 30
prePromotionAnalysis:
templates:
- templateName: nereus-api-error-rate
args:
- name: service
value: nereus-api-preview

35
deploy/secrets/README.md Normal file
View file

@ -0,0 +1,35 @@
# Secrets
`nereus-db` holds two keys, used by `postgres.yaml` and `rollout.yaml`:
- `POSTGRES_PASSWORD`
- `DATABASE_URL``postgres://nereus:<password>@nereus-postgres:5432/nereus?sslmode=disable`
It is not in `base/kustomization.yaml`. Plaintext never lands in the repo, and
gitleaks runs on every push.
## dev (k3d)
Create it directly, before `kubectl apply -k deploy/overlays/dev`:
```fish
set pw (openssl rand -hex 16)
kubectl create secret generic nereus-db -n nereus \
--from-literal=POSTGRES_PASSWORD=$pw \
--from-literal=DATABASE_URL="postgres://nereus:$pw@nereus-postgres:5432/nereus?sslmode=disable"
```
Rotating means deleting the secret, the StatefulSet's PVC, and restarting —
Postgres only reads `POSTGRES_PASSWORD` when it initialises the data directory.
## prod (sealed)
Write the plaintext to `nereus-db.plain.yaml` (gitignored), then:
```fish
kubeseal --format yaml < nereus-db.plain.yaml > nereus-db-sealed.yaml
```
Commit only `nereus-db-sealed.yaml` and add it to the prod overlay. The
controller isn't installed yet — `kubectl get crd | grep sealed` comes back
empty.

29
docs/ci-cd.md Normal file
View file

@ -0,0 +1,29 @@
# Forgejo CI/CD
`.forgejo/workflows/pipeline.yml` runs verification for pushes and pull
requests. A push to `main` also publishes immutable commit-SHA tags for the API,
load generator, and web images, then deploys the API and load generator through
the production overlay and Argo Rollouts.
Two load-generator Deployments keep traffic on both the active and preview
Services. Preview traffic is required for the pre-promotion analysis to measure
the candidate revision instead of treating absent samples as success.
The repository needs a dedicated Forgejo runner labelled `docker`. It must have
Docker with Compose support, outbound access to the configured registries, and
network access to the Kubernetes API endpoint contained in the kubeconfig.
Keep this runner private to trusted repositories because deployment jobs can
access the Docker socket and production credentials.
Configure these encrypted repository or organization Actions secrets in the
Forgejo UI:
- `REGISTRY_USERNAME`: account allowed to push the three Nereus packages.
- `REGISTRY_PASSWORD`: package-scoped token for that account.
- `KUBECONFIG_B64`: base64 encoding of a least-privilege deployment kubeconfig.
The workflow never prints these values. It writes the kubeconfig to the
ephemeral workspace with mode `0600`, removes it in an `always()` step, and
creates the Kubernetes registry pull secret through a pipe so its generated
manifest is not logged or committed. The pre-existing `nereus-db` Secret is
required and is only checked for presence.

68
docs/evidence.md Normal file
View file

@ -0,0 +1,68 @@
# Final project verification evidence
Captured on 2026-08-24 from the disposable QEMU and k3d integration labs. The
commands below expose no credentials and can be rerun before the presentation
to refresh screenshots or terminal recordings.
## Two-node Fedora cluster
`scripts/provision/lab.sh check` passed after provisioning, host reboot, node 2
disconnect/reconnect, and complete deletion/recreation of node 2. At the final
check both Fedora 44 machines ran k3s `v1.33.4+k3s1` and reported `Ready`:
```text
nereus-node1 Ready control-plane,master 192.168.122.10
nereus-node2 Ready <none> 192.168.122.11
```
The node-replacement test found and fixed two recovery requirements: remove the
replacement address from the private SSH known-hosts file, and delete the old
Kubernetes node identity before the new agent joins. The PostgreSQL local-path
volume was correctly treated as lost with the deleted VM, recreated empty, and
reseeded by the load generator.
## Live application
The final namespace check showed three ready API pods, one ready load-generator
pod, and one ready PostgreSQL pod. The same-origin Mini PC route returned:
```text
GET / 200
GET /healthz 200
GET /readyz 200
X-Nereus-Version 0.1.0
GET /api/v1/buoys 42 records
```
The static dashboard stayed available while the cluster API was unavailable
during node recovery, then recovered without a page reload.
## Container evidence
The locally inspected `nereus-api:0.1.0` image is 7,815,732 bytes and declares
numeric non-root user `65532`. The container verification also passed with a
read-only root filesystem.
## Rollback evidence
The local k3d lab was destroyed and recreated from scratch. Re-running
`scripts/k3d/lab.sh up` reconciled the running cluster successfully. The proof
produced these AnalysisRun results:
```text
probe-746bbb94df-2-pre Successful vector(0.0)
probe-556d5b659b-3-pre Failed vector(1.0)
```
After the failed analysis, the active Service selector remained on the healthy
revision `746bbb94df`.
## Presentation commands
```bash
scripts/provision/lab.sh check
curl -i https://nereus.fiwlabs.dev/healthz
curl -i https://nereus.fiwlabs.dev/readyz
curl -fsS https://nereus.fiwlabs.dev/api/v1/buoys | jq length
scripts/k3d/lab.sh prove
```

31
docs/mini-pc.md Normal file
View file

@ -0,0 +1,31 @@
# Mini PC web stack
The Mini PC stack joins the host's existing Traefik `proxy` network and proxies
the frozen API paths through nginx. It does not run its own Traefik instance,
PostgreSQL, the API, or the load generator.
Export the following values in the operator shell or service manager:
```text
WEB_HOST public dashboard hostname; defaults to nereus.fiwlabs.dev
CLUSTER_API_UPSTREAM cluster address and port, without a URL scheme
WEB_ASSET_PATH asset path relative to the build context; defaults to apps/web
```
Do not put these values in a repository `.env` file. Start the isolated stack
from the repository root:
```sh
docker compose -f build/compose.mini.yaml up -d --build
```
The Mini PC keeps the static assets directly in `~/Server/web/nereus`. Copy the
three build files into its `build/` directory, set `WEB_ASSET_PATH=.` when
building there, and join the existing external `proxy` network. The existing
Traefik instance supplies the `websecure` entrypoint and `porkbun` certificate
resolver; do not start a second proxy on ports 80 and 443.
When the cluster is unavailable, nginx continues serving the dashboard and
returns a gateway failure only for `/api/v1/*`, `/healthz`, and `/readyz`. The
browser keeps polling those relative paths and recovers when the cluster is
reachable again.

242
docs/roadmap.md Normal file
View file

@ -0,0 +1,242 @@
# Nereus delivery roadmap
Updated: 2026-08-26
`[x]` means locally verified. `[~]` means implemented but awaiting a real
integration environment. `[ ]` means not implemented. `[H]` is human-owned and
must not be edited by agents.
Update this file in the same change that completes or materially changes a
task. Mark a task `[x]` only after its stated verification passes.
## API service
- [x] Create the Go 1.26 module and dependency lock file.
- [x] Create the idempotent `buoys` table migration.
- [x] Create the idempotent `readings` table migration.
- [x] Create `idx_readings_buoy_time` idempotently.
- [x] Parse `PORT`, `CHAOS_ERROR_RATE`, and `LOG_LEVEL`.
- [x] Preserve the build-injected `APP_VERSION` value.
- [x] Require `DATABASE_URL` without logging it.
- [x] Start HTTP without waiting for PostgreSQL.
- [x] Retry migrations while PostgreSQL is unavailable.
- [x] Keep `/healthz` independent of PostgreSQL.
- [x] Require a reachable, migrated database for `/readyz`.
- [x] Implement `GET /api/v1/buoys`.
- [x] Implement `POST /api/v1/buoys`.
- [x] Implement `GET /api/v1/buoys/{id}`.
- [x] Implement `DELETE /api/v1/buoys/{id}`.
- [x] Implement filtered, paginated `GET /api/v1/readings`.
- [x] Implement `POST /api/v1/readings`.
- [x] Implement the real grouped aggregate query.
- [x] Bound JSON request bodies and return JSON errors.
- [x] Restrict chaos injection to `/api/v1/*`.
- [x] Expose all five required Prometheus metrics.
- [x] Use chi route templates for the HTTP `path` label.
- [x] Enable OTLP/gRPC tracing only when configured.
- [x] Create HTTP spans and child database spans.
- [x] Emit structured request logs containing `trace_id`.
- [x] Add table-driven handler tests without a live database.
- [x] Test chaos and unaffected health behavior.
- [x] Add a `testing.Short()`-guarded PostgreSQL integration test.
- [x] Pass `go test -short ./...`.
- [x] Pass `go vet ./...`.
- [x] Build with `CGO_ENABLED=0`.
- [x] Run the integration test against PostgreSQL 17.
- [x] Test database failure behavior on every data endpoint.
- [x] Assert exact metric names and route-template labels in tests.
- [x] Test malformed and oversized JSON request bodies.
- [x] Run `golangci-lint run` with the CI-selected version.
## Load generator
- [x] Create the standalone Go 1.26 module.
- [x] Require `TARGET_URL` and default `RPS` to 5.
- [x] Reuse existing buoys or seed 42 validated offshore buoys.
- [x] Generate the 60/25/15 request mix.
- [x] Generate plausible synthetic reading values.
- [x] Count HTTP statuses and network errors.
- [x] Log a JSON status summary every ten seconds.
- [x] Continue traffic while requests and seeding fail.
- [x] Retry seeding without blocking read traffic.
- [x] Handle SIGTERM and SIGINT.
- [x] Keep the implementation below 200 lines.
- [x] Pass `go vet` and a static build.
- [x] Test request weighting deterministically.
- [x] Test seeding against an empty `httptest` API.
- [x] Test continued traffic through repeated HTTP 500 responses.
- [x] Run against the real API for at least five minutes.
- [x] Confirm Prometheus receives continuous request samples.
## Container build and local Compose
- [x] Create the API builder stage from `golang:1.26-alpine`.
- [x] Cache dependencies before copying API source.
- [x] Build with `CGO_ENABLED=0` and `-ldflags "-s -w"`.
- [x] Inject `APP_VERSION` with `-X`.
- [x] Copy only the API binary into `gcr.io/distroless/static:nonroot`.
- [x] Create the load-generator multi-stage image.
- [x] Use `gcr.io/distroless/static:nonroot` for the load generator.
- [x] Add the required `.dockerignore` exclusions.
- [x] Confirm both images run non-root and read-only.
- [x] Confirm the API image is below 25 MB (7,815,877 bytes locally).
- [x] Add API, PostgreSQL 17, and OTEL Collector to local Compose.
- [x] Add the PostgreSQL healthcheck.
- [x] Make the API wait on healthy PostgreSQL in Compose.
- [x] Verify `docker compose up` reaches API readiness.
- [x] Keep Mini PC and local-development Compose files separate.
## Static web dashboard
- [x] Create the no-build static HTML entry point.
- [x] Create the dark teal/navy glass visual system.
- [x] Replace decorative background waves with an operational chart grid.
- [x] Keep version, health, and error panels visible together.
- [x] Show the build-injected API version from the existing health response header.
- [x] Poll `/healthz` every two seconds.
- [x] Poll `/readyz` every two seconds.
- [x] Show each health indicator's last transition timestamp.
- [x] Count non-2xx `/api/v1/*` browser responses.
- [x] Calculate the rolling 60-second HTTP error rate.
- [x] Distinguish network-offline events from HTTP errors.
- [x] Preserve and grey the last known telemetry values offline.
- [x] Recover automatically when the API returns.
- [x] Add a fully local Natural Earth buoy map and pulsing markers.
- [x] Add animated reading charts.
- [x] Keep all API calls same-origin and relative.
- [x] Remove all frontend runtime dependencies on external hosts.
- [x] Verify all panels fit at 1280×720 without page scrolling.
- [x] Create the unprivileged nginx image.
- [x] Create the separate Mini PC Traefik Compose file.
- [x] Test the finished offline/degraded rendering without API connectivity.
- [x] Test the rendered dashboard while the API returns HTTP 500 responses.
## Observability
- [x] Receive OTLP/gRPC traces in the Collector.
- [x] Export traces from the Collector to Tempo.
- [~] Ship structured Kubernetes pod logs to Loki with the cluster Collector.
- [x] Start Loki locally and verify its Grafana data source readiness.
- [x] Configure Prometheus to scrape `/metrics`.
- [x] Add request-rate, error-rate, and latency panels.
- [x] Add database-operation latency panels.
- [x] Add readings-ingested and active-buoy panels.
- [x] Add a deployed-version dashboard variable.
- [~] Correlate logs and traces using `trace_id` after cluster log ingestion exists.
- [x] Add API error-rate, latency, readiness, and scrape alerts.
- [x] Load the dashboard and evaluate all six PromQL queries.
- [x] Trigger chaos and distinguish the failing version in Grafana.
## Fedora 44 host provisioning
- [x] Separate node 1 and node 2 inventory groups.
- [x] Pin the project k3s version.
- [x] Reject managed hosts that are not Fedora 44.
- [x] Install common host dependencies.
- [x] Enable firewalld.
- [x] Trust the configured pod and service CIDRs.
- [x] Open VXLAN port 8472/UDP between nodes.
- [x] Open kubelet port 10250/TCP between nodes.
- [x] Open API server port 6443/TCP on node 1.
- [x] Install node 1 as the k3s server.
- [x] Install node 2 as a k3s agent.
- [x] Keep the cluster token out of repository files and output.
- [x] Enable both k3s systemd services.
- [x] Wait for every Kubernetes node to become Ready.
- [x] Add the equivalent manual configuration checklist.
- [x] Document Terraform-to-Ansible inventory handoff.
- [x] Keep libvirt addresses, bridge rules, and VM network profiles out of the reusable Ansible roles.
- [x] Run the playbook twice on two clean Fedora 44 QEMU machines.
- [x] Confirm the second run reports no unintended changes (`changed=0`).
- [x] Restrict firewall sources to actual node/operator networks.
- [x] Confirm SELinux remains enforcing on both QEMU machines.
- [x] Reboot both QEMU machines and confirm automatic recovery.
- [x] Disconnect and reconnect node 2, then confirm it rejoins.
- [x] Record tested minimum CPU, memory, and disk requirements.
## One-command QEMU lab
- [x] Check QEMU, libvirt, cloud-utils, Ansible, SSH, ping, and KVM.
- [x] Install missing dependencies on a Fedora workstation.
- [x] Enable libvirt and its default network.
- [x] Discover and cache the Fedora 44 x86_64 cloud image.
- [x] Keep SSH and VM state outside the repository.
- [x] Generate cloud-init media for both nodes.
- [x] Create reusable copy-on-write disks.
- [x] Create each VM with two vCPUs and 3 GiB RAM.
- [x] Assign deterministic addresses to both local VMs.
- [x] Wait for ICMP ping and SSH on both nodes.
- [x] Generate the Ansible inventory automatically.
- [x] Configure both nodes automatically.
- [x] Check Ansible reachability, k3s files, and binaries.
- [x] Check firewalld, k3s services, ports 6443 and 10250.
- [x] Check Kubernetes node readiness.
- [x] Add `check`, `stop`, and explicit `destroy` actions.
- [x] Execute `lab.sh up` with working `/dev/kvm` and pass the full verification.
- [x] Reboot both local VMs and pass the bounded post-reboot service and node checks.
- [x] Execute `lab.sh check` after a workstation reboot.
- [x] Test recovery from an interrupted image download and validate the resumed QCOW2.
- [x] Test recovery after node 2 is deleted manually and rejoin it with a fresh identity.
- [ ] Test on a workstation with no dependencies installed.
- [x] Decide whether an aarch64 image path is required; keep the lab x86_64-only.
- [ ] After final sign-off, destroy the QEMU lab and remove packages installed only for it with Pacman's dependency-aware cleanup.
## Local k3d rollback mechanism
- [x] Define one local server and two local agent nodes.
- [x] Map Traefik to host ports 8080 and 8443.
- [x] Add lean kube-prometheus-stack values.
- [x] Add the synthetic Argo Rollouts analysis harness.
- [x] Add an idempotent `up/check/destroy` command and verify fresh creation and reconciliation.
- [x] Install Argo Rollouts.
- [x] Install kube-prometheus-stack with the local values.
- [x] Apply the synthetic harness.
- [x] Prove `vector(0.0)` permits promotion.
- [x] Change the test value to `vector(1.0)`.
- [x] Prove failed analysis aborts promotion with `vector(0.9)`.
- [x] Tear down and recreate the test from scratch.
## Human-owned deployment and infrastructure
- [H] Define infrastructure modules under `terraform/`.
- [H] Export provisioned addresses for Ansible inventory.
- [H] Create Kubernetes base resources and overlays. Agent-authored 2026-08-21 at the owner's request; builds and server dry-runs pass, not applied.
- [H] Create API and load-generator workloads. Agent-authored 2026-08-21; not applied.
- [H] Create active and preview Services. Agent-authored 2026-08-21; not applied.
- [H] Create the Prometheus ServiceMonitor. Agent-authored 2026-08-21; not applied, scrape not confirmed.
- [H] Create the real error-rate AnalysisTemplate. Agent-authored 2026-08-21; PromQL never evaluated against real series.
- [H] Configure blue-green promotion and automated rollback. Agent-authored 2026-08-21; promotion and abort paths untested with the real API.
- [H] Maintain Forgejo Actions and GitHub mirror workflows.
- [H] Create sealed secrets from off-repository plaintext inputs.
## Forgejo CI/CD
- [x] Run API and load-generator race tests, vet, and pinned lint in containers.
- [x] Scan committed history and the working tree for secrets.
- [x] Scan release configuration and images for high or critical findings.
- [x] Build immutable commit-SHA API, load-generator, and web image tags.
- [x] Keep active and preview traffic flowing during rollout analysis.
- [~] Run the Forgejo workflow after a private `docker` runner and encrypted secrets are configured.
- [~] Publish images and execute the first automated production rollout.
## End-to-end acceptance
- [ ] Provision two clean Fedora hosts.
- [ ] Reboot both hosts before platform installation.
- [x] Install the cluster platform components.
- [x] Deploy PostgreSQL and wait for health.
- [x] Verify API liveness before database readiness.
- [x] Verify readiness after migrations complete.
- [x] Deploy the load generator and observe continuous samples.
- [x] Send one request to every API endpoint in the local Compose stack.
- [x] Verify every required metric and exact label name locally.
- [x] Verify an HTTP trace contains a child database span.
- [x] Correlate one stdout request log and Tempo trace by `trace_id`.
- [x] Promote a healthy preview through analysis.
- [x] Deploy a preview with a nonzero chaos rate.
- [x] Observe preview errors in Prometheus.
- [x] Confirm analysis aborts the unhealthy promotion.
- [x] Confirm the active Service remains on the healthy version.
- [x] Confirm load generation continues throughout failure.
- [x] Confirm the Mini PC page remains usable with the cluster off.
- [x] Capture reproducible, non-secret evidence needed for the final project report.

23
observability/README.md Normal file
View file

@ -0,0 +1,23 @@
# Nereus observability
Start the application and its local observability overlay with one command:
```bash
docker compose -f compose.yaml -f observability/compose.yaml up -d
```
The local endpoints are:
- Grafana: `http://127.0.0.1:13000`
- Prometheus: `http://127.0.0.1:19090`
- API: `http://127.0.0.1:18080`
Grafana uses anonymous administrator access only in this local overlay. No
password or credential is stored in the repository. Stop the complete stack
with the same two Compose files and `down`.
`otel-collector/local.yaml` handles local API traces. `otel-collector/config.yaml`
is the cluster configuration: it receives traces and tails only pod logs under
the `nereus` namespace path, then exports traces to Tempo and logs to Loki. Log
collection and trace-to-log links therefore require the real Kubernetes
environment and are not simulated in local Compose.

View file

@ -0,0 +1,46 @@
groups:
- name: nereus-api
rules:
- alert: NereusAPIHighErrorRate
expr: |
sum(rate(nereus_http_requests_total{path=~"/api/v1/.*",status=~"5.."}[2m]))
/
clamp_min(sum(rate(nereus_http_requests_total{path=~"/api/v1/.*"}[2m])), 0.001)
> 0.05
for: 1m
labels:
severity: critical
annotations:
summary: Nereus API error rate exceeds 5 percent
description: The API has returned more than 5 percent HTTP 5xx responses for one minute.
- alert: NereusAPIHighLatency
expr: |
histogram_quantile(
0.95,
sum by (le) (rate(nereus_http_request_duration_seconds_bucket{path=~"/api/v1/.*"}[5m]))
) > 1
for: 2m
labels:
severity: warning
annotations:
summary: Nereus API p95 latency exceeds one second
description: The API p95 request duration has exceeded one second for two minutes.
- alert: NereusAPIReadinessFailing
expr: sum(rate(nereus_http_requests_total{path="/readyz",status="503"}[2m])) > 0
for: 1m
labels:
severity: critical
annotations:
summary: Nereus API cannot reach its database
description: Readiness requests have returned HTTP 503 for one minute.
- alert: NereusAPIDown
expr: up{job="nereus-api"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: Prometheus cannot scrape the Nereus API
description: The Nereus API metrics endpoint has been unreachable for one minute.

View file

@ -0,0 +1,57 @@
services:
prometheus:
image: prom/prometheus:v3.14.0
command:
- --config.file=/etc/prometheus/prometheus.yaml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=6h
ports:
- "${PROMETHEUS_PORT:-19090}:9090"
volumes:
- ./observability/prometheus/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro
- ./observability/alerts:/etc/prometheus/rules:ro
- prometheus-data:/prometheus
tempo:
image: grafana/tempo:3.0.3
command: ["-config.file=/etc/tempo.yaml"]
volumes:
- ./observability/tempo/tempo.yaml:/etc/tempo.yaml:ro
tmpfs:
- /var/tempo:uid=10001,gid=10001,mode=0755
loki:
image: grafana/loki:3.7.6
command: ["-config.file=/etc/loki/local-config.yaml"]
volumes:
- ./observability/loki/loki.yaml:/etc/loki/local-config.yaml:ro
tmpfs:
- /var/loki:uid=10001,gid=10001,mode=0755
otel-collector:
volumes:
- ./observability/otel-collector/local.yaml:/etc/otelcol-contrib/config.yaml:ro
depends_on:
- tempo
- loki
grafana:
image: grafana/grafana:13.2.0
environment:
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
GF_AUTH_DISABLE_LOGIN_FORM: "true"
ports:
- "${GRAFANA_PORT:-13000}:3000"
volumes:
- ./observability/grafana/provisioning:/etc/grafana/provisioning:ro
- ./observability/grafana/dashboards:/var/lib/grafana/dashboards:ro
- grafana-data:/var/lib/grafana
depends_on:
- prometheus
- tempo
- loki
volumes:
prometheus-data:
grafana-data:

View file

@ -0,0 +1,92 @@
{
"annotations": {"list": []},
"editable": false,
"graphTooltip": 1,
"panels": [
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {"defaults": {"unit": "reqps"}},
"gridPos": {"h": 8, "w": 8, "x": 0, "y": 0},
"id": 1,
"targets": [{"expr": "sum by (version) (rate(nereus_http_requests_total{path=~\"/api/v1/.*\",version=~\"$version\"}[1m]))", "legendFormat": "{{version}}", "refId": "A"}],
"title": "API request rate",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {"defaults": {"unit": "percentunit", "min": 0, "max": 1}},
"gridPos": {"h": 8, "w": 8, "x": 8, "y": 0},
"id": 2,
"targets": [{"expr": "sum by (version) (rate(nereus_http_requests_total{path=~\"/api/v1/.*\",status=~\"5..\",version=~\"$version\"}[1m])) / clamp_min(sum by (version) (rate(nereus_http_requests_total{path=~\"/api/v1/.*\",version=~\"$version\"}[1m])), 0.001)", "legendFormat": "{{version}}", "refId": "A"}],
"title": "API error rate",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {"defaults": {"unit": "s"}},
"gridPos": {"h": 8, "w": 8, "x": 16, "y": 0},
"id": 3,
"targets": [{"expr": "histogram_quantile(0.95, sum by (le, version) (rate(nereus_http_request_duration_seconds_bucket{path=~\"/api/v1/.*\",version=~\"$version\"}[5m])))", "legendFormat": "p95 {{version}}", "refId": "A"}],
"title": "API p95 latency",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {"defaults": {"unit": "s"}},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
"id": 4,
"targets": [{"expr": "histogram_quantile(0.95, sum by (le, operation) (rate(nereus_db_query_duration_seconds_bucket[5m])))", "legendFormat": "{{operation}}", "refId": "A"}],
"title": "Database p95 latency",
"type": "timeseries"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"fieldConfig": {"defaults": {"unit": "ops"}},
"gridPos": {"h": 8, "w": 6, "x": 12, "y": 8},
"id": 5,
"targets": [{"expr": "sum(rate(nereus_readings_ingested_total[1m]))", "refId": "A"}],
"title": "Readings ingested",
"type": "stat"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"gridPos": {"h": 8, "w": 6, "x": 18, "y": 8},
"id": 6,
"targets": [{"expr": "nereus_buoys_active", "refId": "A"}],
"title": "Active buoys",
"type": "stat"
},
{
"datasource": {"type": "loki", "uid": "loki"},
"gridPos": {"h": 9, "w": 24, "x": 0, "y": 16},
"id": 7,
"targets": [{"expr": "{k8s_namespace_name=\"nereus\"} | json", "refId": "A"}],
"title": "Correlated API logs",
"type": "logs"
}
],
"refresh": "5s",
"schemaVersion": 42,
"tags": ["nereus"],
"templating": {
"list": [
{
"allValue": ".*",
"current": {"text": "All", "value": "$__all"},
"datasource": {"type": "prometheus", "uid": "prometheus"},
"definition": "label_values(nereus_http_requests_total, version)",
"includeAll": true,
"label": "Version",
"name": "version",
"query": {"query": "label_values(nereus_http_requests_total, version)", "refId": "StandardVariableQuery"},
"refresh": 1,
"type": "query"
}
]
},
"time": {"from": "now-30m", "to": "now"},
"timezone": "browser",
"title": "Nereus API",
"uid": "nereus-api",
"version": 1
}

View file

@ -0,0 +1,10 @@
apiVersion: 1
providers:
- name: Nereus
folder: Nereus
type: file
disableDeletion: true
editable: false
options:
path: /var/lib/grafana/dashboards

View file

@ -0,0 +1,41 @@
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
jsonData:
timeInterval: 5s
- name: Loki
uid: loki
type: loki
access: proxy
url: http://loki:3100
editable: false
jsonData:
derivedFields:
- name: TraceID
matcherRegex: '"trace_id":"([a-f0-9]{32})"'
datasourceUid: tempo
url: '${__value.raw}'
- name: Tempo
uid: tempo
type: tempo
access: proxy
url: http://tempo:3200
editable: false
jsonData:
tracesToLogsV2:
datasourceUid: loki
spanStartTimeShift: -1m
spanEndTimeShift: 1m
tags:
- key: service.name
value: service_name
filterByTraceID: true

View file

@ -0,0 +1,28 @@
auth_enabled: false
server:
http_listen_port: 3100
common:
path_prefix: /var/loki
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
storage_config:
filesystem:
directory: /var/loki/chunks
analytics:
reporting_enabled: false

View file

@ -0,0 +1,44 @@
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
file_log/nereus:
include:
- /var/log/pods/nereus_*/*/*.log
include_file_path: true
operators:
- type: container
id: parse-container-log
processors:
memory_limiter:
check_interval: 1s
limit_mib: 256
k8s_attributes:
extract:
metadata:
- k8s.namespace.name
- k8s.pod.name
- k8s.container.name
batch:
timeout: 5s
exporters:
otlp_grpc/tempo:
endpoint: tempo:4317
tls:
insecure: true
otlp_http/loki:
endpoint: http://loki:3100/otlp
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, k8s_attributes, batch]
exporters: [otlp_grpc/tempo]
logs:
receivers: [file_log/nereus]
processors: [memory_limiter, k8s_attributes, batch]
exporters: [otlp_http/loki]

View file

@ -0,0 +1,25 @@
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
processors:
memory_limiter:
check_interval: 1s
limit_mib: 256
batch:
timeout: 5s
exporters:
otlp_grpc/tempo:
endpoint: tempo:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlp_grpc/tempo]

View file

@ -0,0 +1,12 @@
global:
scrape_interval: 5s
evaluation_interval: 5s
rule_files:
- /etc/prometheus/rules/*.yaml
scrape_configs:
- job_name: nereus-api
metrics_path: /metrics
static_configs:
- targets: [api:8080]

View file

@ -0,0 +1,17 @@
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
storage:
trace:
backend: local
wal:
path: /var/tempo/wal
local:
path: /var/tempo/traces

33
scripts/k3d-nereus.yaml Normal file
View file

@ -0,0 +1,33 @@
apiVersion: k3d.io/v1alpha5
kind: Simple
metadata:
name: nereus
# Three nodes so this is a real multi-node k3s, not a single-node toy.
# Mirrors the two Fedora VMs plus room for a rollout to spread across nodes.
servers: 1
agents: 2
image: docker.io/rancher/k3s:v1.33.4-k3s1
ports:
# Traefik on 8080/8443 so it never fights the Mini PC stack or a local dev server.
- port: 8080:80
nodeFilters: [loadbalancer]
- port: 8443:443
nodeFilters: [loadbalancer]
options:
k3d:
wait: true
timeout: 180s
k3s:
extraArgs:
# kube-prometheus-stack scrapes these; k3s binds them to localhost by default.
- arg: --kube-controller-manager-arg=bind-address=0.0.0.0
nodeFilters: [server:*]
- arg: --kube-scheduler-arg=bind-address=0.0.0.0
nodeFilters: [server:*]
kubeconfig:
updateDefaultKubeconfig: true
switchCurrentContext: true

View file

@ -0,0 +1,71 @@
# Mechanism test for the automated rollback, run before the real API exists.
#
# It proves the exact chain the project depends on: Argo Rollouts pauses a
# blue-green promotion, runs an AnalysisRun, that run queries Prometheus over
# the network, evaluates the result against a threshold, and aborts the rollout
# on failure. The only thing faked is the metric itself.
#
# Throwaway. deploy/rollouts/ holds the real thing.
---
apiVersion: v1
kind: Service
metadata: {name: probe-active, namespace: nereus}
spec:
selector: {app: probe}
ports: [{port: 80, targetPort: 8080}]
---
apiVersion: v1
kind: Service
metadata: {name: probe-preview, namespace: nereus}
spec:
selector: {app: probe}
ports: [{port: 80, targetPort: 8080}]
---
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata: {name: error-rate, namespace: nereus}
spec:
metrics:
- name: error-rate
interval: 10s
count: 3
# Same shape as the real query will use: fail when the error ratio is
# above the threshold. failureLimit 0 means one bad sample aborts.
successCondition: "result[0] < 0.05"
failureLimit: 0
provider:
prometheus:
address: http://kube-prometheus-stack-prometheus.observability.svc.cluster.local:9090
query: "{{args.query}}"
args:
- name: query
---
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata: {name: probe, namespace: nereus}
spec:
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels: {app: probe}
template:
metadata:
labels: {app: probe}
spec:
containers:
- name: web
image: nginxinc/nginx-unprivileged:alpine
ports: [{containerPort: 8080}]
resources:
requests: {cpu: 10m, memory: 16Mi}
strategy:
blueGreen:
activeService: probe-active
previewService: probe-preview
autoPromotionEnabled: true
prePromotionAnalysis:
templates:
- templateName: error-rate
args:
- name: query
value: "vector(0.0)"

View file

@ -0,0 +1,65 @@
# Values for the k3d (level 2) cluster only. Kept lean so the whole stack fits
# alongside the app on a laptop-sized machine.
#
# No credentials here. Grafana's admin password is generated by the chart into a
# secret; read it with:
# kubectl -n observability get secret kube-prometheus-stack-grafana \
# -o jsonpath='{.data.admin-password}' | base64 -d
# k3s does not expose these the way a kubeadm cluster does. Left enabled they
# produce permanently-firing "target down" alerts that bury the real ones.
kubeEtcd:
enabled: false
kubeProxy:
enabled: false
kubeControllerManager:
service:
port: 10257
targetPort: 10257
serviceMonitor:
https: true
insecureSkipVerify: true
kubeScheduler:
service:
port: 10259
targetPort: 10259
serviceMonitor:
https: true
insecureSkipVerify: true
prometheus:
prometheusSpec:
retention: 6h
resources:
requests: {cpu: 100m, memory: 512Mi}
limits: {memory: 1500Mi}
# Pick up ServiceMonitors from every namespace, not just the chart's own
# release. The app lives in `nereus` and must be scraped from there.
serviceMonitorSelectorNilUsesHelmValues: false
podMonitorSelectorNilUsesHelmValues: false
ruleSelectorNilUsesHelmValues: false
storageSpec:
volumeClaimTemplate:
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 5Gi
grafana:
defaultDashboardsTimezone: browser
resources:
requests: {cpu: 50m, memory: 128Mi}
limits: {memory: 512Mi}
alertmanager:
alertmanagerSpec:
resources:
requests: {cpu: 25m, memory: 64Mi}
limits: {memory: 256Mi}
prometheusOperator:
resources:
requests: {cpu: 50m, memory: 128Mi}
limits: {memory: 512Mi}

180
scripts/k3d/lab.sh Executable file
View file

@ -0,0 +1,180 @@
#!/usr/bin/env bash
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
readonly CLUSTER_NAME="nereus"
readonly CONTEXT="k3d-${CLUSTER_NAME}"
readonly ARGO_CHART_VERSION="2.41.1"
readonly PROMETHEUS_CHART_VERSION="88.5.2"
require_commands() {
local command_name
for command_name in docker k3d helm kubectl; do
command -v "${command_name}" >/dev/null || {
echo "missing required command: ${command_name}" >&2
exit 1
}
done
docker info >/dev/null
}
use_context() {
kubectl config use-context "${CONTEXT}" >/dev/null
}
cluster_exists() {
k3d cluster get "${CLUSTER_NAME}" >/dev/null 2>&1
}
up() {
require_commands
if ! cluster_exists; then
k3d cluster create --config "${REPO_ROOT}/scripts/k3d-nereus.yaml"
else
k3d cluster start "${CLUSTER_NAME}"
fi
use_context
helm repo add argo https://argoproj.github.io/argo-helm --force-update
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts --force-update
helm repo update
helm upgrade --install argo-rollouts argo/argo-rollouts \
--namespace argo-rollouts \
--create-namespace \
--version "${ARGO_CHART_VERSION}" \
--wait \
--timeout 5m
helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack \
--namespace observability \
--create-namespace \
--version "${PROMETHEUS_CHART_VERSION}" \
--values "${SCRIPT_DIR}/kube-prometheus-stack.values.yaml" \
--wait \
--timeout 10m
kubectl create namespace nereus --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f "${SCRIPT_DIR}/analysis-harness/harness.yaml"
check
}
check() {
require_commands
cluster_exists || {
echo "cluster ${CLUSTER_NAME} does not exist; run $0 up" >&2
exit 1
}
use_context
kubectl wait node --all --for=condition=Ready --timeout=3m
kubectl wait deployment --all --namespace argo-rollouts --for=condition=Available --timeout=3m
kubectl wait deployment --all --namespace observability --for=condition=Available --timeout=5m
kubectl get rollout probe --namespace nereus >/dev/null
kubectl get analysistemplate error-rate --namespace nereus >/dev/null
echo "k3d rollback lab is ready"
}
analysis_name() {
kubectl get rollout probe --namespace nereus \
-o jsonpath='{.status.blueGreen.prePromotionAnalysisRunStatus.name}'
}
wait_for_analysis() {
local previous_name="$1"
local expected_phase="$2"
local analysis_run=""
local phase=""
local attempt
for attempt in {1..90}; do
analysis_run="$(analysis_name)"
if [[ -n "${analysis_run}" && "${analysis_run}" != "${previous_name}" ]]; then
phase="$(kubectl get analysisrun "${analysis_run}" --namespace nereus -o jsonpath='{.status.phase}')"
if [[ "${phase}" == "${expected_phase}" ]]; then
echo "${analysis_run} reached ${expected_phase}"
return 0
fi
if [[ "${phase}" == "Error" || "${phase}" == "Inconclusive" ]]; then
echo "${analysis_run} ended unexpectedly with ${phase}" >&2
return 1
fi
fi
sleep 2
done
echo "analysis did not reach ${expected_phase} within 180 seconds" >&2
return 1
}
set_proof_revision() {
local query="$1"
local revision="$2"
kubectl patch rollout probe --namespace nereus --type=merge --patch \
"{\"spec\":{\"strategy\":{\"blueGreen\":{\"prePromotionAnalysis\":{\"args\":[{\"name\":\"query\",\"value\":\"${query}\"}]}}},\"template\":{\"metadata\":{\"annotations\":{\"nereus.fiwlabs.dev/proof\":\"${revision}\"}}}}}"
}
prove() {
check
local proof_id
local previous_analysis
local healthy_analysis
local healthy_revision=""
local active_revision=""
local stable_revision=""
local attempt
proof_id="$(date -u +%Y%m%d%H%M%S)"
previous_analysis="$(analysis_name)"
set_proof_revision "vector(0.0)" "healthy-${proof_id}"
wait_for_analysis "${previous_analysis}" Successful
healthy_analysis="$(analysis_name)"
for attempt in {1..30}; do
healthy_revision="$(kubectl get rollout probe --namespace nereus -o jsonpath='{.status.stableRS}')"
active_revision="$(kubectl get service probe-active --namespace nereus -o jsonpath='{.spec.selector.rollouts-pod-template-hash}')"
if [[ -n "${healthy_revision}" && "${active_revision}" == "${healthy_revision}" ]]; then
break
fi
sleep 2
done
[[ -n "${healthy_revision}" && "${active_revision}" == "${healthy_revision}" ]] || {
echo "healthy revision was not promoted" >&2
return 1
}
set_proof_revision "vector(1.0)" "failing-${proof_id}"
wait_for_analysis "${healthy_analysis}" Failed
stable_revision="$(kubectl get rollout probe --namespace nereus -o jsonpath='{.status.stableRS}')"
active_revision="$(kubectl get service probe-active --namespace nereus -o jsonpath='{.spec.selector.rollouts-pod-template-hash}')"
[[ "${stable_revision}" == "${healthy_revision}" && "${active_revision}" == "${healthy_revision}" ]] || {
echo "failed revision replaced the active healthy revision" >&2
return 1
}
set_proof_revision "vector(0.0)" "healthy-${proof_id}"
echo "rollback proof passed; active revision stayed ${healthy_revision}"
}
destroy() {
require_commands
if cluster_exists; then
k3d cluster delete "${CLUSTER_NAME}"
else
echo "cluster ${CLUSTER_NAME} does not exist"
fi
}
case "${1:-}" in
up) up ;;
check) check ;;
prove) prove ;;
destroy) destroy ;;
*)
echo "usage: $0 {up|check|prove|destroy}" >&2
exit 2
;;
esac

View file

@ -0,0 +1,97 @@
# Fedora host provisioning
This directory configures already-installed Fedora 44 hosts. Infrastructure can
be created manually or by the hand-maintained Terraform configuration; both
paths produce the same input: two reachable hosts in `inventory.yml`.
Node 1 runs the k3s server. Node 2 runs a k3s agent. The playbook is idempotent
and may be rerun to converge package, firewall, k3s, and service state.
## Automated path
Copy `inventory.example.yml` to `inventory.yml`, replace the addresses and node
IP values, set `k3s_node_cidr` to the network containing only cluster nodes,
set `k3s_operator_cidrs` to the authorized administration networks, and set the
SSH user and connection options required by those hosts.
The managed machines may be physical servers, VMs from any provider, or
manually installed systems; the playbook does not depend on libvirt or the
local lab subnet. Then provide the existing cluster token only in the process
environment:
```bash
K3S_TOKEN="$(openssl rand -hex 32)" ./bootstrap.sh inventory.yml
```
The bootstrap checks for `ansible-playbook` and installs the declared Ansible
collections. It does not install system packages on the operator machine.
Do not save the token in the inventory or repository. Preserve it in the
operator's secret manager so a replacement agent can join the same cluster.
## Manual path
Use `manual-checklist.md` when configuration must be performed interactively.
It describes the same end state as the playbook, so a manually prepared host
can later be managed by Ansible without rebuilding it.
## Terraform path
Terraform is responsible only for creating machines, networks, and addresses.
After `terraform apply`, put its resulting addresses into `inventory.yml` and
run this playbook. Keeping configuration out of provisioner hooks makes the
same Ansible workflow usable for physical hardware, VMs, and manually created
hosts.
## One-command local QEMU lab
`lab.sh` is only a disposable integration harness for this workstation. Its
`virbr0` interface, fixed test addresses, UFW forwarding rules, cloud image,
and NetworkManager profiles are deliberately kept out of the reusable Ansible
roles. Do not run it on the two production machines; put their real addresses
in an inventory and run `bootstrap.sh` instead.
On an x86_64 Fedora or Arch-family workstation with hardware virtualization enabled:
```bash
./lab.sh
```
To reuse an existing Fedora 44 Cloud Base Generic QCOW2 image instead of
downloading another copy:
```bash
FEDORA_IMAGE=/path/to/Fedora-Cloud-Base-Generic-44.x86_64.qcow2 ./lab.sh up
```
The lab intentionally supports x86_64 only. Both target Fedora hosts and the
workstation used for the final project run x86_64, so maintaining a separate
aarch64 image, firmware, and verification path would add an untested platform
without helping the deployment demonstration.
The Fedora Server Guest Generic image is not suitable for this workflow because
it starts the interactive initial-setup program instead of accepting cloud-init
configuration.
The command installs missing host packages, enables libvirt, uses the selected
local image or downloads the Fedora 44 cloud image, creates two reusable VMs,
waits for ping and SSH, runs the Ansible configuration, and verifies
applications, services, directories, ports, and Kubernetes node readiness. Its
VM metadata and SSH key live under
`${XDG_STATE_HOME:-$HOME/.local/state}/nereus-lab`, outside the repository.
Subsequent operations are `./lab.sh check`, `./lab.sh stop`, and the explicitly
destructive `./lab.sh destroy`.
Deleting a VM also deletes any k3s local-path volumes stored on that machine.
The lab recreates the host and its Kubernetes identity, but stateful demo data
on the deleted disk must be recreated separately. This is acceptable for the
disposable harness and is not a backup strategy.
### Tested lab capacity
The complete two-node stack passed provisioning, reboot, node-disconnect
recovery, workload readiness, and public API checks on 2026-08-24 with 2 vCPUs,
3 GiB RAM, and a 30 GiB virtual disk per node. These are the lowest settings
tested for this project, not a production sizing recommendation. The thin
QCOW2 files used approximately 2.0 GiB for node 1 and 3.3 GiB for node 2 during
that verification.

View file

@ -0,0 +1,8 @@
[defaults]
inventory = inventory.yml
interpreter_python = auto_silent
retry_files_enabled = False
stdout_callback = default
[ssh_connection]
pipelining = True

25
scripts/provision/bootstrap.sh Executable file
View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
inventory=${1:-"$script_dir/inventory.yml"}
if [[ ! -f "$inventory" ]]; then
printf 'Inventory not found: %s\nCopy %s/inventory.example.yml and edit it first.\n' "$inventory" "$script_dir" >&2
exit 1
fi
inventory=$(realpath -- "$inventory")
if [[ -z "${K3S_TOKEN:-}" ]]; then
printf 'K3S_TOKEN must be supplied through the environment.\n' >&2
exit 1
fi
if ! command -v ansible-playbook >/dev/null 2>&1; then
printf 'ansible-playbook is required. Install ansible-core on this operator machine.\n' >&2
exit 1
fi
cd "$script_dir"
ansible-galaxy collection install --requirements-file requirements.yml
ansible-playbook --inventory "$inventory" site.yml

View file

@ -0,0 +1,20 @@
all:
vars:
ansible_user: fedora
k3s_version: v1.33.4+k3s1
k3s_cluster_cidr: 10.42.0.0/16
k3s_service_cidr: 10.43.0.0/16
k3s_node_cidr: 192.0.2.0/24
k3s_operator_cidrs:
- 198.51.100.0/24
children:
k3s_server:
hosts:
node1:
ansible_host: 192.0.2.10
k3s_node_ip: 192.0.2.10
k3s_agent:
hosts:
node2:
ansible_host: 192.0.2.11
k3s_node_ip: 192.0.2.11

232
scripts/provision/lab.sh Executable file
View file

@ -0,0 +1,232 @@
#!/usr/bin/env bash
set -euo pipefail
action=${1:-up}
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
state_root=${XDG_STATE_HOME:-"$HOME/.local/state"}/nereus-lab
image_root=/var/lib/libvirt/images/nereus-lab
inventory="$state_root/inventory.yml"
key="$state_root/id_ed25519"
known_hosts="$state_root/known_hosts"
token_file="$state_root/k3s-token"
fedora_release=44
nodes=(nereus-node1 nereus-node2)
created_nodes=()
log() { printf '[nereus-lab] %s\n' "$*"; }
fail() { printf '[nereus-lab] ERROR: %s\n' "$*" >&2; exit 1; }
install_dependencies() {
local missing=()
for tool in qemu-img virsh virt-install virt-customize cloud-localds ansible-playbook curl ssh-keygen openssl ping; do
command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
done
((${#missing[@]} == 0)) && return
[[ -r /etc/os-release ]] || fail "missing tools: ${missing[*]}"
# shellcheck disable=SC1091
. /etc/os-release
log "installing virtualization, cloud-init, and Ansible tooling"
case ${ID:-} in
fedora)
sudo dnf install -y @virtualization cloud-utils guestfs-tools ansible-core openssl iputils
;;
arch|cachyos)
sudo pacman -S --needed --noconfirm qemu-full libvirt virt-install cloud-image-utils guestfs-tools ansible-core openssl iputils dnsmasq
;;
*)
fail "automatic dependency installation does not support ${ID:-this host}; missing: ${missing[*]}"
;;
esac
}
ensure_host() {
local uplink
install_dependencies
[[ $(uname -m) == x86_64 ]] || fail "the bundled Fedora image workflow currently supports x86_64 hosts"
[[ -e /dev/kvm ]] || fail "/dev/kvm is unavailable; enable CPU virtualization in firmware"
sudo systemctl enable --now libvirtd
if ! sudo virsh net-info default >/dev/null 2>&1; then
fail "libvirt's default network is missing"
fi
sudo virsh net-start default >/dev/null 2>&1 || true
sudo virsh net-autostart default >/dev/null
if command -v ufw >/dev/null 2>&1 && sudo ufw status | grep -q '^Status: active'; then
uplink=$(ip route show default | awk '/default/ {print $5; exit}')
[[ -n "$uplink" ]] || fail "could not determine the host's default network interface"
sudo ufw allow in on virbr0 to any port 53 proto udp >/dev/null
sudo ufw allow in on virbr0 to any port 53 proto tcp >/dev/null
sudo ufw allow in on virbr0 to any port 67 proto udp >/dev/null
sudo ufw route allow in on virbr0 out on virbr0 from 192.168.122.0/24 to 192.168.122.0/24 >/dev/null
sudo ufw route allow in on virbr0 out on "$uplink" from 192.168.122.0/24 >/dev/null
fi
mkdir -p "$state_root"
chmod 0700 "$state_root"
if [[ ! -f "$key" ]]; then
ssh-keygen -q -t ed25519 -N '' -f "$key"
fi
touch "$known_hosts"
chmod 0600 "$key" "$known_hosts"
if [[ ! -f "$token_file" ]]; then
umask 077
openssl rand -hex 32 >"$token_file"
fi
chmod 0600 "$token_file"
sudo install -d -m 0755 "$image_root"
}
image_url() {
local arch=x86_64 listing filename
listing="https://download.fedoraproject.org/pub/fedora/linux/releases/${fedora_release}/Cloud/${arch}/images/"
filename=$(curl -fsSL "$listing" | sed -nE "s/.*href=\"(Fedora-Cloud-Base-Generic-${fedora_release}-[0-9.]+\.${arch}\.qcow2)\".*/\1/p" | sort -V | tail -n1)
[[ -n "$filename" ]] || fail "could not discover the Fedora ${fedora_release} cloud image"
printf '%s%s\n' "$listing" "$filename"
}
ensure_base_image() {
local cached="$state_root/fedora-${fedora_release}-cloud-base.qcow2" source=${FEDORA_IMAGE:-}
if [[ ! -s "$cached" ]]; then
if [[ -n "$source" ]]; then
[[ -f "$source" ]] || fail "FEDORA_IMAGE does not exist: $source"
[[ $(basename "$source") == Fedora-Cloud-Base-Generic-* ]] || fail "FEDORA_IMAGE must be a Fedora Cloud Base Generic QCOW2 image"
qemu-img check -q "$source" || fail "FEDORA_IMAGE is not a valid QCOW2 image"
log "using local Fedora ${fedora_release} image: $source"
install -m 0644 "$source" "$cached"
else
log "downloading Fedora ${fedora_release} cloud image"
curl -fL --retry 3 --continue-at - -o "$cached.partial" "$(image_url)"
if ! qemu-img check -q "$cached.partial"; then
rm -f "$cached.partial"
fail "downloaded Fedora image is not a valid QCOW2 image"
fi
mv "$cached.partial" "$cached"
fi
fi
sudo install -m 0644 "$cached" "$image_root/fedora-${fedora_release}-cloud-base.qcow2"
}
node_mac() {
case $1 in
nereus-node1) printf '52:54:00:6e:01:01\n' ;;
nereus-node2) printf '52:54:00:6e:01:02\n' ;;
*) fail "no MAC address assigned for $1" ;;
esac
}
create_seed() {
local node=$1 seed="$state_root/${node}-seed.iso" user_data="$state_root/${node}-user-data"
{
printf '#cloud-config\nhostname: %s\nmanage_etc_hosts: true\nusers:\n' "$node"
printf ' - name: fedora\n groups: [wheel]\n sudo: ALL=(ALL) NOPASSWD:ALL\n shell: /bin/bash\n ssh_authorized_keys:\n'
printf ' - %s\n' "$(<"$key.pub")"
printf 'ssh_pwauth: false\npackages: [qemu-guest-agent]\nruncmd:\n - [systemctl, enable, --now, qemu-guest-agent]\n'
} >"$user_data"
chmod 0600 "$user_data"
cloud-localds --dsmode local --hostname "$node" "$seed" "$user_data"
sudo install -m 0644 "$seed" "$image_root/${node}-seed.iso"
}
install_network_service() {
local node=$1 disk=$2
sudo virt-customize -q -a "$disk" \
--copy-in "$script_dir/nereus-network.service:/etc/systemd/system" \
--copy-in "$script_dir/${node}.nmconnection:/etc/NetworkManager/system-connections" \
--chmod "0600:/etc/NetworkManager/system-connections/${node}.nmconnection" \
--run-command "chown root:root /etc/NetworkManager/system-connections/${node}.nmconnection" \
--run-command 'rm -f /etc/NetworkManager/system-connections/cloud-init-ens2.nmconnection' \
--run-command 'systemctl enable nereus-network.service'
}
create_vm() {
local node=$1 disk="$image_root/${node}.qcow2" mac
mac=$(node_mac "$node")
if sudo virsh dominfo "$node" >/dev/null 2>&1; then
if [[ $(sudo virsh domstate "$node") == "shut off" ]]; then
install_network_service "$node" "$disk"
fi
sudo virsh start "$node" >/dev/null 2>&1 || true
return
fi
log "creating $node"
created_nodes+=("$node")
ssh-keygen -q -f "$known_hosts" -R "$(node_ip "$node")" >/dev/null 2>&1 || true
create_seed "$node"
sudo qemu-img create -q -f qcow2 -F qcow2 -b "$image_root/fedora-${fedora_release}-cloud-base.qcow2" "$disk" 30G
install_network_service "$node" "$disk"
sudo virt-install --name "$node" --memory 3072 --vcpus 2 --import \
--disk "path=$disk,format=qcow2,bus=virtio" \
--disk "path=$image_root/${node}-seed.iso,format=raw,bus=virtio,readonly=on" \
--network "network=default,model=virtio,mac=$mac" --graphics none --noautoconsole \
--boot uefi --osinfo detect=on,require=off
}
node_ip() {
case $1 in
nereus-node1) printf '192.168.122.10\n' ;;
nereus-node2) printf '192.168.122.11\n' ;;
*) fail "no address assigned for $1" ;;
esac
}
wait_ssh() {
local ip=$1
for _ in $(seq 1 90); do
if ssh -i "$key" -o BatchMode=yes -o ConnectTimeout=2 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$known_hosts" "fedora@$ip" true 2>/dev/null; then return; fi
sleep 2
done
fail "SSH did not become ready at $ip"
}
wait_ping() {
local node=$1 ip=$2
for _ in $(seq 1 60); do
ping -c 1 -W 1 "$ip" >/dev/null 2>&1 && return
sleep 2
done
fail "$node does not answer ping at $ip"
}
write_inventory() {
local ip1=$1 ip2=$2
umask 077
printf 'all:\n vars:\n ansible_user: fedora\n ansible_ssh_private_key_file: %s\n ansible_ssh_common_args: "-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=%s"\n k3s_version: v1.33.4+k3s1\n k3s_cluster_cidr: 10.42.0.0/16\n k3s_service_cidr: 10.43.0.0/16\n k3s_node_cidr: 192.168.122.0/24\n k3s_operator_cidrs:\n - 192.168.122.1/32\n children:\n k3s_server:\n hosts:\n node1:\n ansible_host: %s\n k3s_node_ip: %s\n k3s_agent:\n hosts:\n node2:\n ansible_host: %s\n k3s_node_ip: %s\n' "$key" "$known_hosts" "$ip1" "$ip1" "$ip2" "$ip2" >"$inventory"
}
check_lab() {
[[ -f "$inventory" ]] || fail "run '$0 up' first"
log "checking reachability, services, ports, packages, and directories"
ansible all -i "$inventory" -m ansible.builtin.ping
ansible all -i "$inventory" -b -m ansible.builtin.shell -a 'for i in $(seq 1 60); do test -d /var/lib/rancher/k3s && command -v k3s >/dev/null && systemctl is-active --quiet firewalld && ss -lnt | grep -q ":10250 " && exit 0; sleep 2; done; exit 1'
ansible k3s_server -i "$inventory" -b -m ansible.builtin.shell -a 'for i in $(seq 1 60); do systemctl is-active --quiet k3s && ss -lnt | grep -q ":6443 " && exit 0; sleep 2; done; exit 1'
ansible k3s_agent -i "$inventory" -b -m ansible.builtin.shell -a 'for i in $(seq 1 60); do systemctl is-active --quiet k3s-agent && exit 0; sleep 2; done; exit 1'
ansible k3s_server -i "$inventory" -b -m ansible.builtin.command -a 'k3s kubectl get nodes -o wide'
ansible k3s_server -i "$inventory" -b -m ansible.builtin.command -a 'k3s kubectl wait --for=condition=Ready nodes --all --timeout=60s'
log "all checks passed"
}
case "$action" in
up)
ensure_host
ensure_base_image
for node in "${nodes[@]}"; do create_vm "$node"; done
ip1=$(node_ip "${nodes[0]}"); ip2=$(node_ip "${nodes[1]}")
log "node1=$ip1 node2=$ip2"
wait_ping node1 "$ip1"
wait_ping node2 "$ip2"
wait_ssh "$ip1"; wait_ssh "$ip2"
for node in "${created_nodes[@]}"; do
if [[ "$node" != "${nodes[0]}" ]]; then
ssh -i "$key" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$known_hosts" \
"fedora@$ip1" "sudo -n k3s kubectl delete node '$node' --ignore-not-found=true"
fi
done
write_inventory "$ip1" "$ip2"
K3S_TOKEN=$(<"$token_file") "$script_dir/bootstrap.sh" "$inventory"
check_lab
;;
check) check_lab ;;
stop) for node in "${nodes[@]}"; do sudo virsh shutdown "$node" >/dev/null 2>&1 || true; done ;;
destroy)
for node in "${nodes[@]}"; do sudo virsh destroy "$node" >/dev/null 2>&1 || true; sudo virsh undefine "$node" --remove-all-storage; done
;;
*) fail "usage: $0 [up|check|stop|destroy]" ;;
esac

View file

@ -0,0 +1,24 @@
# Manual Fedora 44 checklist
Use the values in `inventory.yml` as the source of truth. The manual result must
match the Ansible result: one server named `node1`, one agent named `node2`, the
same pinned k3s version, and both nodes reporting Ready.
1. Install Fedora Server 44 on both hosts, apply system updates, assign stable
addresses, and confirm SSH access with sudo privileges.
2. Install `curl`, `firewalld`, and `policycoreutils-python-utils` on both hosts.
3. Enable firewalld. Trust the configured pod and service CIDRs, then allow
`8472/udp` and `10250/tcp` between the two nodes. Allow `6443/tcp` to node 1
from node 2 and authorized operator networks.
4. Generate a cluster token in a secret manager. Never place it in shell
history, an inventory file, or this repository.
5. Download the official installer from `https://get.k3s.io`. On node 1, install
the pinned version in server mode with its stable node IP.
6. On node 2, run the same pinned installer in agent mode with node 1's
`https://ADDRESS:6443` URL and the runtime cluster token.
7. Enable and start `k3s.service` on node 1 and `k3s-agent.service` on node 2.
8. On node 1, run `k3s kubectl get nodes -o wide` and verify both nodes are
Ready before installing platform components.
Prefer the playbook for the actual command details. This checklist deliberately
does not encourage copying a cluster token into an interactive command line.

View file

@ -0,0 +1,14 @@
[Unit]
Description=Activate the Nereus NetworkManager profile
After=NetworkManager.service cloud-init-local.service
Requires=NetworkManager.service
Before=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/nmcli connection reload
ExecStart=/usr/bin/nmcli connection up nereus-static
RemainAfterExit=yes
[Install]
WantedBy=network-online.target

View file

@ -0,0 +1,22 @@
[connection]
id=nereus-static
uuid=9b589a0e-fca8-4a73-82eb-e42601dbfa01
type=ethernet
interface-name=ens2
autoconnect=true
autoconnect-priority=999
[ethernet]
mac-address=52:54:00:6E:01:01
[ipv4]
address1=192.168.122.10/24
dns=192.168.122.1;
gateway=192.168.122.1
method=manual
[ipv6]
addr-gen-mode=default
method=disabled
[proxy]

View file

@ -0,0 +1,22 @@
[connection]
id=nereus-static
uuid=9b589a0e-fca8-4a73-82eb-e42601dbfa02
type=ethernet
interface-name=ens2
autoconnect=true
autoconnect-priority=999
[ethernet]
mac-address=52:54:00:6E:01:02
[ipv4]
address1=192.168.122.11/24
dns=192.168.122.1;
gateway=192.168.122.1
method=manual
[ipv6]
addr-gen-mode=default
method=disabled
[proxy]

View file

@ -0,0 +1,3 @@
---
collections:
- name: ansible.posix

View file

@ -0,0 +1,68 @@
---
- name: Require a node address
ansible.builtin.assert:
that:
- k3s_node_ip is defined
- k3s_node_ip | string | length > 0
- k3s_node_cidr is defined
- k3s_node_cidr | string | length > 0
- k3s_operator_cidrs is defined
- k3s_operator_cidrs | length > 0
- name: Install host dependencies
ansible.builtin.dnf:
name:
- curl
- firewalld
- policycoreutils-python-utils
state: present
- name: Enable firewalld
ansible.builtin.systemd_service:
name: firewalld
enabled: true
state: started
- name: Trust the k3s pod network
ansible.posix.firewalld:
source: "{{ k3s_cluster_cidr }}"
zone: trusted
permanent: true
immediate: true
state: enabled
- name: Trust the k3s service network
ansible.posix.firewalld:
source: "{{ k3s_service_cidr }}"
zone: trusted
permanent: true
immediate: true
state: enabled
- name: Remove globally open node-to-node k3s ports
ansible.posix.firewalld:
port: "{{ item }}"
permanent: true
immediate: true
state: disabled
loop:
- 8472/udp
- 10250/tcp
- name: Allow node-to-node k3s ports from the node network
ansible.posix.firewalld:
rich_rule: 'rule family="ipv4" source address="{{ k3s_node_cidr }}" port port="{{ item.port }}" protocol="{{ item.protocol }}" accept'
permanent: true
immediate: true
state: enabled
loop:
- {port: "8472", protocol: udp}
- {port: "10250", protocol: tcp}
- name: Download the official k3s installer
ansible.builtin.get_url:
url: https://get.k3s.io
dest: /var/tmp/k3s-install.sh
mode: "0755"
owner: root
group: root

View file

@ -0,0 +1,22 @@
---
- name: Require the runtime cluster token
ansible.builtin.assert:
that: lookup('ansible.builtin.env', 'K3S_TOKEN') | length >= 32
fail_msg: "K3S_TOKEN must contain at least 32 characters."
no_log: true
- name: Install or reconcile the k3s agent
ansible.builtin.command: /var/tmp/k3s-install.sh
environment:
INSTALL_K3S_VERSION: "{{ k3s_version }}"
INSTALL_K3S_EXEC: "agent --node-ip {{ k3s_node_ip }}"
K3S_URL: "https://{{ hostvars[groups['k3s_server'][0]].k3s_node_ip }}:6443"
K3S_TOKEN: "{{ lookup('ansible.builtin.env', 'K3S_TOKEN') }}"
changed_when: false
no_log: true
- name: Enable the k3s agent
ansible.builtin.systemd_service:
name: k3s-agent
enabled: true
state: started

View file

@ -0,0 +1,36 @@
---
- name: Require the runtime cluster token
ansible.builtin.assert:
that: lookup('ansible.builtin.env', 'K3S_TOKEN') | length >= 32
fail_msg: "K3S_TOKEN must contain at least 32 characters."
no_log: true
- name: Remove the globally open k3s API port
ansible.posix.firewalld:
port: 6443/tcp
permanent: true
immediate: true
state: disabled
- name: Allow the k3s API from node and operator networks
ansible.posix.firewalld:
rich_rule: 'rule family="ipv4" source address="{{ item }}" port port="6443" protocol="tcp" accept'
permanent: true
immediate: true
state: enabled
loop: "{{ [k3s_node_cidr] + k3s_operator_cidrs }}"
- name: Install or reconcile the k3s server
ansible.builtin.command: /var/tmp/k3s-install.sh
environment:
INSTALL_K3S_VERSION: "{{ k3s_version }}"
INSTALL_K3S_EXEC: "server --node-ip {{ k3s_node_ip }} --write-kubeconfig-mode 0640"
K3S_TOKEN: "{{ lookup('ansible.builtin.env', 'K3S_TOKEN') }}"
changed_when: false
no_log: true
- name: Enable the k3s server
ansible.builtin.systemd_service:
name: k3s
enabled: true
state: started

View file

@ -0,0 +1,13 @@
---
- name: Wait for both nodes to report Ready
ansible.builtin.command: k3s kubectl wait --for=condition=Ready nodes --all --timeout=180s
changed_when: false
- name: Read the node list
ansible.builtin.command: k3s kubectl get nodes -o wide
register: node_list
changed_when: false
- name: Show the resulting cluster
ansible.builtin.debug:
var: node_list.stdout_lines

View file

@ -0,0 +1,38 @@
---
- name: Prepare Fedora k3s nodes
hosts: all
become: true
gather_facts: true
pre_tasks:
- name: Require Fedora 44
ansible.builtin.assert:
that:
- ansible_distribution == "Fedora"
- ansible_distribution_major_version == "44"
fail_msg: "This playbook supports Fedora 44 only."
- name: Require one server and at least one agent
ansible.builtin.assert:
that:
- groups['k3s_server'] | length == 1
- groups['k3s_agent'] | length >= 1
run_once: true
roles:
- common
- name: Configure the k3s server
hosts: k3s_server
become: true
roles:
- k3s_server
- name: Configure k3s agents
hosts: k3s_agent
become: true
roles:
- k3s_agent
- name: Validate the cluster
hosts: k3s_server
become: true
roles:
- validation