Compare commits
No commits in common. "26529a837b675a6b2b073f43774d10b3ba9c6c31" and "a9a1c201e7f6ed9055ac3c937f3b3d64ca2080ae" have entirely different histories.
26529a837b
...
a9a1c201e7
82 changed files with 0 additions and 5547 deletions
|
|
@ -1,25 +0,0 @@
|
|||
.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.*
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
self-hosted-runner:
|
||||
labels:
|
||||
- docker
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
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
7
.gitignore
vendored
|
|
@ -75,7 +75,6 @@ CLAUDE.local.md
|
|||
|
||||
.direnv/
|
||||
.envrc
|
||||
.ci/
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Terraform. The lock file is committed on purpose; state never is.
|
||||
|
|
@ -95,17 +94,11 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
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
109
apps/api/go.sum
|
|
@ -1,109 +0,0 @@
|
|||
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
341
apps/api/http.go
|
|
@ -1,341 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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
165
apps/api/main.go
|
|
@ -1,165 +0,0 @@
|
|||
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):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
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"`
|
||||
}
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
module git.fiwlabs.dev/fiwdev/nereus/apps/loadgen
|
||||
|
||||
go 1.26
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
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
242
apps/web/app.js
|
|
@ -1,242 +0,0 @@
|
|||
(() => {
|
||||
"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
|
|
@ -1,101 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
: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
26
apps/web/vendor/LEAFLET-LICENSE
vendored
|
|
@ -1,26 +0,0 @@
|
|||
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
661
apps/web/vendor/leaflet.css
vendored
|
|
@ -1,661 +0,0 @@
|
|||
/* 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
6
apps/web/vendor/leaflet.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -1,19 +0,0 @@
|
|||
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"]
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
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"]
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: 0.0.0.0:4317
|
||||
|
||||
exporters:
|
||||
debug:
|
||||
verbosity: basic
|
||||
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
exporters: [debug]
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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
66
compose.yaml
|
|
@ -1,66 +0,0 @@
|
|||
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:
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# 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"
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: nereus
|
||||
labels:
|
||||
app.kubernetes.io/name: nereus
|
||||
app.kubernetes.io/part-of: nereus
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
# 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"
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
# 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]))
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
# 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
|
||||
```
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
# 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
242
docs/roadmap.md
|
|
@ -1,242 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
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:
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: Nereus
|
||||
folder: Nereus
|
||||
type: file
|
||||
disableDeletion: true
|
||||
editable: false
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
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]
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
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]
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
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]
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
# 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)"
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
# 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}
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
[defaults]
|
||||
inventory = inventory.yml
|
||||
interpreter_python = auto_silent
|
||||
retry_files_enabled = False
|
||||
stdout_callback = default
|
||||
|
||||
[ssh_connection]
|
||||
pipelining = True
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
#!/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
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
[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
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
[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]
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
[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]
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
---
|
||||
collections:
|
||||
- name: ansible.posix
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
---
|
||||
- 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
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
---
|
||||
- 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
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
---
|
||||
- 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
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
---
|
||||
- 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
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
---
|
||||
- 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
|
||||
Loading…
Add table
Reference in a new issue