Compare commits
13 commits
a9a1c201e7
...
26529a837b
| Author | SHA1 | Date | |
|---|---|---|---|
| 26529a837b | |||
| 99fde46ed3 | |||
| 3fa278d447 | |||
| 0d8d8c43be | |||
| 3547d0bf3a | |||
| fd7608227d | |||
| 0dd6f003f3 | |||
| a622739053 | |||
| 10c8e2c884 | |||
| 3355f8b7ef | |||
| e1f728ff9b | |||
| 6c1329c2a9 | |||
| af050c9751 |
82 changed files with 5547 additions and 0 deletions
25
.dockerignore
Normal file
25
.dockerignore
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.forgejo
|
||||||
|
.github
|
||||||
|
.codex
|
||||||
|
.claude
|
||||||
|
.cursor
|
||||||
|
.ci
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
AGENTS.md
|
||||||
|
PLAN.md
|
||||||
|
docs
|
||||||
|
terraform
|
||||||
|
deploy
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
**/*_test.go
|
||||||
|
**/testdata
|
||||||
|
**/fixtures
|
||||||
|
bin
|
||||||
|
dist
|
||||||
|
apps/api/api
|
||||||
|
apps/loadgen/loadgen
|
||||||
|
coverage.*
|
||||||
3
.forgejo/actionlint.yaml
Normal file
3
.forgejo/actionlint.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
self-hosted-runner:
|
||||||
|
labels:
|
||||||
|
- docker
|
||||||
181
.forgejo/workflows/pipeline.yml
Normal file
181
.forgejo/workflows/pipeline.yml
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
name: CI and deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||||
|
|
||||||
|
- name: Scan secrets
|
||||||
|
run: |
|
||||||
|
docker run --rm -v "$PWD:/repo" \
|
||||||
|
ghcr.io/gitleaks/gitleaks:v8.30.1 \
|
||||||
|
detect --source /repo --redact -v
|
||||||
|
|
||||||
|
- name: Test API
|
||||||
|
run: |
|
||||||
|
docker run --rm -v "$PWD/apps/api:/src" -w /src golang:1.26 \
|
||||||
|
bash -ec 'go vet ./... && go test -race ./...'
|
||||||
|
|
||||||
|
- name: Test load generator
|
||||||
|
run: |
|
||||||
|
docker run --rm -v "$PWD/apps/loadgen:/src" -w /src golang:1.26 \
|
||||||
|
bash -ec 'go vet ./... && go test -race ./... && test "$(wc -l < main.go)" -lt 200'
|
||||||
|
|
||||||
|
- name: Lint Go
|
||||||
|
run: |
|
||||||
|
docker run --rm -v "$PWD:/repo" -w /repo/apps/api \
|
||||||
|
golangci/golangci-lint:v2.12.2 golangci-lint run
|
||||||
|
docker run --rm -v "$PWD:/repo" -w /repo/apps/loadgen \
|
||||||
|
golangci/golangci-lint:v2.12.2 golangci-lint run
|
||||||
|
|
||||||
|
- name: Check reachable Go vulnerabilities
|
||||||
|
run: |
|
||||||
|
docker run --rm -v "$PWD/apps/api:/src" -w /src golang:1.26 \
|
||||||
|
bash -ec 'go run golang.org/x/vuln/cmd/govulncheck@v1.7.0 ./...'
|
||||||
|
docker run --rm -v "$PWD/apps/loadgen:/src" -w /src golang:1.26 \
|
||||||
|
bash -ec 'go run golang.org/x/vuln/cmd/govulncheck@v1.7.0 ./...'
|
||||||
|
|
||||||
|
- name: Check frontend and shell syntax
|
||||||
|
run: |
|
||||||
|
docker run --rm -v "$PWD:/repo" -w /repo node:24-alpine \
|
||||||
|
node --check apps/web/app.js
|
||||||
|
docker run --rm -v "$PWD:/repo" -w /repo bash:5.3 \
|
||||||
|
bash -ec 'for file in scripts/k3d/lab.sh scripts/provision/bootstrap.sh scripts/provision/lab.sh; do bash -n "$file"; done'
|
||||||
|
|
||||||
|
- name: Validate Compose and Kubernetes configuration
|
||||||
|
env:
|
||||||
|
CLUSTER_API_UPSTREAM: 192.0.2.1:8080
|
||||||
|
NEREUS_HOST: nereus.example.test
|
||||||
|
run: |
|
||||||
|
docker compose -f compose.yaml config -q
|
||||||
|
docker compose -f build/compose.mini.yaml config -q
|
||||||
|
for path in \
|
||||||
|
deploy/base \
|
||||||
|
deploy/overlays/dev \
|
||||||
|
deploy/overlays/prod \
|
||||||
|
deploy/rollouts \
|
||||||
|
deploy/observability
|
||||||
|
do
|
||||||
|
docker run --rm -v "$PWD:/work" -w /work \
|
||||||
|
registry.k8s.io/kubectl:v1.33.4 kustomize "$path" >/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Scan configuration
|
||||||
|
run: |
|
||||||
|
docker run --rm -v "$PWD:/repo" -w /repo aquasec/trivy:0.74.0 \
|
||||||
|
config --severity HIGH,CRITICAL --exit-code 1 \
|
||||||
|
--skip-dirs .git --skip-dirs terraform .
|
||||||
|
|
||||||
|
publish:
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
needs: verify
|
||||||
|
runs-on: docker
|
||||||
|
env:
|
||||||
|
API_IMAGE: git.fiwlabs.dev/fiwdev/nereus-api:${{ github.sha }}
|
||||||
|
LOADGEN_IMAGE: git.fiwlabs.dev/fiwdev/nereus-loadgen:${{ github.sha }}
|
||||||
|
WEB_IMAGE: git.fiwlabs.dev/fiwdev/nereus-web:${{ github.sha }}
|
||||||
|
DOCKER_CONFIG: ${{ github.workspace }}/.ci/docker
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
env:
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
install -d -m 0700 "$DOCKER_CONFIG"
|
||||||
|
printf '%s' "$REGISTRY_PASSWORD" | \
|
||||||
|
docker login git.fiwlabs.dev --username "$REGISTRY_USERNAME" --password-stdin
|
||||||
|
|
||||||
|
- name: Build and push images
|
||||||
|
run: |
|
||||||
|
docker build -f build/api.Dockerfile \
|
||||||
|
--build-arg "APP_VERSION=${{ github.sha }}" \
|
||||||
|
-t "$API_IMAGE" .
|
||||||
|
docker build -f build/loadgen.Dockerfile -t "$LOADGEN_IMAGE" .
|
||||||
|
docker build -f build/web.Dockerfile -t "$WEB_IMAGE" .
|
||||||
|
|
||||||
|
for image in "$API_IMAGE" "$LOADGEN_IMAGE" "$WEB_IMAGE"
|
||||||
|
do
|
||||||
|
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
aquasec/trivy:0.74.0 image --severity HIGH,CRITICAL \
|
||||||
|
--ignore-unfixed --exit-code 1 "$image"
|
||||||
|
done
|
||||||
|
|
||||||
|
docker push "$API_IMAGE"
|
||||||
|
docker push "$LOADGEN_IMAGE"
|
||||||
|
docker push "$WEB_IMAGE"
|
||||||
|
|
||||||
|
- name: Remove registry credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf .ci
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
needs: publish
|
||||||
|
runs-on: docker
|
||||||
|
env:
|
||||||
|
API_IMAGE: git.fiwlabs.dev/fiwdev/nereus-api:${{ github.sha }}
|
||||||
|
LOADGEN_IMAGE: git.fiwlabs.dev/fiwdev/nereus-loadgen:${{ github.sha }}
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||||
|
|
||||||
|
- name: Prepare cluster access
|
||||||
|
env:
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
|
||||||
|
run: |
|
||||||
|
install -d -m 0700 .ci
|
||||||
|
printf '%s' "$KUBECONFIG_B64" | base64 -d >.ci/kubeconfig
|
||||||
|
chmod 0600 .ci/kubeconfig
|
||||||
|
printf '%s' "$REGISTRY_PASSWORD" | \
|
||||||
|
docker --config "$PWD/.ci/docker" login git.fiwlabs.dev \
|
||||||
|
--username "$REGISTRY_USERNAME" --password-stdin
|
||||||
|
|
||||||
|
- name: Apply and verify rollout
|
||||||
|
run: |
|
||||||
|
kube() {
|
||||||
|
docker run --rm --network host \
|
||||||
|
-v "$PWD:/work" -w /work \
|
||||||
|
registry.k8s.io/kubectl:v1.33.4 \
|
||||||
|
--kubeconfig=/work/.ci/kubeconfig "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
kube create namespace nereus --dry-run=client -o yaml | kube apply -f -
|
||||||
|
kube create secret generic nereus-registry \
|
||||||
|
--namespace nereus \
|
||||||
|
--type kubernetes.io/dockerconfigjson \
|
||||||
|
--from-file=.dockerconfigjson=/work/.ci/docker/config.json \
|
||||||
|
--dry-run=client -o yaml | kube apply -f -
|
||||||
|
|
||||||
|
kube get secret nereus-db --namespace nereus >/dev/null
|
||||||
|
kube apply -k deploy/observability
|
||||||
|
kube apply -k deploy/overlays/prod
|
||||||
|
kube patch rollout.argoproj.io nereus-api --namespace nereus --type merge \
|
||||||
|
--patch "{\"spec\":{\"template\":{\"spec\":{\"containers\":[{\"name\":\"api\",\"image\":\"$API_IMAGE\"}]}}}}"
|
||||||
|
kube set image deployment/nereus-loadgen --namespace nereus \
|
||||||
|
"loadgen=$LOADGEN_IMAGE"
|
||||||
|
kube set image deployment/nereus-loadgen-preview --namespace nereus \
|
||||||
|
"loadgen=$LOADGEN_IMAGE"
|
||||||
|
|
||||||
|
kube rollout status deployment/nereus-loadgen --namespace nereus --timeout=5m
|
||||||
|
kube rollout status deployment/nereus-loadgen-preview --namespace nereus --timeout=5m
|
||||||
|
kube wait rollout.argoproj.io/nereus-api --namespace nereus \
|
||||||
|
--for=jsonpath='{.status.phase}'=Healthy --timeout=10m
|
||||||
|
kube get rollout.argoproj.io/nereus-api --namespace nereus
|
||||||
|
|
||||||
|
- name: Remove cluster credentials
|
||||||
|
if: always()
|
||||||
|
run: rm -rf .ci
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -75,6 +75,7 @@ CLAUDE.local.md
|
||||||
|
|
||||||
.direnv/
|
.direnv/
|
||||||
.envrc
|
.envrc
|
||||||
|
.ci/
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Terraform. The lock file is committed on purpose; state never is.
|
# Terraform. The lock file is committed on purpose; state never is.
|
||||||
|
|
@ -94,11 +95,17 @@ override.tf.json
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
bin/
|
bin/
|
||||||
dist/
|
dist/
|
||||||
|
apps/api/api
|
||||||
|
apps/loadgen/loadgen
|
||||||
node_modules/
|
node_modules/
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
coverage.*
|
coverage.*
|
||||||
vendor/
|
vendor/
|
||||||
|
!apps/web/vendor/
|
||||||
|
!apps/web/vendor/leaflet.css
|
||||||
|
!apps/web/vendor/leaflet.js
|
||||||
|
!apps/web/vendor/LEAFLET-LICENSE
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# OS noise
|
# OS noise
|
||||||
|
|
|
||||||
11
apps/api/context.go
Normal file
11
apps/api/context.go
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func contextWithTimeout(r *http.Request, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(r.Context(), timeout)
|
||||||
|
}
|
||||||
45
apps/api/go.mod
Normal file
45
apps/api/go.mod
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
module git.fiwlabs.dev/fiwdev/nereus/apps/api
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
|
github.com/prometheus/client_golang v1.23.2
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
|
||||||
|
go.opentelemetry.io/otel v1.43.0
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
|
||||||
|
go.opentelemetry.io/otel/sdk v1.43.0
|
||||||
|
go.opentelemetry.io/otel/trace v1.43.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
|
github.com/prometheus/procfs v0.16.1 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||||
|
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
|
golang.org/x/net v0.56.0 // indirect
|
||||||
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
|
golang.org/x/text v0.39.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||||
|
google.golang.org/grpc v1.82.1 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
)
|
||||||
109
apps/api/go.sum
Normal file
109
apps/api/go.sum
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||||
|
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
|
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||||
|
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||||
|
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||||
|
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
||||||
|
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||||
|
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
|
||||||
|
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||||
|
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||||
|
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||||
|
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||||
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||||
|
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||||
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
|
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||||
|
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
341
apps/api/http.go
Normal file
341
apps/api/http.go
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
mathrand "math/rand/v2"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
type api struct {
|
||||||
|
store store
|
||||||
|
metrics *metrics
|
||||||
|
logger *slog.Logger
|
||||||
|
version string
|
||||||
|
chaosRate float64
|
||||||
|
migrated *atomic.Bool
|
||||||
|
prometheus prometheus.Gatherer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) routes() http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(a.versionHeader)
|
||||||
|
r.Use(a.observeRequest)
|
||||||
|
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
})
|
||||||
|
r.Get("/readyz", a.ready)
|
||||||
|
r.Handle("/metrics", promhttp.HandlerFor(a.prometheus, promhttp.HandlerOpts{}))
|
||||||
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
|
r.Use(a.chaos)
|
||||||
|
r.Get("/buoys", a.listBuoys)
|
||||||
|
r.Post("/buoys", a.createBuoy)
|
||||||
|
r.Get("/buoys/{id}", a.getBuoy)
|
||||||
|
r.Delete("/buoys/{id}", a.deleteBuoy)
|
||||||
|
r.Get("/readings", a.listReadings)
|
||||||
|
r.Post("/readings", a.createReading)
|
||||||
|
r.Get("/readings/aggregate", a.aggregateReadings)
|
||||||
|
})
|
||||||
|
return otelhttp.NewHandler(r, "http.request")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) versionHeader(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Nereus-Version", a.version)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type responseRecorder struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *responseRecorder) WriteHeader(status int) {
|
||||||
|
w.status = status
|
||||||
|
w.ResponseWriter.WriteHeader(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) observeRequest(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
started := time.Now()
|
||||||
|
recorder := &responseRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||||
|
next.ServeHTTP(recorder, r)
|
||||||
|
path := chi.RouteContext(r.Context()).RoutePattern()
|
||||||
|
if path == "" {
|
||||||
|
path = "unmatched"
|
||||||
|
}
|
||||||
|
status := strconv.Itoa(recorder.status)
|
||||||
|
a.metrics.requests.WithLabelValues(r.Method, path, status, a.version).Inc()
|
||||||
|
a.metrics.duration.WithLabelValues(r.Method, path, a.version).Observe(time.Since(started).Seconds())
|
||||||
|
span := trace.SpanFromContext(r.Context()).SpanContext()
|
||||||
|
a.logger.InfoContext(r.Context(), "request", "method", r.Method, "path", path, "status", recorder.status, "duration_ms", time.Since(started).Milliseconds(), "trace_id", span.TraceID().String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) chaos(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.chaosRate > 0 && mathrand.Float64() < a.chaosRate {
|
||||||
|
writeError(w, http.StatusInternalServerError, "injected failure")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) ready(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := contextWithTimeout(r, 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if !a.migrated.Load() || a.store.Ping(ctx) != nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "database unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) listBuoys(w http.ResponseWriter, r *http.Request) {
|
||||||
|
items, err := a.store.ListBuoys(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
a.internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []buoy{}
|
||||||
|
}
|
||||||
|
a.metrics.active.Set(float64(len(items)))
|
||||||
|
writeJSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) createBuoy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input buoy
|
||||||
|
if !decodeJSON(w, r, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input.Name = strings.TrimSpace(input.Name)
|
||||||
|
if input.Name == "" || len(input.Name) > 120 || input.Latitude < -90 || input.Latitude > 90 || input.Longitude < -180 || input.Longitude > 180 {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid buoy")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := newUUID()
|
||||||
|
if err != nil {
|
||||||
|
a.internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input.ID = id
|
||||||
|
created, err := a.store.CreateBuoy(r.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
a.internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.metrics.active.Inc()
|
||||||
|
writeJSON(w, http.StatusCreated, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) getBuoy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if !validUUID(id) {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid buoy ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := a.store.GetBuoy(r.Context(), id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeError(w, http.StatusNotFound, "buoy not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
a.internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) deleteBuoy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
if !validUUID(id) {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid buoy ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := a.store.DeleteBuoy(r.Context(), id)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
writeError(w, http.StatusNotFound, "buoy not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
a.internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.metrics.active.Dec()
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) listReadings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := r.URL.Query()
|
||||||
|
from, ok := optionalTime(w, query.Get("from"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
to, ok := optionalTime(w, query.Get("to"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if from != nil && to != nil && from.After(*to) {
|
||||||
|
writeError(w, http.StatusBadRequest, "from must not be after to")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if id := query.Get("buoy_id"); id != "" && !validUUID(id) {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid buoy ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, ok := boundedInt(query.Get("limit"), 100, 1, 1000)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid limit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
offset, ok := boundedInt(query.Get("offset"), 0, 0, math.MaxInt)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid offset")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := a.store.ListReadings(r.Context(), query.Get("buoy_id"), from, to, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
a.internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []reading{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) createReading(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input reading
|
||||||
|
if !decodeJSON(w, r, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validUUID(input.BuoyID) || input.WaveHeight < 0 || input.Salinity != nil && *input.Salinity < 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid reading")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := newUUID()
|
||||||
|
if err != nil {
|
||||||
|
a.internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
input.ID = id
|
||||||
|
created, err := a.store.CreateReading(r.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
a.internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.metrics.ingested.Inc()
|
||||||
|
writeJSON(w, http.StatusCreated, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) aggregateReadings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
window, err := time.ParseDuration(r.URL.Query().Get("window"))
|
||||||
|
if err != nil || window < time.Minute || window > 24*time.Hour {
|
||||||
|
writeError(w, http.StatusBadRequest, "window must be between 1m and 24h")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := a.store.AggregateReadings(r.Context(), window)
|
||||||
|
if err != nil {
|
||||||
|
a.internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []aggregate{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *api) internalError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
a.logger.ErrorContext(r.Context(), "request failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
}
|
||||||
|
func writeError(w http.ResponseWriter, status int, message string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": message})
|
||||||
|
}
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||||
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(dst); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||||
|
writeError(w, http.StatusBadRequest, "request body must contain one JSON object")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalTime(w http.ResponseWriter, value string) (*time.Time, bool) {
|
||||||
|
if value == "" {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
parsed, err := time.Parse(time.RFC3339, value)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid timestamp")
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &parsed, true
|
||||||
|
}
|
||||||
|
func boundedInt(value string, fallback, min, max int) (int, bool) {
|
||||||
|
if value == "" {
|
||||||
|
return fallback, true
|
||||||
|
}
|
||||||
|
parsed, err := strconv.Atoi(value)
|
||||||
|
return parsed, err == nil && parsed >= min && parsed <= max
|
||||||
|
}
|
||||||
|
|
||||||
|
func validUUID(value string) bool {
|
||||||
|
if len(value) != 36 || value[8] != '-' || value[13] != '-' || value[18] != '-' || value[23] != '-' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
compact := strings.ReplaceAll(value, "-", "")
|
||||||
|
_, err := hex.DecodeString(compact)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUUID() (string, error) {
|
||||||
|
var b [16]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "", fmt.Errorf("generate UUID: %w", err)
|
||||||
|
}
|
||||||
|
b[6] = (b[6] & 0x0f) | 0x40
|
||||||
|
b[8] = (b[8] & 0x3f) | 0x80
|
||||||
|
encoded := make([]byte, 36)
|
||||||
|
hex.Encode(encoded[0:8], b[0:4])
|
||||||
|
encoded[8] = '-'
|
||||||
|
hex.Encode(encoded[9:13], b[4:6])
|
||||||
|
encoded[13] = '-'
|
||||||
|
hex.Encode(encoded[14:18], b[6:8])
|
||||||
|
encoded[18] = '-'
|
||||||
|
hex.Encode(encoded[19:23], b[8:10])
|
||||||
|
encoded[23] = '-'
|
||||||
|
hex.Encode(encoded[24:36], b[10:16])
|
||||||
|
return string(encoded), nil
|
||||||
|
}
|
||||||
256
apps/api/http_test.go
Normal file
256
apps/api/http_test.go
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeStore struct{ pingErr error }
|
||||||
|
|
||||||
|
const (
|
||||||
|
testBuoyID = "00000000-0000-4000-8000-000000000001"
|
||||||
|
missingBuoyID = "00000000-0000-4000-8000-000000000002"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (f *fakeStore) Ping(context.Context) error { return f.pingErr }
|
||||||
|
func (f *fakeStore) Migrate(context.Context) error { return nil }
|
||||||
|
func (f *fakeStore) Close() {}
|
||||||
|
func (f *fakeStore) ListBuoys(context.Context) ([]buoy, error) {
|
||||||
|
return []buoy{{ID: testBuoyID, Name: "Atlantic"}}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) CreateBuoy(_ context.Context, b buoy) (buoy, error) {
|
||||||
|
b.CreatedAt = time.Unix(1, 0)
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) GetBuoy(_ context.Context, id string) (buoy, error) {
|
||||||
|
if id == missingBuoyID {
|
||||||
|
return buoy{}, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
return buoy{ID: id}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) DeleteBuoy(_ context.Context, id string) error {
|
||||||
|
if id == missingBuoyID {
|
||||||
|
return pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) ListReadings(context.Context, string, *time.Time, *time.Time, int, int) ([]reading, error) {
|
||||||
|
return []reading{}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) CreateReading(_ context.Context, r reading) (reading, error) {
|
||||||
|
r.RecordedAt = time.Unix(1, 0)
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
func (f *fakeStore) AggregateReadings(context.Context, time.Duration) ([]aggregate, error) {
|
||||||
|
return []aggregate{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingStore struct {
|
||||||
|
fakeStore
|
||||||
|
operation string
|
||||||
|
}
|
||||||
|
|
||||||
|
var errDatabaseUnavailable = errors.New("database unavailable")
|
||||||
|
|
||||||
|
func (f *failingStore) ListBuoys(context.Context) ([]buoy, error) {
|
||||||
|
if f.operation == "list buoys" {
|
||||||
|
return nil, errDatabaseUnavailable
|
||||||
|
}
|
||||||
|
return f.fakeStore.ListBuoys(context.Background())
|
||||||
|
}
|
||||||
|
func (f *failingStore) CreateBuoy(ctx context.Context, b buoy) (buoy, error) {
|
||||||
|
if f.operation == "create buoy" {
|
||||||
|
return buoy{}, errDatabaseUnavailable
|
||||||
|
}
|
||||||
|
return f.fakeStore.CreateBuoy(ctx, b)
|
||||||
|
}
|
||||||
|
func (f *failingStore) GetBuoy(ctx context.Context, id string) (buoy, error) {
|
||||||
|
if f.operation == "get buoy" {
|
||||||
|
return buoy{}, errDatabaseUnavailable
|
||||||
|
}
|
||||||
|
return f.fakeStore.GetBuoy(ctx, id)
|
||||||
|
}
|
||||||
|
func (f *failingStore) DeleteBuoy(ctx context.Context, id string) error {
|
||||||
|
if f.operation == "delete buoy" {
|
||||||
|
return errDatabaseUnavailable
|
||||||
|
}
|
||||||
|
return f.fakeStore.DeleteBuoy(ctx, id)
|
||||||
|
}
|
||||||
|
func (f *failingStore) ListReadings(ctx context.Context, buoyID string, from, to *time.Time, limit, offset int) ([]reading, error) {
|
||||||
|
if f.operation == "list readings" {
|
||||||
|
return nil, errDatabaseUnavailable
|
||||||
|
}
|
||||||
|
return f.fakeStore.ListReadings(ctx, buoyID, from, to, limit, offset)
|
||||||
|
}
|
||||||
|
func (f *failingStore) CreateReading(ctx context.Context, r reading) (reading, error) {
|
||||||
|
if f.operation == "create reading" {
|
||||||
|
return reading{}, errDatabaseUnavailable
|
||||||
|
}
|
||||||
|
return f.fakeStore.CreateReading(ctx, r)
|
||||||
|
}
|
||||||
|
func (f *failingStore) AggregateReadings(ctx context.Context, window time.Duration) ([]aggregate, error) {
|
||||||
|
if f.operation == "aggregate readings" {
|
||||||
|
return nil, errDatabaseUnavailable
|
||||||
|
}
|
||||||
|
return f.fakeStore.AggregateReadings(ctx, window)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAPI(s store, chaos float64) http.Handler {
|
||||||
|
handler, _ := testAPIWithMetrics(s, chaos)
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAPIWithMetrics(s store, chaos float64) (http.Handler, *metrics) {
|
||||||
|
reg := prometheus.NewRegistry()
|
||||||
|
ready := &atomic.Bool{}
|
||||||
|
ready.Store(true)
|
||||||
|
m := newMetrics(reg)
|
||||||
|
a := &api{store: s, metrics: m, logger: slog.New(slog.NewTextHandler(io.Discard, nil)), version: "test", chaosRate: chaos, migrated: ready, prometheus: reg}
|
||||||
|
return a.routes(), m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlers(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, method, path, body string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"health", http.MethodGet, "/healthz", "", http.StatusOK},
|
||||||
|
{"ready", http.MethodGet, "/readyz", "", http.StatusOK},
|
||||||
|
{"metrics", http.MethodGet, "/metrics", "", http.StatusOK},
|
||||||
|
{"list buoys", http.MethodGet, "/api/v1/buoys", "", http.StatusOK},
|
||||||
|
{"create buoy", http.MethodPost, "/api/v1/buoys", `{"name":"North","latitude":42,"longitude":-8}`, http.StatusCreated},
|
||||||
|
{"invalid buoy", http.MethodPost, "/api/v1/buoys", `{"name":"","latitude":42,"longitude":-8}`, http.StatusBadRequest},
|
||||||
|
{"get buoy", http.MethodGet, "/api/v1/buoys/" + testBuoyID, "", http.StatusOK},
|
||||||
|
{"missing buoy", http.MethodGet, "/api/v1/buoys/" + missingBuoyID, "", http.StatusNotFound},
|
||||||
|
{"invalid buoy ID", http.MethodGet, "/api/v1/buoys/not-a-uuid", "", http.StatusBadRequest},
|
||||||
|
{"delete buoy", http.MethodDelete, "/api/v1/buoys/" + testBuoyID, "", http.StatusNoContent},
|
||||||
|
{"list readings", http.MethodGet, "/api/v1/readings?limit=10", "", http.StatusOK},
|
||||||
|
{"bad timestamp", http.MethodGet, "/api/v1/readings?from=yesterday", "", http.StatusBadRequest},
|
||||||
|
{"create reading", http.MethodPost, "/api/v1/readings", `{"buoy_id":"` + testBuoyID + `","water_temp":14,"wave_height":2}`, http.StatusCreated},
|
||||||
|
{"aggregate", http.MethodGet, "/api/v1/readings/aggregate?window=1h", "", http.StatusOK},
|
||||||
|
{"bad aggregate", http.MethodGet, "/api/v1/readings/aggregate?window=bad", "", http.StatusBadRequest},
|
||||||
|
}
|
||||||
|
handler := testAPI(&fakeStore{}, 0)
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(recorder, req)
|
||||||
|
if recorder.Code != tt.want {
|
||||||
|
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, tt.want, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if got := recorder.Header().Get("X-Nereus-Version"); got != "test" {
|
||||||
|
t.Errorf("X-Nereus-Version = %q, want test", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadinessFailure(t *testing.T) {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
testAPI(&fakeStore{pingErr: context.DeadlineExceeded}, 0).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||||
|
if recorder.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChaosMiddleware(t *testing.T) {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
testAPI(&fakeStore{}, 1).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/buoys", nil))
|
||||||
|
if recorder.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
testAPI(&fakeStore{}, 1).ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("health status = %d, want %d", recorder.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDataEndpointsReturnInternalErrorOnDatabaseFailure(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, method, path, body string
|
||||||
|
}{
|
||||||
|
{"list buoys", http.MethodGet, "/api/v1/buoys", ""},
|
||||||
|
{"create buoy", http.MethodPost, "/api/v1/buoys", `{"name":"North","latitude":42,"longitude":-8}`},
|
||||||
|
{"get buoy", http.MethodGet, "/api/v1/buoys/" + testBuoyID, ""},
|
||||||
|
{"delete buoy", http.MethodDelete, "/api/v1/buoys/" + testBuoyID, ""},
|
||||||
|
{"list readings", http.MethodGet, "/api/v1/readings", ""},
|
||||||
|
{"create reading", http.MethodPost, "/api/v1/readings", `{"buoy_id":"` + testBuoyID + `","water_temp":14,"wave_height":2}`},
|
||||||
|
{"aggregate readings", http.MethodGet, "/api/v1/readings/aggregate?window=1h", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
testAPI(&failingStore{operation: tt.name}, 0).ServeHTTP(recorder, httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body)))
|
||||||
|
if recorder.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusInternalServerError, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if recorder.Body.String() != "{\"error\":\"internal error\"}\n" {
|
||||||
|
t.Fatalf("body = %q", recorder.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONBodyValidation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, body string
|
||||||
|
}{
|
||||||
|
{"malformed", `{"name":`},
|
||||||
|
{"multiple values", `{"name":"North","latitude":42,"longitude":-8} {}`},
|
||||||
|
{"oversized", `{"name":"` + strings.Repeat("x", 1<<20) + `","latitude":42,"longitude":-8}`},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
testAPI(&fakeStore{}, 0).ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/buoys", strings.NewReader(tt.body)))
|
||||||
|
if recorder.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetricNamesAndRouteTemplateLabels(t *testing.T) {
|
||||||
|
handler, metrics := testAPIWithMetrics(&fakeStore{}, 0)
|
||||||
|
metrics.db.WithLabelValues("test").Observe(0)
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/buoys/"+testBuoyID, nil))
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||||
|
body := recorder.Body.String()
|
||||||
|
|
||||||
|
for _, name := range []string{
|
||||||
|
"nereus_http_requests_total",
|
||||||
|
"nereus_http_request_duration_seconds",
|
||||||
|
"nereus_db_query_duration_seconds",
|
||||||
|
"nereus_readings_ingested_total",
|
||||||
|
"nereus_buoys_active",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, "# HELP "+name+" ") {
|
||||||
|
t.Errorf("metrics output does not contain %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `nereus_http_requests_total{method="GET",path="/api/v1/buoys/{id}",status="200",version="test"} 1`) {
|
||||||
|
t.Error("request counter does not contain the exact labels and buoy route template")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `nereus_http_request_duration_seconds_count{method="GET",path="/api/v1/buoys/{id}",version="test"} 1`) {
|
||||||
|
t.Error("request histogram does not contain the exact labels and buoy route template")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, `path="/api/v1/buoys/`+testBuoyID+`"`) {
|
||||||
|
t.Error("metrics output contains a resolved resource path")
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apps/api/integration_test.go
Normal file
31
apps/api/integration_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPostgresIntegration(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("integration test disabled in short mode")
|
||||||
|
}
|
||||||
|
dsn, configured := os.LookupEnv("DATABASE_URL")
|
||||||
|
if !configured {
|
||||||
|
t.Skip("DATABASE_URL is not configured")
|
||||||
|
}
|
||||||
|
m := newMetrics(prometheus.NewRegistry())
|
||||||
|
db, err := newPostgresStore(context.Background(), dsn, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new store: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err := db.Migrate(context.Background()); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Ping(context.Background()); err != nil {
|
||||||
|
t.Fatalf("ping: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
165
apps/api/main.go
Normal file
165
apps/api/main.go
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||||
|
"go.opentelemetry.io/otel/sdk/resource"
|
||||||
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||||
|
semconv "go.opentelemetry.io/otel/semconv/v1.34.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
var appVersion = "dev"
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
port string
|
||||||
|
database string
|
||||||
|
chaosRate float64
|
||||||
|
logLevel slog.Level
|
||||||
|
traceReady bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
slog.Error("startup failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
cfg, err := loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.logLevel}))
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
defer stop()
|
||||||
|
shutdownTrace, err := configureTracing(ctx, cfg.traceReady)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := shutdownTrace(context.Background()); err != nil {
|
||||||
|
logger.Error("trace shutdown failed", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
registry := prometheus.NewRegistry()
|
||||||
|
m := newMetrics(registry)
|
||||||
|
db, err := newPostgresStore(ctx, cfg.database, m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
var migrated atomic.Bool
|
||||||
|
go migrateUntilReady(ctx, db, &migrated, logger)
|
||||||
|
application := &api{store: db, metrics: m, logger: logger, version: appVersion, chaosRate: cfg.chaosRate, migrated: &migrated, prometheus: registry}
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: ":" + cfg.port,
|
||||||
|
Handler: application.routes(),
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
MaxHeaderBytes: 1 << 20,
|
||||||
|
}
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() { errCh <- server.ListenAndServe() }()
|
||||||
|
logger.Info("server started", "port", cfg.port, "version", appVersion)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||||
|
return fmt.Errorf("shutdown server: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case err := <-errCh:
|
||||||
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("serve HTTP: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() (config, error) {
|
||||||
|
cfg := config{port: envOr("PORT", "8080"), database: os.Getenv("DATABASE_URL"), traceReady: os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != ""}
|
||||||
|
if cfg.database == "" {
|
||||||
|
return config{}, errors.New("DATABASE_URL is required")
|
||||||
|
}
|
||||||
|
chaos, err := strconv.ParseFloat(envOr("CHAOS_ERROR_RATE", "0.0"), 64)
|
||||||
|
if err != nil || chaos < 0 || chaos > 1 {
|
||||||
|
return config{}, errors.New("CHAOS_ERROR_RATE must be between 0.0 and 1.0")
|
||||||
|
}
|
||||||
|
cfg.chaosRate = chaos
|
||||||
|
switch envOr("LOG_LEVEL", "info") {
|
||||||
|
case "debug":
|
||||||
|
cfg.logLevel = slog.LevelDebug
|
||||||
|
case "info":
|
||||||
|
cfg.logLevel = slog.LevelInfo
|
||||||
|
case "warn":
|
||||||
|
cfg.logLevel = slog.LevelWarn
|
||||||
|
case "error":
|
||||||
|
cfg.logLevel = slog.LevelError
|
||||||
|
default:
|
||||||
|
return config{}, errors.New("LOG_LEVEL must be debug, info, warn, or error")
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(name, fallback string) string {
|
||||||
|
if value := os.Getenv(name); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureTracing(ctx context.Context, enabled bool) (func(context.Context) error, error) {
|
||||||
|
if !enabled {
|
||||||
|
return func(context.Context) error { return nil }, nil
|
||||||
|
}
|
||||||
|
exporter, err := otlptracegrpc.New(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create trace exporter: %w", err)
|
||||||
|
}
|
||||||
|
res, err := resource.New(ctx, resource.WithAttributes(semconv.ServiceName("nereus-api")))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create trace resource: %w", err)
|
||||||
|
}
|
||||||
|
provider := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter), sdktrace.WithResource(res))
|
||||||
|
otel.SetTracerProvider(provider)
|
||||||
|
return provider.Shutdown, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateUntilReady(ctx context.Context, db store, ready *atomic.Bool, logger *slog.Logger) {
|
||||||
|
for {
|
||||||
|
attempt, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
err := db.Migrate(attempt)
|
||||||
|
cancel()
|
||||||
|
if err == nil {
|
||||||
|
ready.Store(true)
|
||||||
|
logger.Info("database migrations applied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Warn("database unavailable; migration will retry", "error", err)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
apps/api/metrics.go
Normal file
23
apps/api/metrics.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
||||||
|
type metrics struct {
|
||||||
|
requests *prometheus.CounterVec
|
||||||
|
duration *prometheus.HistogramVec
|
||||||
|
db *prometheus.HistogramVec
|
||||||
|
ingested prometheus.Counter
|
||||||
|
active prometheus.Gauge
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMetrics(reg prometheus.Registerer) *metrics {
|
||||||
|
m := &metrics{
|
||||||
|
requests: prometheus.NewCounterVec(prometheus.CounterOpts{Name: "nereus_http_requests_total", Help: "HTTP requests processed."}, []string{"method", "path", "status", "version"}),
|
||||||
|
duration: prometheus.NewHistogramVec(prometheus.HistogramOpts{Name: "nereus_http_request_duration_seconds", Help: "HTTP request duration."}, []string{"method", "path", "version"}),
|
||||||
|
db: prometheus.NewHistogramVec(prometheus.HistogramOpts{Name: "nereus_db_query_duration_seconds", Help: "Database query duration."}, []string{"operation"}),
|
||||||
|
ingested: prometheus.NewCounter(prometheus.CounterOpts{Name: "nereus_readings_ingested_total", Help: "Readings successfully ingested."}),
|
||||||
|
active: prometheus.NewGauge(prometheus.GaugeOpts{Name: "nereus_buoys_active", Help: "Current number of buoys."}),
|
||||||
|
}
|
||||||
|
reg.MustRegister(m.requests, m.duration, m.db, m.ingested, m.active)
|
||||||
|
return m
|
||||||
|
}
|
||||||
25
apps/api/migrations/001_init.sql
Normal file
25
apps/api/migrations/001_init.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Replicas start together during a rollout, so serialize schema creation.
|
||||||
|
SELECT pg_advisory_xact_lock(hashtext('nereus_schema_migration'));
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS buoys (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
latitude DOUBLE PRECISION NOT NULL,
|
||||||
|
longitude DOUBLE PRECISION NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS readings (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
buoy_id UUID NOT NULL REFERENCES buoys(id) ON DELETE CASCADE,
|
||||||
|
water_temp DOUBLE PRECISION NOT NULL,
|
||||||
|
wave_height DOUBLE PRECISION NOT NULL,
|
||||||
|
salinity DOUBLE PRECISION,
|
||||||
|
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_readings_buoy_time ON readings (buoy_id, recorded_at DESC);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
28
apps/api/model.go
Normal file
28
apps/api/model.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type buoy struct {
|
||||||
|
ID string `db:"id" json:"id"`
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
Latitude float64 `db:"latitude" json:"latitude"`
|
||||||
|
Longitude float64 `db:"longitude" json:"longitude"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type reading struct {
|
||||||
|
ID string `db:"id" json:"id"`
|
||||||
|
BuoyID string `db:"buoy_id" json:"buoy_id"`
|
||||||
|
WaterTemp float64 `db:"water_temp" json:"water_temp"`
|
||||||
|
WaveHeight float64 `db:"wave_height" json:"wave_height"`
|
||||||
|
Salinity *float64 `db:"salinity" json:"salinity,omitempty"`
|
||||||
|
RecordedAt time.Time `db:"recorded_at" json:"recorded_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type aggregate struct {
|
||||||
|
Bucket time.Time `db:"bucket" json:"bucket"`
|
||||||
|
AverageTemp float64 `db:"average_water_temp" json:"average_water_temp"`
|
||||||
|
AverageWave float64 `db:"average_wave_height" json:"average_wave_height"`
|
||||||
|
AverageSaline *float64 `db:"average_salinity" json:"average_salinity"`
|
||||||
|
Count int64 `db:"count" json:"count"`
|
||||||
|
}
|
||||||
159
apps/api/store.go
Normal file
159
apps/api/store.go
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/001_init.sql
|
||||||
|
var migrationSQL string
|
||||||
|
|
||||||
|
type store interface {
|
||||||
|
Ping(context.Context) error
|
||||||
|
Migrate(context.Context) error
|
||||||
|
ListBuoys(context.Context) ([]buoy, error)
|
||||||
|
CreateBuoy(context.Context, buoy) (buoy, error)
|
||||||
|
GetBuoy(context.Context, string) (buoy, error)
|
||||||
|
DeleteBuoy(context.Context, string) error
|
||||||
|
ListReadings(context.Context, string, *time.Time, *time.Time, int, int) ([]reading, error)
|
||||||
|
CreateReading(context.Context, reading) (reading, error)
|
||||||
|
AggregateReadings(context.Context, time.Duration) ([]aggregate, error)
|
||||||
|
Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
type postgresStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
metrics *metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPostgresStore(ctx context.Context, dsn string, m *metrics) (*postgresStore, error) {
|
||||||
|
pool, err := pgxpool.New(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create database pool: %w", err)
|
||||||
|
}
|
||||||
|
return &postgresStore{pool: pool, metrics: m}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresStore) observe(ctx context.Context, operation string) (context.Context, func()) {
|
||||||
|
ctx, span := otel.Tracer("nereus-api/database").Start(ctx, operation)
|
||||||
|
started := time.Now()
|
||||||
|
return ctx, func() {
|
||||||
|
s.metrics.db.WithLabelValues(operation).Observe(time.Since(started).Seconds())
|
||||||
|
span.End()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresStore) Ping(ctx context.Context) error { return s.pool.Ping(ctx) }
|
||||||
|
func (s *postgresStore) Close() { s.pool.Close() }
|
||||||
|
|
||||||
|
func (s *postgresStore) Migrate(ctx context.Context) error {
|
||||||
|
ctx, done := s.observe(ctx, "migrate")
|
||||||
|
defer done()
|
||||||
|
if _, err := s.pool.Exec(ctx, migrationSQL); err != nil {
|
||||||
|
return fmt.Errorf("apply migrations: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresStore) ListBuoys(ctx context.Context) ([]buoy, error) {
|
||||||
|
ctx, done := s.observe(ctx, "list_buoys")
|
||||||
|
defer done()
|
||||||
|
rows, err := s.pool.Query(ctx, `SELECT id, name, latitude, longitude, created_at FROM buoys ORDER BY created_at`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query buoys: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items, err := pgx.CollectRows(rows, pgx.RowToStructByName[buoy])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("collect buoys: %w", err)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresStore) CreateBuoy(ctx context.Context, b buoy) (buoy, error) {
|
||||||
|
ctx, done := s.observe(ctx, "create_buoy")
|
||||||
|
defer done()
|
||||||
|
err := s.pool.QueryRow(ctx, `INSERT INTO buoys (id,name,latitude,longitude) VALUES ($1,$2,$3,$4) RETURNING created_at`, b.ID, b.Name, b.Latitude, b.Longitude).Scan(&b.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return buoy{}, fmt.Errorf("insert buoy: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresStore) GetBuoy(ctx context.Context, id string) (buoy, error) {
|
||||||
|
ctx, done := s.observe(ctx, "get_buoy")
|
||||||
|
defer done()
|
||||||
|
var b buoy
|
||||||
|
err := s.pool.QueryRow(ctx, `SELECT id, name, latitude, longitude, created_at FROM buoys WHERE id=$1`, id).Scan(&b.ID, &b.Name, &b.Latitude, &b.Longitude, &b.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return buoy{}, fmt.Errorf("select buoy: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresStore) DeleteBuoy(ctx context.Context, id string) error {
|
||||||
|
ctx, done := s.observe(ctx, "delete_buoy")
|
||||||
|
defer done()
|
||||||
|
tag, err := s.pool.Exec(ctx, `DELETE FROM buoys WHERE id=$1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete buoy: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresStore) ListReadings(ctx context.Context, buoyID string, from, to *time.Time, limit, offset int) ([]reading, error) {
|
||||||
|
ctx, done := s.observe(ctx, "list_readings")
|
||||||
|
defer done()
|
||||||
|
rows, err := s.pool.Query(ctx, `SELECT id, buoy_id, water_temp, wave_height, salinity, recorded_at FROM readings WHERE ($1::text='' OR buoy_id=NULLIF($1,'')::uuid) AND ($2::timestamptz IS NULL OR recorded_at >= $2) AND ($3::timestamptz IS NULL OR recorded_at <= $3) ORDER BY recorded_at DESC LIMIT $4 OFFSET $5`, buoyID, from, to, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query readings: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items, err := pgx.CollectRows(rows, pgx.RowToStructByName[reading])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("collect readings: %w", err)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresStore) CreateReading(ctx context.Context, r reading) (reading, error) {
|
||||||
|
ctx, done := s.observe(ctx, "create_reading")
|
||||||
|
defer done()
|
||||||
|
err := s.pool.QueryRow(ctx, `INSERT INTO readings (id,buoy_id,water_temp,wave_height,salinity,recorded_at) VALUES ($1,$2,$3,$4,$5,COALESCE($6,now())) RETURNING recorded_at`, r.ID, r.BuoyID, r.WaterTemp, r.WaveHeight, r.Salinity, nullableTime(r.RecordedAt)).Scan(&r.RecordedAt)
|
||||||
|
if err != nil {
|
||||||
|
return reading{}, fmt.Errorf("insert reading: %w", err)
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableTime(value time.Time) any {
|
||||||
|
if value.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *postgresStore) AggregateReadings(ctx context.Context, window time.Duration) ([]aggregate, error) {
|
||||||
|
ctx, done := s.observe(ctx, "aggregate_readings")
|
||||||
|
defer done()
|
||||||
|
seconds := int64(window.Seconds())
|
||||||
|
rows, err := s.pool.Query(ctx, `SELECT to_timestamp(floor(extract(epoch FROM recorded_at)/$1)*$1) AS bucket, avg(water_temp) AS average_water_temp, avg(wave_height) AS average_wave_height, avg(salinity) AS average_salinity, count(*) AS count FROM readings GROUP BY bucket ORDER BY bucket DESC`, seconds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("aggregate readings: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items, err := pgx.CollectRows(rows, pgx.RowToStructByName[aggregate])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("collect aggregates: %w", err)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
3
apps/loadgen/go.mod
Normal file
3
apps/loadgen/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module git.fiwlabs.dev/fiwdev/nereus/apps/loadgen
|
||||||
|
|
||||||
|
go 1.26
|
||||||
199
apps/loadgen/main.go
Normal file
199
apps/loadgen/main.go
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"math/rand/v2"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type generator struct {
|
||||||
|
client *http.Client
|
||||||
|
target string
|
||||||
|
buoys []string
|
||||||
|
mu sync.Mutex
|
||||||
|
counts map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
type buoy struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Latitude float64 `json:"latitude,omitempty"`
|
||||||
|
Longitude float64 `json:"longitude,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
slog.Error("load generator stopped", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
target := strings.TrimRight(os.Getenv("TARGET_URL"), "/")
|
||||||
|
if target == "" {
|
||||||
|
return errors.New("TARGET_URL is required")
|
||||||
|
}
|
||||||
|
rps, err := strconv.Atoi(envOr("RPS", "5"))
|
||||||
|
if err != nil || rps < 1 || rps > 100 {
|
||||||
|
return errors.New("RPS must be between 1 and 100")
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
||||||
|
defer stop()
|
||||||
|
g := &generator{client: &http.Client{Timeout: 5 * time.Second}, target: target, counts: make(map[string]int)}
|
||||||
|
if err := g.seed(ctx); err != nil {
|
||||||
|
logger.Warn("initial buoy seed failed; traffic will continue", "error", err)
|
||||||
|
}
|
||||||
|
go g.seedUntilReady(ctx, logger)
|
||||||
|
go g.summarize(ctx, logger)
|
||||||
|
ticker := time.NewTicker(time.Second / time.Duration(rps))
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
go g.send(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *generator) seed(ctx context.Context) error {
|
||||||
|
var existing []buoy
|
||||||
|
if err := g.request(ctx, http.MethodGet, "/api/v1/buoys", nil, &existing); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.mu.Lock()
|
||||||
|
for _, item := range existing {
|
||||||
|
g.buoys = append(g.buoys, item.ID)
|
||||||
|
}
|
||||||
|
ready := len(g.buoys) > 0
|
||||||
|
g.mu.Unlock()
|
||||||
|
if ready {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
positions := [][2]float64{{44.5, -9}, {44.2, -8}, {44.3, -7}, {44.4, -6}, {44.5, -5}, {44.6, -4}, {44.7, -3}, {44.5, -2}, {43.8, -2}, {43.5, -9.8}, {42.5, -10.2}, {41.5, -10.5}, {40.5, -10.8}, {39.5, -11}, {38.5, -11}, {37.5, -10.8}, {36.5, -10}, {35.9, -9}, {35.8, -8}, {35.7, -7}, {35.2, -7.5}, {35, -8.5}, {35.8, -4.5}, {36, -3.5}, {36.2, -2.5}, {36.5, -1.5}, {36.8, -.5}, {37.2, .5}, {37.7, 1.2}, {38.1, 2}, {38, 3}, {38.5, 4}, {39.5, 1}, {40, 1.5}, {40.5, 2}, {41, 2.7}, {41.5, 3.3}, {42, 3.8}, {42.5, 4.5}, {43, -9.8}, {41, -10.5}, {39, -10.8}}
|
||||||
|
for i, position := range positions {
|
||||||
|
input := buoy{Name: fmt.Sprintf("Synthetic %d", i+1), Latitude: position[0], Longitude: position[1]}
|
||||||
|
var created buoy
|
||||||
|
if err := g.request(ctx, http.MethodPost, "/api/v1/buoys", input, &created); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
g.mu.Lock()
|
||||||
|
g.buoys = append(g.buoys, created.ID)
|
||||||
|
g.mu.Unlock()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *generator) send(ctx context.Context) { g.sendRoll(ctx, rand.IntN(100)) }
|
||||||
|
func (g *generator) sendRoll(ctx context.Context, roll int) {
|
||||||
|
path, method, body := "/api/v1/readings", http.MethodGet, any(nil)
|
||||||
|
if roll >= 60 && roll < 85 {
|
||||||
|
g.mu.Lock()
|
||||||
|
ids := append([]string(nil), g.buoys...)
|
||||||
|
g.mu.Unlock()
|
||||||
|
if len(ids) == 0 {
|
||||||
|
_ = g.request(ctx, method, path, nil, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
method = http.MethodPost
|
||||||
|
phase := float64(time.Now().UnixMilli()) / 60000
|
||||||
|
body = map[string]any{"buoy_id": ids[rand.IntN(len(ids))], "water_temp": 19 + 2*math.Sin(phase) + rand.Float64()*.4, "wave_height": 1.8 + .8*math.Sin(phase*.7+1) + rand.Float64()*.25, "salinity": 35.5 + .5*math.Sin(phase*.3) + rand.Float64()*.15}
|
||||||
|
} else if roll >= 85 {
|
||||||
|
path = "/api/v1/readings/aggregate?window=1h"
|
||||||
|
}
|
||||||
|
_ = g.request(ctx, method, path, body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *generator) seedUntilReady(ctx context.Context, logger *slog.Logger) {
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
g.mu.Lock()
|
||||||
|
ready := len(g.buoys) > 0
|
||||||
|
g.mu.Unlock()
|
||||||
|
if ready {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := g.seed(ctx); err != nil {
|
||||||
|
logger.Warn("buoy seed failed; traffic continues", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *generator) request(ctx context.Context, method, path string, body, output any) error {
|
||||||
|
var payload *bytes.Reader
|
||||||
|
if body == nil {
|
||||||
|
payload = bytes.NewReader(nil)
|
||||||
|
} else {
|
||||||
|
encoded, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode request: %w", err)
|
||||||
|
}
|
||||||
|
payload = bytes.NewReader(encoded)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, g.target+path, payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := g.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
g.record("network_error")
|
||||||
|
return fmt.Errorf("send request: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
g.record(strconv.Itoa(resp.StatusCode))
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("API returned %s", resp.Status)
|
||||||
|
}
|
||||||
|
if output != nil && json.NewDecoder(resp.Body).Decode(output) != nil {
|
||||||
|
return errors.New("decode response")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *generator) record(status string) { g.mu.Lock(); defer g.mu.Unlock(); g.counts[status]++ }
|
||||||
|
func (g *generator) summarize(ctx context.Context, logger *slog.Logger) {
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
g.mu.Lock()
|
||||||
|
counts := g.counts
|
||||||
|
g.counts = make(map[string]int)
|
||||||
|
g.mu.Unlock()
|
||||||
|
logger.Info("traffic summary", "status_counts", counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(name, fallback string) string {
|
||||||
|
if value := os.Getenv(name); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
106
apps/loadgen/main_test.go
Normal file
106
apps/loadgen/main_test.go
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequestWeighting(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
counts := make(map[string]int)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
counts[r.Method+" "+r.URL.RequestURI()]++
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
g := &generator{client: server.Client(), target: server.URL, buoys: []string{"buoy-1"}, counts: make(map[string]int)}
|
||||||
|
for roll := range 100 {
|
||||||
|
g.sendRoll(context.Background(), roll)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[string]int{
|
||||||
|
"GET /api/v1/readings": 60,
|
||||||
|
"POST /api/v1/readings": 25,
|
||||||
|
"GET /api/v1/readings/aggregate?window=1h": 15,
|
||||||
|
}
|
||||||
|
if fmt.Sprint(counts) != fmt.Sprint(want) {
|
||||||
|
t.Fatalf("request weighting = %v, want %v", counts, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedEmptyAPI(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
created := 0
|
||||||
|
var posted []buoy
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/buoys":
|
||||||
|
_ = json.NewEncoder(w).Encode([]buoy{})
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/buoys":
|
||||||
|
var input buoy
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
http.Error(w, "invalid buoy", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
created++
|
||||||
|
posted = append(posted, input)
|
||||||
|
id := fmt.Sprintf("buoy-%d", created)
|
||||||
|
mu.Unlock()
|
||||||
|
_ = json.NewEncoder(w).Encode(buoy{ID: id})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
g := &generator{client: server.Client(), target: server.URL, counts: make(map[string]int)}
|
||||||
|
if err := g.seed(context.Background()); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
if created != 42 || len(g.buoys) != 42 {
|
||||||
|
t.Fatalf("created %d buoys and retained %d IDs, want 42 and 42", created, len(g.buoys))
|
||||||
|
}
|
||||||
|
want := [][2]float64{{44.5, -9}, {44.2, -8}, {44.3, -7}, {44.4, -6}, {44.5, -5}, {44.6, -4}, {44.7, -3}, {44.5, -2}, {43.8, -2}, {43.5, -9.8}, {42.5, -10.2}, {41.5, -10.5}, {40.5, -10.8}, {39.5, -11}, {38.5, -11}, {37.5, -10.8}, {36.5, -10}, {35.9, -9}, {35.8, -8}, {35.7, -7}, {35.2, -7.5}, {35, -8.5}, {35.8, -4.5}, {36, -3.5}, {36.2, -2.5}, {36.5, -1.5}, {36.8, -.5}, {37.2, .5}, {37.7, 1.2}, {38.1, 2}, {38, 3}, {38.5, 4}, {39.5, 1}, {40, 1.5}, {40.5, 2}, {41, 2.7}, {41.5, 3.3}, {42, 3.8}, {42.5, 4.5}, {43, -9.8}, {41, -10.5}, {39, -10.8}}
|
||||||
|
for i, position := range want {
|
||||||
|
if posted[i].Latitude != position[0] || posted[i].Longitude != position[1] {
|
||||||
|
t.Errorf("buoy %d position = (%v, %v), want (%v, %v)", i, posted[i].Latitude, posted[i].Longitude, position[0], position[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrafficContinuesThroughHTTP500(t *testing.T) {
|
||||||
|
const requests = 30
|
||||||
|
var mu sync.Mutex
|
||||||
|
received := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
received++
|
||||||
|
mu.Unlock()
|
||||||
|
http.Error(w, "unavailable", http.StatusInternalServerError)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
g := &generator{client: server.Client(), target: server.URL, buoys: []string{"buoy-1"}, counts: make(map[string]int)}
|
||||||
|
for roll := range requests {
|
||||||
|
g.sendRoll(context.Background(), roll)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
got := received
|
||||||
|
mu.Unlock()
|
||||||
|
if got != requests {
|
||||||
|
t.Fatalf("received %d requests, want %d", got, requests)
|
||||||
|
}
|
||||||
|
if g.counts["500"] != requests {
|
||||||
|
t.Fatalf("recorded %d HTTP 500 responses, want %d", g.counts["500"], requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
242
apps/web/app.js
Normal file
242
apps/web/app.js
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const CACHE_KEY = "nereus.telemetry.v1";
|
||||||
|
const state = {
|
||||||
|
buoys: [],
|
||||||
|
readings: [],
|
||||||
|
markers: [],
|
||||||
|
responses: [],
|
||||||
|
totalErrors: 0,
|
||||||
|
connectivity: "offline",
|
||||||
|
version: null,
|
||||||
|
lastDataAt: null,
|
||||||
|
healthPolling: false,
|
||||||
|
dataPolling: false,
|
||||||
|
probes: {
|
||||||
|
liveness: {ok: null, changedAt: null},
|
||||||
|
readiness: {ok: null, changedAt: null}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const elements = Object.fromEntries([
|
||||||
|
"clock", "version-value", "version-note", "liveness-dot", "liveness-time",
|
||||||
|
"readiness-dot", "readiness-time", "error-total", "error-rate", "error-state",
|
||||||
|
"buoy-count", "data-age", "map-state", "telemetry-state", "latest-temp",
|
||||||
|
"latest-wave", "latest-salinity", "readings-chart"
|
||||||
|
].map((id) => [id, document.getElementById(id)]));
|
||||||
|
|
||||||
|
const map = L.map("map", {zoomControl: false, attributionControl: true, scrollWheelZoom: false}).setView([39.3, -4.2], 5);
|
||||||
|
map.attributionControl.addAttribution("Natural Earth");
|
||||||
|
if (window.NEREUS_LAND) {
|
||||||
|
L.geoJSON(window.NEREUS_LAND, {
|
||||||
|
interactive: false,
|
||||||
|
style: {className: "landmass", fillColor: "#183640", fillOpacity: .72, color: "#41636a", weight: .7}
|
||||||
|
}).addTo(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreCache() {
|
||||||
|
try {
|
||||||
|
const cached = JSON.parse(localStorage.getItem(CACHE_KEY));
|
||||||
|
if (!cached || !Array.isArray(cached.buoys) || !Array.isArray(cached.readings)) return;
|
||||||
|
state.buoys = cached.buoys;
|
||||||
|
state.readings = cached.readings;
|
||||||
|
state.lastDataAt = cached.savedAt || null;
|
||||||
|
renderTelemetry(true);
|
||||||
|
} catch (_) {
|
||||||
|
// A damaged cache is equivalent to no cache and should stay invisible.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCache() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CACHE_KEY, JSON.stringify({buoys: state.buoys, readings: state.readings, savedAt: state.lastDataAt}));
|
||||||
|
} catch (_) {
|
||||||
|
// Private browsing and storage quotas must not affect the dashboard.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(path) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
const response = await fetch(path, {headers: {Accept: "application/json"}, cache: "no-store"});
|
||||||
|
state.responses.push({at: startedAt, error: !response.ok});
|
||||||
|
if (!response.ok) {
|
||||||
|
state.totalErrors += 1;
|
||||||
|
throw Object.assign(new Error("API response was not successful"), {httpFailure: true});
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
if (!error.httpFailure) state.connectivity = "offline";
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
renderErrors();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(path, includeVersion = false) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(path, {cache: "no-store"});
|
||||||
|
return {ok: response.status === 200, version: includeVersion ? response.headers.get("X-Nereus-Version") : null};
|
||||||
|
} catch (_) {
|
||||||
|
return {ok: false, version: null};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProbe(name, ok) {
|
||||||
|
const probeState = state.probes[name];
|
||||||
|
if (probeState.ok !== ok) {
|
||||||
|
probeState.ok = ok;
|
||||||
|
probeState.changedAt = new Date();
|
||||||
|
}
|
||||||
|
elements[`${name}-dot`].className = `indicator ${ok ? "ok" : "bad"}`;
|
||||||
|
elements[`${name}-time`].textContent = probeState.changedAt ? `Changed ${formatTime(probeState.changedAt)}` : "No transition yet";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollHealth() {
|
||||||
|
if (state.healthPolling) return;
|
||||||
|
state.healthPolling = true;
|
||||||
|
try {
|
||||||
|
const [live, ready] = await Promise.all([probe("/healthz", true), probe("/readyz")]);
|
||||||
|
if (live.version) state.version = live.version;
|
||||||
|
updateProbe("liveness", live.ok);
|
||||||
|
updateProbe("readiness", ready.ok);
|
||||||
|
if (!live.ok) state.connectivity = "offline";
|
||||||
|
renderConnection();
|
||||||
|
} finally {
|
||||||
|
state.healthPolling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollData() {
|
||||||
|
if (state.dataPolling) return;
|
||||||
|
state.dataPolling = true;
|
||||||
|
try {
|
||||||
|
const [buoys, readings] = await Promise.all([
|
||||||
|
apiFetch("/api/v1/buoys"),
|
||||||
|
apiFetch("/api/v1/readings?limit=500")
|
||||||
|
]);
|
||||||
|
state.buoys = Array.isArray(buoys) ? buoys : [];
|
||||||
|
state.readings = Array.isArray(readings) ? readings : [];
|
||||||
|
state.lastDataAt = new Date().toISOString();
|
||||||
|
state.connectivity = "online";
|
||||||
|
saveCache();
|
||||||
|
renderTelemetry(false);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.httpFailure) state.connectivity = "degraded";
|
||||||
|
renderTelemetry(true);
|
||||||
|
} finally {
|
||||||
|
state.dataPolling = false;
|
||||||
|
renderConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConnection() {
|
||||||
|
const online = state.connectivity === "online";
|
||||||
|
const reachable = state.connectivity !== "offline";
|
||||||
|
elements["telemetry-state"].className = `connection-pill ${state.connectivity}`;
|
||||||
|
elements["telemetry-state"].textContent = state.connectivity === "online" ? "Live" : state.connectivity === "degraded" ? "API errors" : "Offline";
|
||||||
|
elements["version-value"].textContent = reachable ? state.version || "unavailable" : "offline";
|
||||||
|
elements["version-note"].textContent = reachable ? (state.version ? "Reported by API" : "Version unavailable") : "Cluster unavailable";
|
||||||
|
document.querySelector(".map-card").classList.toggle("stale", !online);
|
||||||
|
document.querySelector(".telemetry-card").classList.toggle("stale", !online);
|
||||||
|
renderErrors();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderErrors() {
|
||||||
|
const cutoff = Date.now() - 60000;
|
||||||
|
state.responses = state.responses.filter((event) => event.at >= cutoff);
|
||||||
|
const failed = state.responses.filter((event) => event.error).length;
|
||||||
|
const rate = state.responses.length ? failed / state.responses.length : 0;
|
||||||
|
elements["error-total"].textContent = state.totalErrors.toLocaleString();
|
||||||
|
elements["error-rate"].textContent = `${(rate * 100).toFixed(1)}%`;
|
||||||
|
elements["error-state"].textContent = state.connectivity === "offline"
|
||||||
|
? "API unreachable"
|
||||||
|
: failed > 0 ? "API responding with errors" : "API responses healthy";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTelemetry(stale) {
|
||||||
|
renderMap(stale);
|
||||||
|
drawChart();
|
||||||
|
const latest = [...state.readings].sort((a, b) => new Date(b.recorded_at) - new Date(a.recorded_at))[0];
|
||||||
|
elements["latest-temp"].textContent = numberOrDash(latest?.water_temp, 1);
|
||||||
|
elements["latest-wave"].textContent = numberOrDash(latest?.wave_height, 1);
|
||||||
|
elements["latest-salinity"].textContent = numberOrDash(latest?.salinity, 1);
|
||||||
|
elements["buoy-count"].textContent = `${state.buoys.length} ${state.buoys.length === 1 ? "buoy" : "buoys"}`;
|
||||||
|
elements["data-age"].textContent = state.lastDataAt ? `Updated ${formatTime(new Date(state.lastDataAt))}` : "No telemetry";
|
||||||
|
elements["map-state"].textContent = state.buoys.length ? (stale ? "Showing last known positions" : "Live positions") : "Waiting for buoy positions";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMap() {
|
||||||
|
state.markers.forEach((marker) => marker.remove());
|
||||||
|
const latest = new Map();
|
||||||
|
state.readings.forEach((reading) => {
|
||||||
|
const previous = latest.get(reading.buoy_id);
|
||||||
|
if (!previous || new Date(reading.recorded_at) > new Date(previous.recorded_at)) latest.set(reading.buoy_id, reading);
|
||||||
|
});
|
||||||
|
state.markers = state.buoys.filter((buoy) => Number.isFinite(buoy.latitude) && Number.isFinite(buoy.longitude)).map((buoy) => {
|
||||||
|
const reading = latest.get(buoy.id);
|
||||||
|
const details = reading ? `${numberOrDash(reading.water_temp, 1)} °C · ${numberOrDash(reading.wave_height, 1)} m · ${numberOrDash(reading.salinity, 1)} PSU<br><small>${formatTime(new Date(reading.recorded_at))}</small>` : "Awaiting first sample";
|
||||||
|
const icon = L.divIcon({className: "", html: '<div class="buoy-marker"></div>', iconSize: [13, 13], iconAnchor: [6, 6]});
|
||||||
|
return L.marker([buoy.latitude, buoy.longitude], {icon}).bindTooltip(`<strong>${escapeText(buoy.name)}</strong><br>${buoy.latitude.toFixed(2)}, ${buoy.longitude.toFixed(2)}<br>${details}`, {direction: "top"}).addTo(map);
|
||||||
|
});
|
||||||
|
if (state.markers.length) {
|
||||||
|
const bounds = L.featureGroup(state.markers).getBounds();
|
||||||
|
if (bounds.isValid()) map.fitBounds(bounds.pad(.22), {maxZoom: 7, animate: false});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawChart() {
|
||||||
|
const canvas = elements["readings-chart"];
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const ratio = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = Math.max(1, Math.floor(rect.width * ratio));
|
||||||
|
canvas.height = Math.max(1, Math.floor(rect.height * ratio));
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
context.scale(ratio, ratio);
|
||||||
|
const width = rect.width, height = rect.height, pad = {top: 13, right: 13, bottom: 23, left: 34};
|
||||||
|
const values = [...state.readings].sort((a, b) => new Date(a.recorded_at) - new Date(b.recorded_at)).slice(-45);
|
||||||
|
context.clearRect(0, 0, width, height);
|
||||||
|
context.strokeStyle = "rgba(121, 173, 180, .12)";
|
||||||
|
context.lineWidth = 1;
|
||||||
|
for (let i = 0; i < 5; i += 1) {
|
||||||
|
const y = pad.top + ((height - pad.top - pad.bottom) * i / 4);
|
||||||
|
context.beginPath(); context.moveTo(pad.left, y); context.lineTo(width - pad.right, y); context.stroke();
|
||||||
|
}
|
||||||
|
if (values.length < 2) {
|
||||||
|
context.fillStyle = "#789aa1"; context.font = "11px system-ui"; context.textAlign = "center";
|
||||||
|
context.fillText("Telemetry will appear when the cluster is online", width / 2, height / 2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const plot = (field, color, min, max) => {
|
||||||
|
const spread = max - min;
|
||||||
|
context.beginPath(); context.strokeStyle = color; context.lineWidth = 2; context.lineJoin = "round";
|
||||||
|
values.forEach((item, index) => {
|
||||||
|
const x = pad.left + ((width - pad.left - pad.right) * index / (values.length - 1));
|
||||||
|
const y = pad.top + (height - pad.top - pad.bottom) * (1 - (Number(item[field]) - min) / spread);
|
||||||
|
if (index === 0) context.moveTo(x, y); else context.lineTo(x, y);
|
||||||
|
});
|
||||||
|
context.stroke();
|
||||||
|
};
|
||||||
|
plot("water_temp", "#55e4d7", 10, 30);
|
||||||
|
plot("wave_height", "#55a9ff", 0, 6);
|
||||||
|
context.fillStyle = "#668d95"; context.font = "9px system-ui"; context.textAlign = "left";
|
||||||
|
context.fillText(formatTime(new Date(values[0].recorded_at)), pad.left, height - 5);
|
||||||
|
context.textAlign = "right"; context.fillText(formatTime(new Date(values.at(-1).recorded_at)), width - pad.right, height - 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date) { return new Intl.DateTimeFormat(undefined, {hour: "2-digit", minute: "2-digit", second: "2-digit"}).format(date); }
|
||||||
|
function numberOrDash(value, digits) { return Number.isFinite(Number(value)) ? Number(value).toFixed(digits) : "--"; }
|
||||||
|
function escapeText(value) { const node = document.createElement("span"); node.textContent = String(value || "Unnamed buoy"); return node.innerHTML; }
|
||||||
|
|
||||||
|
restoreCache();
|
||||||
|
renderConnection();
|
||||||
|
pollHealth();
|
||||||
|
pollData();
|
||||||
|
setInterval(pollHealth, 2000);
|
||||||
|
setInterval(pollData, 5000);
|
||||||
|
setInterval(renderErrors, 1000);
|
||||||
|
setInterval(() => { elements.clock.textContent = formatTime(new Date()); }, 1000);
|
||||||
|
elements.clock.textContent = formatTime(new Date());
|
||||||
|
window.addEventListener("resize", () => { map.invalidateSize(false); drawChart(); });
|
||||||
|
})();
|
||||||
1
apps/web/assets/ne_110m_land.js
Normal file
1
apps/web/assets/ne_110m_land.js
Normal file
File diff suppressed because one or more lines are too long
101
apps/web/index.html
Normal file
101
apps/web/index.html
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="dark">
|
||||||
|
<title>Nereus Ocean Telemetry</title>
|
||||||
|
<link rel="stylesheet" href="vendor/leaflet.css">
|
||||||
|
<link rel="stylesheet" href="styles.css?v=20260824-2">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="dashboard">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-mark" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 48 48"><path d="M8 29c7-8 14-8 21 0 5 5 9 5 13 1M10 20c5-6 10-6 15 0 4 4 8 4 12 0"/></svg>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Ocean intelligence</p>
|
||||||
|
<h1>Nereus</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a class="dev-link" href="https://fiwlabs.dev" target="_blank" rel="noopener noreferrer"><span>fiwlabs.dev</span><b aria-hidden="true">↗</b></a>
|
||||||
|
<div class="clock-block">
|
||||||
|
<span>Local time</span>
|
||||||
|
<strong id="clock">--:--:--</strong>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="status-grid" aria-label="System status">
|
||||||
|
<article class="glass status-card version-card">
|
||||||
|
<div class="status-icon">V</div>
|
||||||
|
<div>
|
||||||
|
<p class="card-label">API version</p>
|
||||||
|
<strong id="version-value">offline</strong>
|
||||||
|
<span id="version-note">Cluster unavailable</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="glass status-card health-card">
|
||||||
|
<div>
|
||||||
|
<p class="card-label">Cluster health</p>
|
||||||
|
<strong>Service probes</strong>
|
||||||
|
</div>
|
||||||
|
<div class="probes">
|
||||||
|
<div class="probe">
|
||||||
|
<span id="liveness-dot" class="indicator unknown"></span>
|
||||||
|
<div><b>Liveness</b><time id="liveness-time">No transition yet</time></div>
|
||||||
|
</div>
|
||||||
|
<div class="probe">
|
||||||
|
<span id="readiness-dot" class="indicator unknown"></span>
|
||||||
|
<div><b>Readiness</b><time id="readiness-time">No transition yet</time></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="glass status-card error-card">
|
||||||
|
<div>
|
||||||
|
<p class="card-label">Client-side API errors</p>
|
||||||
|
<strong id="error-total">0</strong>
|
||||||
|
<span>non-2xx responses</span>
|
||||||
|
</div>
|
||||||
|
<div class="error-rate">
|
||||||
|
<span id="error-rate">0.0%</span>
|
||||||
|
<small>rolling 60s</small>
|
||||||
|
<em id="error-state">Waiting for API</em>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="workspace">
|
||||||
|
<article class="glass map-card">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div><p class="card-label">Active network</p><h2>Buoy positions</h2></div>
|
||||||
|
<div class="panel-meta"><span id="buoy-count">0 buoys</span><span id="data-age">No telemetry</span></div>
|
||||||
|
</div>
|
||||||
|
<div id="map" aria-label="Map of buoy positions"></div>
|
||||||
|
<div id="map-state" class="quiet-state">Waiting for buoy positions</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="glass telemetry-card">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div><p class="card-label">Latest samples</p><h2>Water conditions</h2></div>
|
||||||
|
<span id="telemetry-state" class="connection-pill offline">Offline</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend" aria-hidden="true"><span class="temperature">Water temperature</span><span class="waves">Wave height</span></div>
|
||||||
|
<div class="chart-wrap"><canvas id="readings-chart" aria-label="Recent water temperature and wave height chart"></canvas></div>
|
||||||
|
<div class="telemetry-summary">
|
||||||
|
<div><span>Temperature</span><strong id="latest-temp">--</strong><small>°C</small></div>
|
||||||
|
<div><span>Wave height</span><strong id="latest-wave">--</strong><small>m</small></div>
|
||||||
|
<div><span>Salinity</span><strong id="latest-salinity">--</strong><small>PSU</small></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="vendor/leaflet.js"></script>
|
||||||
|
<script src="assets/ne_110m_land.js"></script>
|
||||||
|
<script src="app.js?v=20260824-3"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
103
apps/web/styles.css
Normal file
103
apps/web/styles.css
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
background: #07151d;
|
||||||
|
color: #d8e3e3;
|
||||||
|
--ink: #07151d;
|
||||||
|
--panel: #0b202a;
|
||||||
|
--panel-deep: #081a23;
|
||||||
|
--rule: #27434b;
|
||||||
|
--rule-strong: #3c6269;
|
||||||
|
--muted: #7f999d;
|
||||||
|
--signal: #52c8bc;
|
||||||
|
--blue: #5aa9d6;
|
||||||
|
--warn: #d6a85a;
|
||||||
|
--bad: #db7770;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
||||||
|
body {
|
||||||
|
min-width: 1280px;
|
||||||
|
background-color: var(--ink);
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(85, 126, 134, .045) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(85, 126, 134, .045) 1px, transparent 1px);
|
||||||
|
background-size: 32px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocean-background { display: none; }
|
||||||
|
.dashboard { height: 100vh; padding: 0 26px 24px; display: grid; grid-template-rows: 74px 102px minmax(0, 1fr); gap: 12px; }
|
||||||
|
.topbar { position: relative; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--rule-strong); }
|
||||||
|
.dev-link { position: absolute; top: 50%; left: 50%; display: flex; align-items: center; gap: 9px; padding: 9px 13px 8px 15px; transform: translate(-50%, -50%); border: 1px solid var(--rule-strong); color: #9cb3b6; background: #0a1c25; box-shadow: inset 2px 0 0 var(--signal); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .16em; text-decoration: none; text-transform: uppercase; transition: color .2s ease, border-color .2s ease, background-color .2s ease; }
|
||||||
|
.dev-link b { color: var(--signal); font-size: 13px; font-weight: 400; line-height: .7; }
|
||||||
|
.dev-link:hover, .dev-link:focus-visible { border-color: var(--signal); color: #e0f5f2; background: #102b34; outline: none; }
|
||||||
|
.brand { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.brand-mark { width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid var(--rule-strong); }
|
||||||
|
.brand-mark svg { width: 24px; fill: none; stroke: var(--signal); stroke-width: 1.7; stroke-linecap: square; }
|
||||||
|
.eyebrow, .card-label { margin: 0 0 4px; color: var(--muted); font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .15em; text-transform: uppercase; }
|
||||||
|
h1 { margin: 0; font-size: 22px; line-height: 1; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
.clock-block { text-align: right; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
|
.clock-block span { display: block; color: var(--muted); font-size: 8px; letter-spacing: .14em; text-transform: uppercase; }
|
||||||
|
.clock-block strong { font-size: 16px; font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.glass { border: 1px solid var(--rule); background: var(--panel); box-shadow: none; backdrop-filter: none; }
|
||||||
|
.status-grid { display: grid; grid-template-columns: .72fr 1.48fr 1.1fr; gap: 12px; }
|
||||||
|
.status-card { position: relative; min-width: 0; padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; border-radius: 0; }
|
||||||
|
.status-card::before { content: ""; position: absolute; top: -1px; left: -1px; width: 54px; height: 2px; background: var(--signal); }
|
||||||
|
.status-card strong { display: block; font: 600 16px ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
|
.status-card > div > span:not(.indicator) { color: var(--muted); font-size: 10px; }
|
||||||
|
.status-icon { display: none; }
|
||||||
|
.version-card { justify-content: flex-start; border-left-color: var(--rule-strong); }
|
||||||
|
.version-card strong { color: var(--bad); text-transform: uppercase; }
|
||||||
|
.probes { display: flex; align-items: center; gap: 34px; }
|
||||||
|
.probe { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.probe b { display: block; font: 600 11px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
|
||||||
|
.probe time { display: block; width: 132px; margin-top: 4px; color: var(--muted); font: 8px ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
|
.indicator { width: 8px; height: 8px; border-radius: 50%; background: #64747a; transition: background-color .2s ease; }
|
||||||
|
.indicator.ok { background: var(--signal); box-shadow: none; }
|
||||||
|
.indicator.bad { background: var(--bad); box-shadow: none; }
|
||||||
|
.error-card strong { font-size: 27px; font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||||
|
.error-rate { min-width: 144px; padding-left: 18px; border-left: 1px solid var(--rule); text-align: left; }
|
||||||
|
.error-rate > span { display: block; color: var(--signal); font: 500 20px ui-monospace, SFMono-Regular, Consolas, monospace; font-variant-numeric: tabular-nums; }
|
||||||
|
.error-rate small { color: var(--muted); font: 8px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
|
||||||
|
.error-rate em { display: block; margin-top: 7px; color: #9cb0b2; font-size: 9px; font-style: normal; }
|
||||||
|
|
||||||
|
.workspace { min-height: 0; display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(400px, .85fr); gap: 12px; }
|
||||||
|
.map-card, .telemetry-card { position: relative; min-height: 0; overflow: hidden; border-radius: 0; }
|
||||||
|
.panel-heading { height: 66px; padding: 14px 16px 11px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--rule); background: var(--panel-deep); }
|
||||||
|
.panel-heading h2 { margin: 0; font-size: 15px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; }
|
||||||
|
.panel-meta { display: flex; gap: 18px; }
|
||||||
|
.panel-meta span, .connection-pill { padding: 0; border: 0; border-radius: 0; color: var(--muted); background: transparent; font: 9px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
|
||||||
|
.panel-meta span + span { padding-left: 18px; border-left: 1px solid var(--rule); }
|
||||||
|
#map { height: calc(100% - 66px); background: #091b24; transition: opacity .2s ease; }
|
||||||
|
#map::before { content: ""; position: absolute; inset: 0; z-index: 200; pointer-events: none; background-image: linear-gradient(rgba(96,137,143,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(96,137,143,.1) 1px, transparent 1px); background-size: 64px 64px; }
|
||||||
|
.map-card.stale #map { filter: saturate(.3) brightness(.72); }
|
||||||
|
.quiet-state { position: absolute; left: 16px; bottom: 14px; z-index: 500; padding: 6px 9px; border: 1px solid var(--rule-strong); color: #9db0b2; background: rgba(7, 21, 29, .92); font: 8px ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .08em; text-transform: uppercase; pointer-events: none; }
|
||||||
|
.quiet-state.hidden { opacity: 0; }
|
||||||
|
.leaflet-container { font-family: Arial, Helvetica, sans-serif; }
|
||||||
|
.leaflet-control-attribution { color: #6b8589 !important; background: rgba(7,21,29,.84) !important; font-size: 8px !important; }
|
||||||
|
.leaflet-control-attribution a { color: #83a5a8 !important; }
|
||||||
|
.landmass { fill: #183640; fill-opacity: .72; stroke: #41636a; stroke-width: .7; }
|
||||||
|
.buoy-marker { width: 9px; height: 9px; border: 1px solid #d9fffa; border-radius: 50%; background: var(--signal); animation: pulse 2s steps(2, end) infinite; }
|
||||||
|
@keyframes pulse { 50% { outline: 4px solid rgba(82, 200, 188, .22); } }
|
||||||
|
|
||||||
|
.telemetry-card { display: grid; grid-template-rows: 66px 28px minmax(0, 1fr) 72px; }
|
||||||
|
.connection-pill::before { content: "●"; margin-right: 6px; }
|
||||||
|
.connection-pill.online { color: var(--signal); }
|
||||||
|
.connection-pill.degraded { color: var(--warn); }
|
||||||
|
.connection-pill.offline { color: var(--bad); }
|
||||||
|
.legend { display: flex; align-items: center; gap: 24px; padding: 0 16px; border-bottom: 1px solid rgba(39,67,75,.65); color: var(--muted); font: 8px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
|
||||||
|
.legend span::before { content: ""; display: inline-block; width: 14px; height: 2px; margin: 0 7px 2px 0; background: var(--signal); }
|
||||||
|
.legend .waves::before { background: var(--blue); }
|
||||||
|
.chart-wrap { min-height: 0; padding: 8px 10px 0; transition: opacity .2s ease; }
|
||||||
|
.telemetry-card.stale .chart-wrap { opacity: .35; }
|
||||||
|
#readings-chart { width: 100%; height: 100%; }
|
||||||
|
.telemetry-summary { margin: 0; display: grid; grid-template-columns: repeat(3, 1fr); overflow: hidden; border-top: 1px solid var(--rule); background: var(--panel-deep); }
|
||||||
|
.telemetry-summary div { padding: 11px 13px; border-right: 1px solid var(--rule); }
|
||||||
|
.telemetry-summary div:last-child { border: 0; }
|
||||||
|
.telemetry-summary span { display: block; color: var(--muted); font: 8px ui-monospace, SFMono-Regular, Consolas, monospace; text-transform: uppercase; }
|
||||||
|
.telemetry-summary strong { display: inline-block; margin-top: 5px; font: 500 17px ui-monospace, SFMono-Regular, Consolas, monospace; font-variant-numeric: tabular-nums; }
|
||||||
|
.telemetry-summary small { margin-left: 3px; color: var(--muted); font: 8px ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
|
||||||
26
apps/web/vendor/LEAFLET-LICENSE
vendored
Normal file
26
apps/web/vendor/LEAFLET-LICENSE
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
BSD 2-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2010-2023, Volodymyr Agafonkin
|
||||||
|
Copyright (c) 2010-2011, CloudMade
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
661
apps/web/vendor/leaflet.css
vendored
Normal file
661
apps/web/vendor/leaflet.css
vendored
Normal file
|
|
@ -0,0 +1,661 @@
|
||||||
|
/* required styles */
|
||||||
|
|
||||||
|
.leaflet-pane,
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-tile-container,
|
||||||
|
.leaflet-pane > svg,
|
||||||
|
.leaflet-pane > canvas,
|
||||||
|
.leaflet-zoom-box,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-layer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
/* Prevents IE11 from highlighting tiles in blue */
|
||||||
|
.leaflet-tile::selection {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||||
|
.leaflet-safari .leaflet-tile {
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
}
|
||||||
|
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||||
|
.leaflet-safari .leaflet-tile-container {
|
||||||
|
width: 1600px;
|
||||||
|
height: 1600px;
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||||
|
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||||
|
.leaflet-container .leaflet-overlay-pane svg {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
.leaflet-container .leaflet-marker-pane img,
|
||||||
|
.leaflet-container .leaflet-shadow-pane img,
|
||||||
|
.leaflet-container .leaflet-tile-pane img,
|
||||||
|
.leaflet-container img.leaflet-image-layer,
|
||||||
|
.leaflet-container .leaflet-tile {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container img.leaflet-tile {
|
||||||
|
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||||
|
mix-blend-mode: plus-lighter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: pan-x pan-y;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag {
|
||||||
|
-ms-touch-action: pinch-zoom;
|
||||||
|
/* Fallback for FF which doesn't support pinch-zoom */
|
||||||
|
touch-action: none;
|
||||||
|
touch-action: pinch-zoom;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tile {
|
||||||
|
filter: inherit;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile-loaded {
|
||||||
|
visibility: inherit;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 800;
|
||||||
|
}
|
||||||
|
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||||
|
.leaflet-overlay-pane svg {
|
||||||
|
-moz-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-pane { z-index: 400; }
|
||||||
|
|
||||||
|
.leaflet-tile-pane { z-index: 200; }
|
||||||
|
.leaflet-overlay-pane { z-index: 400; }
|
||||||
|
.leaflet-shadow-pane { z-index: 500; }
|
||||||
|
.leaflet-marker-pane { z-index: 600; }
|
||||||
|
.leaflet-tooltip-pane { z-index: 650; }
|
||||||
|
.leaflet-popup-pane { z-index: 700; }
|
||||||
|
|
||||||
|
.leaflet-map-pane canvas { z-index: 100; }
|
||||||
|
.leaflet-map-pane svg { z-index: 200; }
|
||||||
|
|
||||||
|
.leaflet-vml-shape {
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
.lvml {
|
||||||
|
behavior: url(#default#VML);
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* control positioning */
|
||||||
|
|
||||||
|
.leaflet-control {
|
||||||
|
position: relative;
|
||||||
|
z-index: 800;
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-top,
|
||||||
|
.leaflet-bottom {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-top {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.leaflet-bottom {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
.leaflet-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control {
|
||||||
|
float: left;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.leaflet-top .leaflet-control {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* zoom and fade animations */
|
||||||
|
|
||||||
|
.leaflet-fade-anim .leaflet-popup {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transition: opacity 0.2s linear;
|
||||||
|
-moz-transition: opacity 0.2s linear;
|
||||||
|
transition: opacity 0.2s linear;
|
||||||
|
}
|
||||||
|
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-animated {
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
-ms-transform-origin: 0 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
svg.leaflet-zoom-animated {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||||
|
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
}
|
||||||
|
.leaflet-zoom-anim .leaflet-tile,
|
||||||
|
.leaflet-pan-anim .leaflet-tile {
|
||||||
|
-webkit-transition: none;
|
||||||
|
-moz-transition: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* cursors */
|
||||||
|
|
||||||
|
.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.leaflet-grab {
|
||||||
|
cursor: -webkit-grab;
|
||||||
|
cursor: -moz-grab;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.leaflet-crosshair,
|
||||||
|
.leaflet-crosshair .leaflet-interactive {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.leaflet-popup-pane,
|
||||||
|
.leaflet-control {
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
.leaflet-dragging .leaflet-grab,
|
||||||
|
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||||
|
.leaflet-dragging .leaflet-marker-draggable {
|
||||||
|
cursor: move;
|
||||||
|
cursor: -webkit-grabbing;
|
||||||
|
cursor: -moz-grabbing;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* marker & overlays interactivity */
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-pane > svg path,
|
||||||
|
.leaflet-tile-container {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-marker-icon.leaflet-interactive,
|
||||||
|
.leaflet-image-layer.leaflet-interactive,
|
||||||
|
.leaflet-pane > svg path.leaflet-interactive,
|
||||||
|
svg.leaflet-image-layer.leaflet-interactive path {
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* visual tweaks */
|
||||||
|
|
||||||
|
.leaflet-container {
|
||||||
|
background: #ddd;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
color: #0078A8;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
border: 2px dotted #38f;
|
||||||
|
background: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general typography */
|
||||||
|
.leaflet-container {
|
||||||
|
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general toolbar styles */
|
||||||
|
|
||||||
|
.leaflet-bar {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a {
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
line-height: 26px;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.leaflet-bar a,
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-position: 50% 50%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:hover,
|
||||||
|
.leaflet-bar a:focus {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.leaflet-bar a.leaflet-disabled {
|
||||||
|
cursor: default;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-bar a {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 2px;
|
||||||
|
border-top-right-radius: 2px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 2px;
|
||||||
|
border-bottom-right-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* zoom control */
|
||||||
|
|
||||||
|
.leaflet-control-zoom-in,
|
||||||
|
.leaflet-control-zoom-out {
|
||||||
|
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||||
|
text-indent: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* layers control */
|
||||||
|
|
||||||
|
.leaflet-control-layers {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers.png);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.leaflet-retina .leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers-2x.png);
|
||||||
|
background-size: 26px 26px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers-toggle {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers .leaflet-control-layers-list,
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded {
|
||||||
|
padding: 6px 10px 6px 6px;
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-scrollbar {
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-selector {
|
||||||
|
margin-top: 2px;
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-separator {
|
||||||
|
height: 0;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
margin: 5px -10px 5px -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default icon URLs */
|
||||||
|
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||||
|
background-image: url(images/marker-icon.png);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* attribution and scale controls */
|
||||||
|
|
||||||
|
.leaflet-container .leaflet-control-attribution {
|
||||||
|
background: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution,
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
padding: 0 5px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a:hover,
|
||||||
|
.leaflet-control-attribution a:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.leaflet-attribution-flag {
|
||||||
|
display: inline !important;
|
||||||
|
vertical-align: baseline !important;
|
||||||
|
width: 1em;
|
||||||
|
height: 0.6669em;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control-scale {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control-scale {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
border: 2px solid #777;
|
||||||
|
border-top: none;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 2px 5px 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
text-shadow: 1px 1px #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child) {
|
||||||
|
border-top: 2px solid #777;
|
||||||
|
border-bottom: none;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||||
|
border-bottom: 2px solid #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-attribution,
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
border: 2px solid rgba(0,0,0,0.2);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* popup */
|
||||||
|
|
||||||
|
.leaflet-popup {
|
||||||
|
position: absolute;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper {
|
||||||
|
padding: 1px;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content {
|
||||||
|
margin: 13px 24px 13px 20px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content p {
|
||||||
|
margin: 17px 0;
|
||||||
|
margin: 1.3em 0;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip-container {
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
margin-top: -1px;
|
||||||
|
margin-left: -20px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
padding: 1px;
|
||||||
|
|
||||||
|
margin: -10px auto 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
-webkit-transform: rotate(45deg);
|
||||||
|
-moz-transform: rotate(45deg);
|
||||||
|
-ms-transform: rotate(45deg);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||||
|
color: #757575;
|
||||||
|
text-decoration: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||||
|
color: #585858;
|
||||||
|
}
|
||||||
|
.leaflet-popup-scrolled {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||||
|
-ms-zoom: 1;
|
||||||
|
}
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
width: 24px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||||
|
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-control-zoom,
|
||||||
|
.leaflet-oldie .leaflet-control-layers,
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
border: 1px solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* div icon */
|
||||||
|
|
||||||
|
.leaflet-div-icon {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Tooltip */
|
||||||
|
/* Base styles for the element that has a tooltip */
|
||||||
|
.leaflet-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
padding: 6px;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #222;
|
||||||
|
white-space: nowrap;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tooltip.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before,
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 6px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Directions */
|
||||||
|
|
||||||
|
.leaflet-tooltip-bottom {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top {
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
bottom: 0;
|
||||||
|
margin-bottom: -12px;
|
||||||
|
border-top-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before {
|
||||||
|
top: 0;
|
||||||
|
margin-top: -12px;
|
||||||
|
margin-left: -6px;
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left {
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
top: 50%;
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before {
|
||||||
|
right: 0;
|
||||||
|
margin-right: -12px;
|
||||||
|
border-left-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
left: 0;
|
||||||
|
margin-left: -12px;
|
||||||
|
border-right-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Printing */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
/* Prevent printers from removing background-images of controls. */
|
||||||
|
.leaflet-control {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/web/vendor/leaflet.js
vendored
Normal file
6
apps/web/vendor/leaflet.js
vendored
Normal file
File diff suppressed because one or more lines are too long
19
build/api.Dockerfile
Normal file
19
build/api.Dockerfile
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
FROM golang:1.26-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY apps/api/go.mod apps/api/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY apps/api/ ./
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
RUN CGO_ENABLED=0 go build \
|
||||||
|
-trimpath \
|
||||||
|
-ldflags "-s -w -X main.appVersion=${APP_VERSION}" \
|
||||||
|
-o /out/nereus-api .
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static:nonroot
|
||||||
|
|
||||||
|
COPY --from=build /out/nereus-api /nereus-api
|
||||||
|
USER nonroot:nonroot
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/nereus-api"]
|
||||||
33
build/compose.mini.yaml
Normal file
33
build/compose.mini.yaml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
name: nereus-web
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: build/web.Dockerfile
|
||||||
|
args:
|
||||||
|
WEB_ASSET_PATH: ${WEB_ASSET_PATH:-apps/web}
|
||||||
|
environment:
|
||||||
|
CLUSTER_API_UPSTREAM: ${CLUSTER_API_UPSTREAM:?set CLUSTER_API_UPSTREAM to the cluster host and port}
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /etc/nginx/conf.d:size=1m,uid=101,gid=101,mode=0755
|
||||||
|
- /tmp:size=16m,mode=1777
|
||||||
|
- /var/cache/nginx:size=16m,mode=0755
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.nereus-web.rule=Host(`${WEB_HOST:-nereus.fiwlabs.dev}`)
|
||||||
|
- traefik.http.routers.nereus-web.entrypoints=websecure
|
||||||
|
- traefik.http.routers.nereus-web.tls=true
|
||||||
|
- traefik.http.routers.nereus-web.tls.certresolver=porkbun
|
||||||
|
- traefik.http.services.nereus-web.loadbalancer.server.port=8080
|
||||||
|
- traefik.docker.network=proxy
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
17
build/loadgen.Dockerfile
Normal file
17
build/loadgen.Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
FROM golang:1.26-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
COPY apps/loadgen/go.mod ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY apps/loadgen/ ./
|
||||||
|
RUN CGO_ENABLED=0 go build \
|
||||||
|
-trimpath \
|
||||||
|
-ldflags "-s -w" \
|
||||||
|
-o /out/nereus-loadgen .
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static:nonroot
|
||||||
|
|
||||||
|
COPY --from=build /out/nereus-loadgen /nereus-loadgen
|
||||||
|
USER nonroot:nonroot
|
||||||
|
ENTRYPOINT ["/nereus-loadgen"]
|
||||||
15
build/otel-collector.local.yaml
Normal file
15
build/otel-collector.local.yaml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc:
|
||||||
|
endpoint: 0.0.0.0:4317
|
||||||
|
|
||||||
|
exporters:
|
||||||
|
debug:
|
||||||
|
verbosity: basic
|
||||||
|
|
||||||
|
service:
|
||||||
|
pipelines:
|
||||||
|
traces:
|
||||||
|
receivers: [otlp]
|
||||||
|
exporters: [debug]
|
||||||
10
build/web.Dockerfile
Normal file
10
build/web.Dockerfile
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
FROM nginxinc/nginx-unprivileged:1.31.4-alpine
|
||||||
|
|
||||||
|
COPY build/web.conf.template /etc/nginx/templates/default.conf.template
|
||||||
|
ARG WEB_ASSET_PATH=apps/web
|
||||||
|
COPY ${WEB_ASSET_PATH}/index.html ${WEB_ASSET_PATH}/styles.css ${WEB_ASSET_PATH}/app.js /usr/share/nginx/html/
|
||||||
|
COPY ${WEB_ASSET_PATH}/assets/ /usr/share/nginx/html/assets/
|
||||||
|
COPY ${WEB_ASSET_PATH}/vendor/ /usr/share/nginx/html/vendor/
|
||||||
|
|
||||||
|
USER 101
|
||||||
|
EXPOSE 8080
|
||||||
33
build/web.conf.template
Normal file
33
build/web.conf.template
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/v1/ {
|
||||||
|
proxy_pass http://${CLUSTER_API_UPSTREAM};
|
||||||
|
proxy_connect_timeout 1s;
|
||||||
|
proxy_read_timeout 5s;
|
||||||
|
proxy_send_timeout 5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /healthz {
|
||||||
|
proxy_pass http://${CLUSTER_API_UPSTREAM};
|
||||||
|
proxy_connect_timeout 1s;
|
||||||
|
proxy_read_timeout 5s;
|
||||||
|
proxy_send_timeout 5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /readyz {
|
||||||
|
proxy_pass http://${CLUSTER_API_UPSTREAM};
|
||||||
|
proxy_connect_timeout 1s;
|
||||||
|
proxy_read_timeout 5s;
|
||||||
|
proxy_send_timeout 5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
66
compose.yaml
Normal file
66
compose.yaml
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
name: nereus
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: nereus
|
||||||
|
POSTGRES_USER: nereus
|
||||||
|
POSTGRES_HOST_AUTH_METHOD: trust
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U nereus -d nereus"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 15
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
otel-collector:
|
||||||
|
image: otel/opentelemetry-collector-contrib:0.159.0
|
||||||
|
command: ["--config=/etc/otelcol-contrib/config.yaml"]
|
||||||
|
read_only: true
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
volumes:
|
||||||
|
- ./build/otel-collector.local.yaml:/etc/otelcol-contrib/config.yaml:ro
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: build/api.Dockerfile
|
||||||
|
args:
|
||||||
|
APP_VERSION: local
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://nereus@postgres:5432/nereus?sslmode=disable
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
|
||||||
|
LOG_LEVEL: info
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
otel-collector:
|
||||||
|
condition: service_started
|
||||||
|
ports:
|
||||||
|
- "${API_PORT:-18080}:8080"
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=16m,mode=1777
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
loadgen:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: build/loadgen.Dockerfile
|
||||||
|
environment:
|
||||||
|
TARGET_URL: http://api:8080
|
||||||
|
RPS: "5"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=16m,mode=1777
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
41
deploy/base/api-services.yaml
Normal file
41
deploy/base/api-services.yaml
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Active and preview Services for the blue-green Rollout.
|
||||||
|
#
|
||||||
|
# Argo Rollouts rewrites both selectors at runtime to pin them to the right
|
||||||
|
# ReplicaSet. What's below is just the starting state.
|
||||||
|
#
|
||||||
|
# Same app.kubernetes.io/name on both so one ServiceMonitor covers them, which
|
||||||
|
# is what gives the analysis query a `service` label to filter on.
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: nereus-api-active
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus-api
|
||||||
|
app.kubernetes.io/part-of: nereus
|
||||||
|
app.kubernetes.io/component: active
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: nereus-api
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 8080
|
||||||
|
targetPort: http
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: nereus-api-preview
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus-api
|
||||||
|
app.kubernetes.io/part-of: nereus
|
||||||
|
app.kubernetes.io/component: preview
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: nereus-api
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 8080
|
||||||
|
targetPort: http
|
||||||
18
deploy/base/configmap.yaml
Normal file
18
deploy/base/configmap.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Non-secret API config. The DSN lives in the nereus-db Secret.
|
||||||
|
#
|
||||||
|
# Values must be quoted -- data is map[string]string, and an unquoted 8080
|
||||||
|
# gets rejected as an integer.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: nereus-api-config
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus-api
|
||||||
|
app.kubernetes.io/part-of: nereus
|
||||||
|
data:
|
||||||
|
PORT: "8080"
|
||||||
|
LOG_LEVEL: "info"
|
||||||
|
# main.go only enables tracing when this is set.
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: "http://nereus-otel-collector.observability.svc.cluster.local:4317"
|
||||||
|
# Patched to a nonzero value on the preview to trigger the rollback demo.
|
||||||
|
CHAOS_ERROR_RATE: "0.0"
|
||||||
40
deploy/base/ingress.yaml
Normal file
40
deploy/base/ingress.yaml
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Traefik ingress. Reachable at http://localhost:8080 in the k3d lab.
|
||||||
|
#
|
||||||
|
# Active Service only -- the preview stays internal so real traffic can't hit
|
||||||
|
# an unpromoted version. /metrics is not exposed either.
|
||||||
|
#
|
||||||
|
# No host, so any Host header matches. A real hostname belongs in the prod
|
||||||
|
# overlay.
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: nereus-api
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus-api
|
||||||
|
app.kubernetes.io/part-of: nereus
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
rules:
|
||||||
|
- http:
|
||||||
|
paths:
|
||||||
|
- path: /api/v1
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: nereus-api-active
|
||||||
|
port:
|
||||||
|
name: http
|
||||||
|
- path: /healthz
|
||||||
|
pathType: Exact
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: nereus-api-active
|
||||||
|
port:
|
||||||
|
name: http
|
||||||
|
- path: /readyz
|
||||||
|
pathType: Exact
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: nereus-api-active
|
||||||
|
port:
|
||||||
|
name: http
|
||||||
18
deploy/base/kustomization.yaml
Normal file
18
deploy/base/kustomization.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Plain Kubernetes only -- no Rollouts CRDs, so this still applies on a
|
||||||
|
# cluster without Argo installed.
|
||||||
|
#
|
||||||
|
# The nereus-db Secret is not here; see deploy/secrets/README.md.
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
# Sets the namespace on everything below, so the files don't repeat it.
|
||||||
|
namespace: nereus
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- configmap.yaml
|
||||||
|
- postgres.yaml
|
||||||
|
- api-services.yaml
|
||||||
|
- servicemonitor.yaml
|
||||||
|
- loadgen.yaml
|
||||||
|
- ingress.yaml
|
||||||
100
deploy/base/loadgen.yaml
Normal file
100
deploy/base/loadgen.yaml
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# Load generator. A plain Deployment -- nothing to promote, and it has to keep
|
||||||
|
# running straight through an API rollout.
|
||||||
|
#
|
||||||
|
# Points at the active Service so its traffic never lands on the preview.
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: nereus-loadgen
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus-loadgen
|
||||||
|
app.kubernetes.io/part-of: nereus
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: nereus-loadgen
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus-loadgen
|
||||||
|
app.kubernetes.io/part-of: nereus
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: nereus-registry
|
||||||
|
securityContext:
|
||||||
|
# distroless nonroot.
|
||||||
|
runAsUser: 65532
|
||||||
|
runAsGroup: 65532
|
||||||
|
runAsNonRoot: true
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: loadgen
|
||||||
|
image: nereus-loadgen:0.1.0
|
||||||
|
# Side-loaded with `k3d image import`; there's no registry.
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
env:
|
||||||
|
- name: TARGET_URL
|
||||||
|
value: http://nereus-api-active.nereus.svc.cluster.local:8080
|
||||||
|
- name: RPS
|
||||||
|
value: "5"
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 25m
|
||||||
|
memory: 32Mi
|
||||||
|
limits:
|
||||||
|
memory: 128Mi
|
||||||
|
---
|
||||||
|
# Keeps request samples flowing through the preview while analysis runs.
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: nereus-loadgen-preview
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus-loadgen-preview
|
||||||
|
app.kubernetes.io/part-of: nereus
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: nereus-loadgen-preview
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus-loadgen-preview
|
||||||
|
app.kubernetes.io/part-of: nereus
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: nereus-registry
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 65532
|
||||||
|
runAsGroup: 65532
|
||||||
|
runAsNonRoot: true
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: loadgen
|
||||||
|
image: nereus-loadgen:0.1.0
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
env:
|
||||||
|
- name: TARGET_URL
|
||||||
|
value: http://nereus-api-preview.nereus.svc.cluster.local:8080
|
||||||
|
- name: RPS
|
||||||
|
value: "5"
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 25m
|
||||||
|
memory: 32Mi
|
||||||
|
limits:
|
||||||
|
memory: 128Mi
|
||||||
7
deploy/base/namespace.yaml
Normal file
7
deploy/base/namespace.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: nereus
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus
|
||||||
|
app.kubernetes.io/part-of: nereus
|
||||||
113
deploy/base/postgres.yaml
Normal file
113
deploy/base/postgres.yaml
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
# Postgres 17, StatefulSet + headless Service.
|
||||||
|
#
|
||||||
|
# StatefulSet rather than Deployment for the stable pod name and a PVC that
|
||||||
|
# survives the pod.
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: nereus-postgres
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus-postgres
|
||||||
|
app.kubernetes.io/part-of: nereus
|
||||||
|
spec:
|
||||||
|
clusterIP: None
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: nereus-postgres
|
||||||
|
ports:
|
||||||
|
- name: postgres
|
||||||
|
port: 5432
|
||||||
|
targetPort: postgres
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: nereus-postgres
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus-postgres
|
||||||
|
app.kubernetes.io/part-of: nereus
|
||||||
|
spec:
|
||||||
|
serviceName: nereus-postgres
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: nereus-postgres
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus-postgres
|
||||||
|
app.kubernetes.io/part-of: nereus
|
||||||
|
spec:
|
||||||
|
securityContext:
|
||||||
|
# postgres:17-alpine runs as uid 70. The Debian tags use 999.
|
||||||
|
runAsUser: 70
|
||||||
|
runAsGroup: 70
|
||||||
|
fsGroup: 70
|
||||||
|
runAsNonRoot: true
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:17-alpine
|
||||||
|
ports:
|
||||||
|
- name: postgres
|
||||||
|
containerPort: 5432
|
||||||
|
env:
|
||||||
|
- name: POSTGRES_DB
|
||||||
|
value: nereus
|
||||||
|
- name: POSTGRES_USER
|
||||||
|
value: nereus
|
||||||
|
- name: POSTGRES_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: nereus-db
|
||||||
|
key: POSTGRES_PASSWORD
|
||||||
|
# Subdirectory, or initdb trips over lost+found on the volume.
|
||||||
|
- name: PGDATA
|
||||||
|
value: /var/lib/postgresql/data/pgdata
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
- name: runtime
|
||||||
|
mountPath: /var/run/postgresql
|
||||||
|
- name: tmp
|
||||||
|
mountPath: /tmp
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command: ["pg_isready", "-U", "nereus", "-d", "nereus"]
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 6
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command: ["pg_isready", "-U", "nereus", "-d", "nereus"]
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 15
|
||||||
|
timeoutSeconds: 5
|
||||||
|
failureThreshold: 3
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
memory: 1Gi
|
||||||
|
volumes:
|
||||||
|
- name: runtime
|
||||||
|
emptyDir: {}
|
||||||
|
- name: tmp
|
||||||
|
emptyDir:
|
||||||
|
sizeLimit: 64Mi
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: data
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
24
deploy/base/servicemonitor.yaml
Normal file
24
deploy/base/servicemonitor.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Prometheus Operator CRD, not core Kubernetes. Works because the k3d values
|
||||||
|
# set serviceMonitorSelectorNilUsesHelmValues: false.
|
||||||
|
#
|
||||||
|
# Matches both Services, so each pod is scraped once per Service. That's the
|
||||||
|
# point -- the `service` label is how the analysis isolates the preview.
|
||||||
|
apiVersion: monitoring.coreos.com/v1
|
||||||
|
kind: ServiceMonitor
|
||||||
|
metadata:
|
||||||
|
name: nereus-api
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus-api
|
||||||
|
app.kubernetes.io/part-of: nereus
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: nereus-api
|
||||||
|
namespaceSelector:
|
||||||
|
matchNames:
|
||||||
|
- nereus
|
||||||
|
endpoints:
|
||||||
|
- port: http
|
||||||
|
path: /metrics
|
||||||
|
interval: 15s
|
||||||
|
scrapeTimeout: 10s
|
||||||
9
deploy/observability/kustomization.yaml
Normal file
9
deploy/observability/kustomization.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Lands in `observability`, alongside kube-prometheus-stack.
|
||||||
|
# kubectl apply -k deploy/observability
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
namespace: observability
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- otel-collector.yaml
|
||||||
109
deploy/observability/otel-collector.yaml
Normal file
109
deploy/observability/otel-collector.yaml
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
# OTLP collector for the cluster. The API's OTEL_EXPORTER_OTLP_ENDPOINT
|
||||||
|
# points here.
|
||||||
|
#
|
||||||
|
# Traces only. The log pipeline in observability/otel-collector/config.yaml
|
||||||
|
# needs a DaemonSet with hostPath access to /var/log/pods plus RBAC, and Loki
|
||||||
|
# isn't in the cluster yet.
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: nereus-otel-collector
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus-otel-collector
|
||||||
|
data:
|
||||||
|
config.yaml: |
|
||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc:
|
||||||
|
endpoint: 0.0.0.0:4317
|
||||||
|
|
||||||
|
processors:
|
||||||
|
memory_limiter:
|
||||||
|
check_interval: 1s
|
||||||
|
limit_mib: 256
|
||||||
|
batch:
|
||||||
|
timeout: 5s
|
||||||
|
|
||||||
|
exporters:
|
||||||
|
otlp/tempo:
|
||||||
|
endpoint: tempo.observability.svc.cluster.local:4317
|
||||||
|
tls:
|
||||||
|
insecure: true
|
||||||
|
# Keeps traces visible in `kubectl logs` until Tempo is deployed.
|
||||||
|
debug:
|
||||||
|
verbosity: basic
|
||||||
|
|
||||||
|
service:
|
||||||
|
pipelines:
|
||||||
|
traces:
|
||||||
|
receivers: [otlp]
|
||||||
|
processors: [memory_limiter, batch]
|
||||||
|
exporters: [otlp/tempo, debug]
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: nereus-otel-collector
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus-otel-collector
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: nereus-otel-collector
|
||||||
|
ports:
|
||||||
|
- name: otlp-grpc
|
||||||
|
port: 4317
|
||||||
|
targetPort: otlp-grpc
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: nereus-otel-collector
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus-otel-collector
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: nereus-otel-collector
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus-otel-collector
|
||||||
|
annotations:
|
||||||
|
# Restarts the pod when the config changes; the collector doesn't reload.
|
||||||
|
checksum/config: placeholder
|
||||||
|
spec:
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 10001
|
||||||
|
runAsGroup: 10001
|
||||||
|
runAsNonRoot: true
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: otel-collector
|
||||||
|
image: otel/opentelemetry-collector-contrib:0.159.0
|
||||||
|
args: ["--config=/conf/config.yaml"]
|
||||||
|
ports:
|
||||||
|
- name: otlp-grpc
|
||||||
|
containerPort: 4317
|
||||||
|
volumeMounts:
|
||||||
|
- name: config
|
||||||
|
mountPath: /conf
|
||||||
|
readOnly: true
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
memory: 384Mi
|
||||||
|
volumes:
|
||||||
|
- name: config
|
||||||
|
configMap:
|
||||||
|
name: nereus-otel-collector
|
||||||
30
deploy/overlays/dev/kustomization.yaml
Normal file
30
deploy/overlays/dev/kustomization.yaml
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# dev -- the local k3d lab.
|
||||||
|
# kubectl apply -k deploy/overlays/dev
|
||||||
|
#
|
||||||
|
# Needs the images imported and the nereus-db Secret created first.
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
namespace: nereus
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- ../../base
|
||||||
|
- ../../rollouts
|
||||||
|
|
||||||
|
patches:
|
||||||
|
# One replica is easier to watch swap over, and enough on a laptop.
|
||||||
|
- target:
|
||||||
|
kind: Rollout
|
||||||
|
name: nereus-api
|
||||||
|
patch: |
|
||||||
|
- op: replace
|
||||||
|
path: /spec/replicas
|
||||||
|
value: 1
|
||||||
|
|
||||||
|
- target:
|
||||||
|
kind: StatefulSet
|
||||||
|
name: nereus-postgres
|
||||||
|
patch: |
|
||||||
|
- op: replace
|
||||||
|
path: /spec/volumeClaimTemplates/0/spec/resources/requests/storage
|
||||||
|
value: 2Gi
|
||||||
51
deploy/overlays/prod/kustomization.yaml
Normal file
51
deploy/overlays/prod/kustomization.yaml
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
# prod overlay -- the two Fedora nodes.
|
||||||
|
# kubectl apply -k deploy/overlays/prod
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
namespace: nereus
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- ../../base
|
||||||
|
- ../../rollouts
|
||||||
|
|
||||||
|
images:
|
||||||
|
- name: nereus-api
|
||||||
|
newName: git.fiwlabs.dev/fiwdev/nereus-api
|
||||||
|
newTag: 0.1.0
|
||||||
|
- name: nereus-loadgen
|
||||||
|
newName: git.fiwlabs.dev/fiwdev/nereus-loadgen
|
||||||
|
newTag: 0.1.0
|
||||||
|
|
||||||
|
patches:
|
||||||
|
# 3 replicas so a node can go down mid-rollout.
|
||||||
|
- target:
|
||||||
|
kind: Rollout
|
||||||
|
name: nereus-api
|
||||||
|
patch: |
|
||||||
|
- op: replace
|
||||||
|
path: /spec/replicas
|
||||||
|
value: 3
|
||||||
|
|
||||||
|
# Spread the API across both nodes.
|
||||||
|
- target:
|
||||||
|
kind: Rollout
|
||||||
|
name: nereus-api
|
||||||
|
patch: |
|
||||||
|
- op: add
|
||||||
|
path: /spec/template/spec/topologySpreadConstraints
|
||||||
|
value:
|
||||||
|
- maxSkew: 1
|
||||||
|
topologyKey: kubernetes.io/hostname
|
||||||
|
whenUnsatisfiable: ScheduleAnyway
|
||||||
|
labelSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: nereus-api
|
||||||
|
|
||||||
|
- target:
|
||||||
|
kind: ConfigMap
|
||||||
|
name: nereus-api-config
|
||||||
|
patch: |
|
||||||
|
- op: replace
|
||||||
|
path: /data/LOG_LEVEL
|
||||||
|
value: "warn"
|
||||||
36
deploy/rollouts/analysistemplate.yaml
Normal file
36
deploy/rollouts/analysistemplate.yaml
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# The real error-rate gate, replacing the vector(0.0) harness in
|
||||||
|
# scripts/k3d/analysis-harness/.
|
||||||
|
#
|
||||||
|
# Runs before promotion. Every sample has to pass; one failure aborts and the
|
||||||
|
# active Service never moves.
|
||||||
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: AnalysisTemplate
|
||||||
|
metadata:
|
||||||
|
name: nereus-api-error-rate
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus-api
|
||||||
|
app.kubernetes.io/part-of: nereus
|
||||||
|
spec:
|
||||||
|
args:
|
||||||
|
- name: service
|
||||||
|
metrics:
|
||||||
|
- name: error-rate
|
||||||
|
# Let the new pods take traffic first.
|
||||||
|
initialDelay: 30s
|
||||||
|
interval: 20s
|
||||||
|
count: 5
|
||||||
|
failureLimit: 0
|
||||||
|
# Empty result means no traffic yet -- not a failure.
|
||||||
|
successCondition: "len(result) == 0 || result[0] < 0.05"
|
||||||
|
provider:
|
||||||
|
prometheus:
|
||||||
|
address: http://kube-prometheus-stack-prometheus.observability.svc.cluster.local:9090
|
||||||
|
# 5xx as a fraction of all responses on the preview.
|
||||||
|
# `or vector(0)` keeps the division defined when there are no errors.
|
||||||
|
query: |
|
||||||
|
(
|
||||||
|
sum(rate(nereus_http_requests_total{service="{{args.service}}",status=~"5.."}[1m]))
|
||||||
|
or vector(0)
|
||||||
|
)
|
||||||
|
/
|
||||||
|
sum(rate(nereus_http_requests_total{service="{{args.service}}"}[1m]))
|
||||||
8
deploy/rollouts/kustomization.yaml
Normal file
8
deploy/rollouts/kustomization.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Argo Rollouts resources, kept separate from base/ so the plain-Kubernetes
|
||||||
|
# manifests stay applicable on a cluster without the Rollouts CRDs installed.
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- rollout.yaml
|
||||||
|
- analysistemplate.yaml
|
||||||
100
deploy/rollouts/rollout.yaml
Normal file
100
deploy/rollouts/rollout.yaml
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# The API, as a blue-green Rollout.
|
||||||
|
#
|
||||||
|
# Same shape as a Deployment, but the new version comes up alongside the old,
|
||||||
|
# gets checked against Prometheus, and only then takes over the active Service.
|
||||||
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: Rollout
|
||||||
|
metadata:
|
||||||
|
name: nereus-api
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus-api
|
||||||
|
app.kubernetes.io/part-of: nereus
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
revisionHistoryLimit: 3
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: nereus-api
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: nereus-api
|
||||||
|
app.kubernetes.io/part-of: nereus
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: nereus-registry
|
||||||
|
securityContext:
|
||||||
|
# distroless nonroot.
|
||||||
|
runAsUser: 65532
|
||||||
|
runAsGroup: 65532
|
||||||
|
runAsNonRoot: true
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: api
|
||||||
|
image: nereus-api:0.1.0
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 8080
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: nereus-api-config
|
||||||
|
env:
|
||||||
|
- name: DATABASE_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: nereus-db
|
||||||
|
key: DATABASE_URL
|
||||||
|
# /healthz ignores Postgres, so a database outage makes pods unready
|
||||||
|
# without restarting them.
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 3
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 2
|
||||||
|
failureThreshold: 3
|
||||||
|
# /readyz waits for migrations.
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /readyz
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 3
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 3
|
||||||
|
failureThreshold: 3
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
volumeMounts:
|
||||||
|
- name: tmp
|
||||||
|
mountPath: /tmp
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
memory: 256Mi
|
||||||
|
volumes:
|
||||||
|
- name: tmp
|
||||||
|
emptyDir:
|
||||||
|
medium: Memory
|
||||||
|
sizeLimit: 16Mi
|
||||||
|
strategy:
|
||||||
|
blueGreen:
|
||||||
|
activeService: nereus-api-active
|
||||||
|
previewService: nereus-api-preview
|
||||||
|
# Auto-promote only if the analysis passes; a failure aborts instead.
|
||||||
|
autoPromotionEnabled: true
|
||||||
|
# Keep the old ReplicaSet warm so an abort falls straight back.
|
||||||
|
scaleDownDelaySeconds: 30
|
||||||
|
prePromotionAnalysis:
|
||||||
|
templates:
|
||||||
|
- templateName: nereus-api-error-rate
|
||||||
|
args:
|
||||||
|
- name: service
|
||||||
|
value: nereus-api-preview
|
||||||
35
deploy/secrets/README.md
Normal file
35
deploy/secrets/README.md
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Secrets
|
||||||
|
|
||||||
|
`nereus-db` holds two keys, used by `postgres.yaml` and `rollout.yaml`:
|
||||||
|
|
||||||
|
- `POSTGRES_PASSWORD`
|
||||||
|
- `DATABASE_URL` — `postgres://nereus:<password>@nereus-postgres:5432/nereus?sslmode=disable`
|
||||||
|
|
||||||
|
It is not in `base/kustomization.yaml`. Plaintext never lands in the repo, and
|
||||||
|
gitleaks runs on every push.
|
||||||
|
|
||||||
|
## dev (k3d)
|
||||||
|
|
||||||
|
Create it directly, before `kubectl apply -k deploy/overlays/dev`:
|
||||||
|
|
||||||
|
```fish
|
||||||
|
set pw (openssl rand -hex 16)
|
||||||
|
kubectl create secret generic nereus-db -n nereus \
|
||||||
|
--from-literal=POSTGRES_PASSWORD=$pw \
|
||||||
|
--from-literal=DATABASE_URL="postgres://nereus:$pw@nereus-postgres:5432/nereus?sslmode=disable"
|
||||||
|
```
|
||||||
|
|
||||||
|
Rotating means deleting the secret, the StatefulSet's PVC, and restarting —
|
||||||
|
Postgres only reads `POSTGRES_PASSWORD` when it initialises the data directory.
|
||||||
|
|
||||||
|
## prod (sealed)
|
||||||
|
|
||||||
|
Write the plaintext to `nereus-db.plain.yaml` (gitignored), then:
|
||||||
|
|
||||||
|
```fish
|
||||||
|
kubeseal --format yaml < nereus-db.plain.yaml > nereus-db-sealed.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit only `nereus-db-sealed.yaml` and add it to the prod overlay. The
|
||||||
|
controller isn't installed yet — `kubectl get crd | grep sealed` comes back
|
||||||
|
empty.
|
||||||
29
docs/ci-cd.md
Normal file
29
docs/ci-cd.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Forgejo CI/CD
|
||||||
|
|
||||||
|
`.forgejo/workflows/pipeline.yml` runs verification for pushes and pull
|
||||||
|
requests. A push to `main` also publishes immutable commit-SHA tags for the API,
|
||||||
|
load generator, and web images, then deploys the API and load generator through
|
||||||
|
the production overlay and Argo Rollouts.
|
||||||
|
|
||||||
|
Two load-generator Deployments keep traffic on both the active and preview
|
||||||
|
Services. Preview traffic is required for the pre-promotion analysis to measure
|
||||||
|
the candidate revision instead of treating absent samples as success.
|
||||||
|
|
||||||
|
The repository needs a dedicated Forgejo runner labelled `docker`. It must have
|
||||||
|
Docker with Compose support, outbound access to the configured registries, and
|
||||||
|
network access to the Kubernetes API endpoint contained in the kubeconfig.
|
||||||
|
Keep this runner private to trusted repositories because deployment jobs can
|
||||||
|
access the Docker socket and production credentials.
|
||||||
|
|
||||||
|
Configure these encrypted repository or organization Actions secrets in the
|
||||||
|
Forgejo UI:
|
||||||
|
|
||||||
|
- `REGISTRY_USERNAME`: account allowed to push the three Nereus packages.
|
||||||
|
- `REGISTRY_PASSWORD`: package-scoped token for that account.
|
||||||
|
- `KUBECONFIG_B64`: base64 encoding of a least-privilege deployment kubeconfig.
|
||||||
|
|
||||||
|
The workflow never prints these values. It writes the kubeconfig to the
|
||||||
|
ephemeral workspace with mode `0600`, removes it in an `always()` step, and
|
||||||
|
creates the Kubernetes registry pull secret through a pipe so its generated
|
||||||
|
manifest is not logged or committed. The pre-existing `nereus-db` Secret is
|
||||||
|
required and is only checked for presence.
|
||||||
68
docs/evidence.md
Normal file
68
docs/evidence.md
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Final project verification evidence
|
||||||
|
|
||||||
|
Captured on 2026-08-24 from the disposable QEMU and k3d integration labs. The
|
||||||
|
commands below expose no credentials and can be rerun before the presentation
|
||||||
|
to refresh screenshots or terminal recordings.
|
||||||
|
|
||||||
|
## Two-node Fedora cluster
|
||||||
|
|
||||||
|
`scripts/provision/lab.sh check` passed after provisioning, host reboot, node 2
|
||||||
|
disconnect/reconnect, and complete deletion/recreation of node 2. At the final
|
||||||
|
check both Fedora 44 machines ran k3s `v1.33.4+k3s1` and reported `Ready`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
nereus-node1 Ready control-plane,master 192.168.122.10
|
||||||
|
nereus-node2 Ready <none> 192.168.122.11
|
||||||
|
```
|
||||||
|
|
||||||
|
The node-replacement test found and fixed two recovery requirements: remove the
|
||||||
|
replacement address from the private SSH known-hosts file, and delete the old
|
||||||
|
Kubernetes node identity before the new agent joins. The PostgreSQL local-path
|
||||||
|
volume was correctly treated as lost with the deleted VM, recreated empty, and
|
||||||
|
reseeded by the load generator.
|
||||||
|
|
||||||
|
## Live application
|
||||||
|
|
||||||
|
The final namespace check showed three ready API pods, one ready load-generator
|
||||||
|
pod, and one ready PostgreSQL pod. The same-origin Mini PC route returned:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET / 200
|
||||||
|
GET /healthz 200
|
||||||
|
GET /readyz 200
|
||||||
|
X-Nereus-Version 0.1.0
|
||||||
|
GET /api/v1/buoys 42 records
|
||||||
|
```
|
||||||
|
|
||||||
|
The static dashboard stayed available while the cluster API was unavailable
|
||||||
|
during node recovery, then recovered without a page reload.
|
||||||
|
|
||||||
|
## Container evidence
|
||||||
|
|
||||||
|
The locally inspected `nereus-api:0.1.0` image is 7,815,732 bytes and declares
|
||||||
|
numeric non-root user `65532`. The container verification also passed with a
|
||||||
|
read-only root filesystem.
|
||||||
|
|
||||||
|
## Rollback evidence
|
||||||
|
|
||||||
|
The local k3d lab was destroyed and recreated from scratch. Re-running
|
||||||
|
`scripts/k3d/lab.sh up` reconciled the running cluster successfully. The proof
|
||||||
|
produced these AnalysisRun results:
|
||||||
|
|
||||||
|
```text
|
||||||
|
probe-746bbb94df-2-pre Successful vector(0.0)
|
||||||
|
probe-556d5b659b-3-pre Failed vector(1.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
After the failed analysis, the active Service selector remained on the healthy
|
||||||
|
revision `746bbb94df`.
|
||||||
|
|
||||||
|
## Presentation commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/provision/lab.sh check
|
||||||
|
curl -i https://nereus.fiwlabs.dev/healthz
|
||||||
|
curl -i https://nereus.fiwlabs.dev/readyz
|
||||||
|
curl -fsS https://nereus.fiwlabs.dev/api/v1/buoys | jq length
|
||||||
|
scripts/k3d/lab.sh prove
|
||||||
|
```
|
||||||
31
docs/mini-pc.md
Normal file
31
docs/mini-pc.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Mini PC web stack
|
||||||
|
|
||||||
|
The Mini PC stack joins the host's existing Traefik `proxy` network and proxies
|
||||||
|
the frozen API paths through nginx. It does not run its own Traefik instance,
|
||||||
|
PostgreSQL, the API, or the load generator.
|
||||||
|
|
||||||
|
Export the following values in the operator shell or service manager:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WEB_HOST public dashboard hostname; defaults to nereus.fiwlabs.dev
|
||||||
|
CLUSTER_API_UPSTREAM cluster address and port, without a URL scheme
|
||||||
|
WEB_ASSET_PATH asset path relative to the build context; defaults to apps/web
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not put these values in a repository `.env` file. Start the isolated stack
|
||||||
|
from the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose -f build/compose.mini.yaml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The Mini PC keeps the static assets directly in `~/Server/web/nereus`. Copy the
|
||||||
|
three build files into its `build/` directory, set `WEB_ASSET_PATH=.` when
|
||||||
|
building there, and join the existing external `proxy` network. The existing
|
||||||
|
Traefik instance supplies the `websecure` entrypoint and `porkbun` certificate
|
||||||
|
resolver; do not start a second proxy on ports 80 and 443.
|
||||||
|
|
||||||
|
When the cluster is unavailable, nginx continues serving the dashboard and
|
||||||
|
returns a gateway failure only for `/api/v1/*`, `/healthz`, and `/readyz`. The
|
||||||
|
browser keeps polling those relative paths and recovers when the cluster is
|
||||||
|
reachable again.
|
||||||
242
docs/roadmap.md
Normal file
242
docs/roadmap.md
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
# Nereus delivery roadmap
|
||||||
|
|
||||||
|
Updated: 2026-08-26
|
||||||
|
|
||||||
|
`[x]` means locally verified. `[~]` means implemented but awaiting a real
|
||||||
|
integration environment. `[ ]` means not implemented. `[H]` is human-owned and
|
||||||
|
must not be edited by agents.
|
||||||
|
|
||||||
|
Update this file in the same change that completes or materially changes a
|
||||||
|
task. Mark a task `[x]` only after its stated verification passes.
|
||||||
|
|
||||||
|
## API service
|
||||||
|
|
||||||
|
- [x] Create the Go 1.26 module and dependency lock file.
|
||||||
|
- [x] Create the idempotent `buoys` table migration.
|
||||||
|
- [x] Create the idempotent `readings` table migration.
|
||||||
|
- [x] Create `idx_readings_buoy_time` idempotently.
|
||||||
|
- [x] Parse `PORT`, `CHAOS_ERROR_RATE`, and `LOG_LEVEL`.
|
||||||
|
- [x] Preserve the build-injected `APP_VERSION` value.
|
||||||
|
- [x] Require `DATABASE_URL` without logging it.
|
||||||
|
- [x] Start HTTP without waiting for PostgreSQL.
|
||||||
|
- [x] Retry migrations while PostgreSQL is unavailable.
|
||||||
|
- [x] Keep `/healthz` independent of PostgreSQL.
|
||||||
|
- [x] Require a reachable, migrated database for `/readyz`.
|
||||||
|
- [x] Implement `GET /api/v1/buoys`.
|
||||||
|
- [x] Implement `POST /api/v1/buoys`.
|
||||||
|
- [x] Implement `GET /api/v1/buoys/{id}`.
|
||||||
|
- [x] Implement `DELETE /api/v1/buoys/{id}`.
|
||||||
|
- [x] Implement filtered, paginated `GET /api/v1/readings`.
|
||||||
|
- [x] Implement `POST /api/v1/readings`.
|
||||||
|
- [x] Implement the real grouped aggregate query.
|
||||||
|
- [x] Bound JSON request bodies and return JSON errors.
|
||||||
|
- [x] Restrict chaos injection to `/api/v1/*`.
|
||||||
|
- [x] Expose all five required Prometheus metrics.
|
||||||
|
- [x] Use chi route templates for the HTTP `path` label.
|
||||||
|
- [x] Enable OTLP/gRPC tracing only when configured.
|
||||||
|
- [x] Create HTTP spans and child database spans.
|
||||||
|
- [x] Emit structured request logs containing `trace_id`.
|
||||||
|
- [x] Add table-driven handler tests without a live database.
|
||||||
|
- [x] Test chaos and unaffected health behavior.
|
||||||
|
- [x] Add a `testing.Short()`-guarded PostgreSQL integration test.
|
||||||
|
- [x] Pass `go test -short ./...`.
|
||||||
|
- [x] Pass `go vet ./...`.
|
||||||
|
- [x] Build with `CGO_ENABLED=0`.
|
||||||
|
- [x] Run the integration test against PostgreSQL 17.
|
||||||
|
- [x] Test database failure behavior on every data endpoint.
|
||||||
|
- [x] Assert exact metric names and route-template labels in tests.
|
||||||
|
- [x] Test malformed and oversized JSON request bodies.
|
||||||
|
- [x] Run `golangci-lint run` with the CI-selected version.
|
||||||
|
|
||||||
|
## Load generator
|
||||||
|
|
||||||
|
- [x] Create the standalone Go 1.26 module.
|
||||||
|
- [x] Require `TARGET_URL` and default `RPS` to 5.
|
||||||
|
- [x] Reuse existing buoys or seed 42 validated offshore buoys.
|
||||||
|
- [x] Generate the 60/25/15 request mix.
|
||||||
|
- [x] Generate plausible synthetic reading values.
|
||||||
|
- [x] Count HTTP statuses and network errors.
|
||||||
|
- [x] Log a JSON status summary every ten seconds.
|
||||||
|
- [x] Continue traffic while requests and seeding fail.
|
||||||
|
- [x] Retry seeding without blocking read traffic.
|
||||||
|
- [x] Handle SIGTERM and SIGINT.
|
||||||
|
- [x] Keep the implementation below 200 lines.
|
||||||
|
- [x] Pass `go vet` and a static build.
|
||||||
|
- [x] Test request weighting deterministically.
|
||||||
|
- [x] Test seeding against an empty `httptest` API.
|
||||||
|
- [x] Test continued traffic through repeated HTTP 500 responses.
|
||||||
|
- [x] Run against the real API for at least five minutes.
|
||||||
|
- [x] Confirm Prometheus receives continuous request samples.
|
||||||
|
|
||||||
|
## Container build and local Compose
|
||||||
|
|
||||||
|
- [x] Create the API builder stage from `golang:1.26-alpine`.
|
||||||
|
- [x] Cache dependencies before copying API source.
|
||||||
|
- [x] Build with `CGO_ENABLED=0` and `-ldflags "-s -w"`.
|
||||||
|
- [x] Inject `APP_VERSION` with `-X`.
|
||||||
|
- [x] Copy only the API binary into `gcr.io/distroless/static:nonroot`.
|
||||||
|
- [x] Create the load-generator multi-stage image.
|
||||||
|
- [x] Use `gcr.io/distroless/static:nonroot` for the load generator.
|
||||||
|
- [x] Add the required `.dockerignore` exclusions.
|
||||||
|
- [x] Confirm both images run non-root and read-only.
|
||||||
|
- [x] Confirm the API image is below 25 MB (7,815,877 bytes locally).
|
||||||
|
- [x] Add API, PostgreSQL 17, and OTEL Collector to local Compose.
|
||||||
|
- [x] Add the PostgreSQL healthcheck.
|
||||||
|
- [x] Make the API wait on healthy PostgreSQL in Compose.
|
||||||
|
- [x] Verify `docker compose up` reaches API readiness.
|
||||||
|
- [x] Keep Mini PC and local-development Compose files separate.
|
||||||
|
|
||||||
|
## Static web dashboard
|
||||||
|
|
||||||
|
- [x] Create the no-build static HTML entry point.
|
||||||
|
- [x] Create the dark teal/navy glass visual system.
|
||||||
|
- [x] Replace decorative background waves with an operational chart grid.
|
||||||
|
- [x] Keep version, health, and error panels visible together.
|
||||||
|
- [x] Show the build-injected API version from the existing health response header.
|
||||||
|
- [x] Poll `/healthz` every two seconds.
|
||||||
|
- [x] Poll `/readyz` every two seconds.
|
||||||
|
- [x] Show each health indicator's last transition timestamp.
|
||||||
|
- [x] Count non-2xx `/api/v1/*` browser responses.
|
||||||
|
- [x] Calculate the rolling 60-second HTTP error rate.
|
||||||
|
- [x] Distinguish network-offline events from HTTP errors.
|
||||||
|
- [x] Preserve and grey the last known telemetry values offline.
|
||||||
|
- [x] Recover automatically when the API returns.
|
||||||
|
- [x] Add a fully local Natural Earth buoy map and pulsing markers.
|
||||||
|
- [x] Add animated reading charts.
|
||||||
|
- [x] Keep all API calls same-origin and relative.
|
||||||
|
- [x] Remove all frontend runtime dependencies on external hosts.
|
||||||
|
- [x] Verify all panels fit at 1280×720 without page scrolling.
|
||||||
|
- [x] Create the unprivileged nginx image.
|
||||||
|
- [x] Create the separate Mini PC Traefik Compose file.
|
||||||
|
- [x] Test the finished offline/degraded rendering without API connectivity.
|
||||||
|
- [x] Test the rendered dashboard while the API returns HTTP 500 responses.
|
||||||
|
|
||||||
|
## Observability
|
||||||
|
|
||||||
|
- [x] Receive OTLP/gRPC traces in the Collector.
|
||||||
|
- [x] Export traces from the Collector to Tempo.
|
||||||
|
- [~] Ship structured Kubernetes pod logs to Loki with the cluster Collector.
|
||||||
|
- [x] Start Loki locally and verify its Grafana data source readiness.
|
||||||
|
- [x] Configure Prometheus to scrape `/metrics`.
|
||||||
|
- [x] Add request-rate, error-rate, and latency panels.
|
||||||
|
- [x] Add database-operation latency panels.
|
||||||
|
- [x] Add readings-ingested and active-buoy panels.
|
||||||
|
- [x] Add a deployed-version dashboard variable.
|
||||||
|
- [~] Correlate logs and traces using `trace_id` after cluster log ingestion exists.
|
||||||
|
- [x] Add API error-rate, latency, readiness, and scrape alerts.
|
||||||
|
- [x] Load the dashboard and evaluate all six PromQL queries.
|
||||||
|
- [x] Trigger chaos and distinguish the failing version in Grafana.
|
||||||
|
|
||||||
|
## Fedora 44 host provisioning
|
||||||
|
|
||||||
|
- [x] Separate node 1 and node 2 inventory groups.
|
||||||
|
- [x] Pin the project k3s version.
|
||||||
|
- [x] Reject managed hosts that are not Fedora 44.
|
||||||
|
- [x] Install common host dependencies.
|
||||||
|
- [x] Enable firewalld.
|
||||||
|
- [x] Trust the configured pod and service CIDRs.
|
||||||
|
- [x] Open VXLAN port 8472/UDP between nodes.
|
||||||
|
- [x] Open kubelet port 10250/TCP between nodes.
|
||||||
|
- [x] Open API server port 6443/TCP on node 1.
|
||||||
|
- [x] Install node 1 as the k3s server.
|
||||||
|
- [x] Install node 2 as a k3s agent.
|
||||||
|
- [x] Keep the cluster token out of repository files and output.
|
||||||
|
- [x] Enable both k3s systemd services.
|
||||||
|
- [x] Wait for every Kubernetes node to become Ready.
|
||||||
|
- [x] Add the equivalent manual configuration checklist.
|
||||||
|
- [x] Document Terraform-to-Ansible inventory handoff.
|
||||||
|
- [x] Keep libvirt addresses, bridge rules, and VM network profiles out of the reusable Ansible roles.
|
||||||
|
- [x] Run the playbook twice on two clean Fedora 44 QEMU machines.
|
||||||
|
- [x] Confirm the second run reports no unintended changes (`changed=0`).
|
||||||
|
- [x] Restrict firewall sources to actual node/operator networks.
|
||||||
|
- [x] Confirm SELinux remains enforcing on both QEMU machines.
|
||||||
|
- [x] Reboot both QEMU machines and confirm automatic recovery.
|
||||||
|
- [x] Disconnect and reconnect node 2, then confirm it rejoins.
|
||||||
|
- [x] Record tested minimum CPU, memory, and disk requirements.
|
||||||
|
|
||||||
|
## One-command QEMU lab
|
||||||
|
|
||||||
|
- [x] Check QEMU, libvirt, cloud-utils, Ansible, SSH, ping, and KVM.
|
||||||
|
- [x] Install missing dependencies on a Fedora workstation.
|
||||||
|
- [x] Enable libvirt and its default network.
|
||||||
|
- [x] Discover and cache the Fedora 44 x86_64 cloud image.
|
||||||
|
- [x] Keep SSH and VM state outside the repository.
|
||||||
|
- [x] Generate cloud-init media for both nodes.
|
||||||
|
- [x] Create reusable copy-on-write disks.
|
||||||
|
- [x] Create each VM with two vCPUs and 3 GiB RAM.
|
||||||
|
- [x] Assign deterministic addresses to both local VMs.
|
||||||
|
- [x] Wait for ICMP ping and SSH on both nodes.
|
||||||
|
- [x] Generate the Ansible inventory automatically.
|
||||||
|
- [x] Configure both nodes automatically.
|
||||||
|
- [x] Check Ansible reachability, k3s files, and binaries.
|
||||||
|
- [x] Check firewalld, k3s services, ports 6443 and 10250.
|
||||||
|
- [x] Check Kubernetes node readiness.
|
||||||
|
- [x] Add `check`, `stop`, and explicit `destroy` actions.
|
||||||
|
- [x] Execute `lab.sh up` with working `/dev/kvm` and pass the full verification.
|
||||||
|
- [x] Reboot both local VMs and pass the bounded post-reboot service and node checks.
|
||||||
|
- [x] Execute `lab.sh check` after a workstation reboot.
|
||||||
|
- [x] Test recovery from an interrupted image download and validate the resumed QCOW2.
|
||||||
|
- [x] Test recovery after node 2 is deleted manually and rejoin it with a fresh identity.
|
||||||
|
- [ ] Test on a workstation with no dependencies installed.
|
||||||
|
- [x] Decide whether an aarch64 image path is required; keep the lab x86_64-only.
|
||||||
|
- [ ] After final sign-off, destroy the QEMU lab and remove packages installed only for it with Pacman's dependency-aware cleanup.
|
||||||
|
|
||||||
|
## Local k3d rollback mechanism
|
||||||
|
|
||||||
|
- [x] Define one local server and two local agent nodes.
|
||||||
|
- [x] Map Traefik to host ports 8080 and 8443.
|
||||||
|
- [x] Add lean kube-prometheus-stack values.
|
||||||
|
- [x] Add the synthetic Argo Rollouts analysis harness.
|
||||||
|
- [x] Add an idempotent `up/check/destroy` command and verify fresh creation and reconciliation.
|
||||||
|
- [x] Install Argo Rollouts.
|
||||||
|
- [x] Install kube-prometheus-stack with the local values.
|
||||||
|
- [x] Apply the synthetic harness.
|
||||||
|
- [x] Prove `vector(0.0)` permits promotion.
|
||||||
|
- [x] Change the test value to `vector(1.0)`.
|
||||||
|
- [x] Prove failed analysis aborts promotion with `vector(0.9)`.
|
||||||
|
- [x] Tear down and recreate the test from scratch.
|
||||||
|
|
||||||
|
## Human-owned deployment and infrastructure
|
||||||
|
|
||||||
|
- [H] Define infrastructure modules under `terraform/`.
|
||||||
|
- [H] Export provisioned addresses for Ansible inventory.
|
||||||
|
- [H] Create Kubernetes base resources and overlays. Agent-authored 2026-08-21 at the owner's request; builds and server dry-runs pass, not applied.
|
||||||
|
- [H] Create API and load-generator workloads. Agent-authored 2026-08-21; not applied.
|
||||||
|
- [H] Create active and preview Services. Agent-authored 2026-08-21; not applied.
|
||||||
|
- [H] Create the Prometheus ServiceMonitor. Agent-authored 2026-08-21; not applied, scrape not confirmed.
|
||||||
|
- [H] Create the real error-rate AnalysisTemplate. Agent-authored 2026-08-21; PromQL never evaluated against real series.
|
||||||
|
- [H] Configure blue-green promotion and automated rollback. Agent-authored 2026-08-21; promotion and abort paths untested with the real API.
|
||||||
|
- [H] Maintain Forgejo Actions and GitHub mirror workflows.
|
||||||
|
- [H] Create sealed secrets from off-repository plaintext inputs.
|
||||||
|
|
||||||
|
## Forgejo CI/CD
|
||||||
|
|
||||||
|
- [x] Run API and load-generator race tests, vet, and pinned lint in containers.
|
||||||
|
- [x] Scan committed history and the working tree for secrets.
|
||||||
|
- [x] Scan release configuration and images for high or critical findings.
|
||||||
|
- [x] Build immutable commit-SHA API, load-generator, and web image tags.
|
||||||
|
- [x] Keep active and preview traffic flowing during rollout analysis.
|
||||||
|
- [~] Run the Forgejo workflow after a private `docker` runner and encrypted secrets are configured.
|
||||||
|
- [~] Publish images and execute the first automated production rollout.
|
||||||
|
|
||||||
|
## End-to-end acceptance
|
||||||
|
|
||||||
|
- [ ] Provision two clean Fedora hosts.
|
||||||
|
- [ ] Reboot both hosts before platform installation.
|
||||||
|
- [x] Install the cluster platform components.
|
||||||
|
- [x] Deploy PostgreSQL and wait for health.
|
||||||
|
- [x] Verify API liveness before database readiness.
|
||||||
|
- [x] Verify readiness after migrations complete.
|
||||||
|
- [x] Deploy the load generator and observe continuous samples.
|
||||||
|
- [x] Send one request to every API endpoint in the local Compose stack.
|
||||||
|
- [x] Verify every required metric and exact label name locally.
|
||||||
|
- [x] Verify an HTTP trace contains a child database span.
|
||||||
|
- [x] Correlate one stdout request log and Tempo trace by `trace_id`.
|
||||||
|
- [x] Promote a healthy preview through analysis.
|
||||||
|
- [x] Deploy a preview with a nonzero chaos rate.
|
||||||
|
- [x] Observe preview errors in Prometheus.
|
||||||
|
- [x] Confirm analysis aborts the unhealthy promotion.
|
||||||
|
- [x] Confirm the active Service remains on the healthy version.
|
||||||
|
- [x] Confirm load generation continues throughout failure.
|
||||||
|
- [x] Confirm the Mini PC page remains usable with the cluster off.
|
||||||
|
- [x] Capture reproducible, non-secret evidence needed for the final project report.
|
||||||
23
observability/README.md
Normal file
23
observability/README.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Nereus observability
|
||||||
|
|
||||||
|
Start the application and its local observability overlay with one command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f compose.yaml -f observability/compose.yaml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The local endpoints are:
|
||||||
|
|
||||||
|
- Grafana: `http://127.0.0.1:13000`
|
||||||
|
- Prometheus: `http://127.0.0.1:19090`
|
||||||
|
- API: `http://127.0.0.1:18080`
|
||||||
|
|
||||||
|
Grafana uses anonymous administrator access only in this local overlay. No
|
||||||
|
password or credential is stored in the repository. Stop the complete stack
|
||||||
|
with the same two Compose files and `down`.
|
||||||
|
|
||||||
|
`otel-collector/local.yaml` handles local API traces. `otel-collector/config.yaml`
|
||||||
|
is the cluster configuration: it receives traces and tails only pod logs under
|
||||||
|
the `nereus` namespace path, then exports traces to Tempo and logs to Loki. Log
|
||||||
|
collection and trace-to-log links therefore require the real Kubernetes
|
||||||
|
environment and are not simulated in local Compose.
|
||||||
46
observability/alerts/nereus.yaml
Normal file
46
observability/alerts/nereus.yaml
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
groups:
|
||||||
|
- name: nereus-api
|
||||||
|
rules:
|
||||||
|
- alert: NereusAPIHighErrorRate
|
||||||
|
expr: |
|
||||||
|
sum(rate(nereus_http_requests_total{path=~"/api/v1/.*",status=~"5.."}[2m]))
|
||||||
|
/
|
||||||
|
clamp_min(sum(rate(nereus_http_requests_total{path=~"/api/v1/.*"}[2m])), 0.001)
|
||||||
|
> 0.05
|
||||||
|
for: 1m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: Nereus API error rate exceeds 5 percent
|
||||||
|
description: The API has returned more than 5 percent HTTP 5xx responses for one minute.
|
||||||
|
|
||||||
|
- alert: NereusAPIHighLatency
|
||||||
|
expr: |
|
||||||
|
histogram_quantile(
|
||||||
|
0.95,
|
||||||
|
sum by (le) (rate(nereus_http_request_duration_seconds_bucket{path=~"/api/v1/.*"}[5m]))
|
||||||
|
) > 1
|
||||||
|
for: 2m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: Nereus API p95 latency exceeds one second
|
||||||
|
description: The API p95 request duration has exceeded one second for two minutes.
|
||||||
|
|
||||||
|
- alert: NereusAPIReadinessFailing
|
||||||
|
expr: sum(rate(nereus_http_requests_total{path="/readyz",status="503"}[2m])) > 0
|
||||||
|
for: 1m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: Nereus API cannot reach its database
|
||||||
|
description: Readiness requests have returned HTTP 503 for one minute.
|
||||||
|
|
||||||
|
- alert: NereusAPIDown
|
||||||
|
expr: up{job="nereus-api"} == 0
|
||||||
|
for: 1m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: Prometheus cannot scrape the Nereus API
|
||||||
|
description: The Nereus API metrics endpoint has been unreachable for one minute.
|
||||||
57
observability/compose.yaml
Normal file
57
observability/compose.yaml
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
services:
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v3.14.0
|
||||||
|
command:
|
||||||
|
- --config.file=/etc/prometheus/prometheus.yaml
|
||||||
|
- --storage.tsdb.path=/prometheus
|
||||||
|
- --storage.tsdb.retention.time=6h
|
||||||
|
ports:
|
||||||
|
- "${PROMETHEUS_PORT:-19090}:9090"
|
||||||
|
volumes:
|
||||||
|
- ./observability/prometheus/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro
|
||||||
|
- ./observability/alerts:/etc/prometheus/rules:ro
|
||||||
|
- prometheus-data:/prometheus
|
||||||
|
|
||||||
|
tempo:
|
||||||
|
image: grafana/tempo:3.0.3
|
||||||
|
command: ["-config.file=/etc/tempo.yaml"]
|
||||||
|
volumes:
|
||||||
|
- ./observability/tempo/tempo.yaml:/etc/tempo.yaml:ro
|
||||||
|
tmpfs:
|
||||||
|
- /var/tempo:uid=10001,gid=10001,mode=0755
|
||||||
|
|
||||||
|
loki:
|
||||||
|
image: grafana/loki:3.7.6
|
||||||
|
command: ["-config.file=/etc/loki/local-config.yaml"]
|
||||||
|
volumes:
|
||||||
|
- ./observability/loki/loki.yaml:/etc/loki/local-config.yaml:ro
|
||||||
|
tmpfs:
|
||||||
|
- /var/loki:uid=10001,gid=10001,mode=0755
|
||||||
|
|
||||||
|
otel-collector:
|
||||||
|
volumes:
|
||||||
|
- ./observability/otel-collector/local.yaml:/etc/otelcol-contrib/config.yaml:ro
|
||||||
|
depends_on:
|
||||||
|
- tempo
|
||||||
|
- loki
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:13.2.0
|
||||||
|
environment:
|
||||||
|
GF_AUTH_ANONYMOUS_ENABLED: "true"
|
||||||
|
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
|
||||||
|
GF_AUTH_DISABLE_LOGIN_FORM: "true"
|
||||||
|
ports:
|
||||||
|
- "${GRAFANA_PORT:-13000}:3000"
|
||||||
|
volumes:
|
||||||
|
- ./observability/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- ./observability/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||||
|
- grafana-data:/var/lib/grafana
|
||||||
|
depends_on:
|
||||||
|
- prometheus
|
||||||
|
- tempo
|
||||||
|
- loki
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
prometheus-data:
|
||||||
|
grafana-data:
|
||||||
92
observability/grafana/dashboards/nereus-api.json
Normal file
92
observability/grafana/dashboards/nereus-api.json
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
{
|
||||||
|
"annotations": {"list": []},
|
||||||
|
"editable": false,
|
||||||
|
"graphTooltip": 1,
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "reqps"}},
|
||||||
|
"gridPos": {"h": 8, "w": 8, "x": 0, "y": 0},
|
||||||
|
"id": 1,
|
||||||
|
"targets": [{"expr": "sum by (version) (rate(nereus_http_requests_total{path=~\"/api/v1/.*\",version=~\"$version\"}[1m]))", "legendFormat": "{{version}}", "refId": "A"}],
|
||||||
|
"title": "API request rate",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "percentunit", "min": 0, "max": 1}},
|
||||||
|
"gridPos": {"h": 8, "w": 8, "x": 8, "y": 0},
|
||||||
|
"id": 2,
|
||||||
|
"targets": [{"expr": "sum by (version) (rate(nereus_http_requests_total{path=~\"/api/v1/.*\",status=~\"5..\",version=~\"$version\"}[1m])) / clamp_min(sum by (version) (rate(nereus_http_requests_total{path=~\"/api/v1/.*\",version=~\"$version\"}[1m])), 0.001)", "legendFormat": "{{version}}", "refId": "A"}],
|
||||||
|
"title": "API error rate",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "s"}},
|
||||||
|
"gridPos": {"h": 8, "w": 8, "x": 16, "y": 0},
|
||||||
|
"id": 3,
|
||||||
|
"targets": [{"expr": "histogram_quantile(0.95, sum by (le, version) (rate(nereus_http_request_duration_seconds_bucket{path=~\"/api/v1/.*\",version=~\"$version\"}[5m])))", "legendFormat": "p95 {{version}}", "refId": "A"}],
|
||||||
|
"title": "API p95 latency",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "s"}},
|
||||||
|
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
|
||||||
|
"id": 4,
|
||||||
|
"targets": [{"expr": "histogram_quantile(0.95, sum by (le, operation) (rate(nereus_db_query_duration_seconds_bucket[5m])))", "legendFormat": "{{operation}}", "refId": "A"}],
|
||||||
|
"title": "Database p95 latency",
|
||||||
|
"type": "timeseries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||||
|
"fieldConfig": {"defaults": {"unit": "ops"}},
|
||||||
|
"gridPos": {"h": 8, "w": 6, "x": 12, "y": 8},
|
||||||
|
"id": 5,
|
||||||
|
"targets": [{"expr": "sum(rate(nereus_readings_ingested_total[1m]))", "refId": "A"}],
|
||||||
|
"title": "Readings ingested",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||||
|
"gridPos": {"h": 8, "w": 6, "x": 18, "y": 8},
|
||||||
|
"id": 6,
|
||||||
|
"targets": [{"expr": "nereus_buoys_active", "refId": "A"}],
|
||||||
|
"title": "Active buoys",
|
||||||
|
"type": "stat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"datasource": {"type": "loki", "uid": "loki"},
|
||||||
|
"gridPos": {"h": 9, "w": 24, "x": 0, "y": 16},
|
||||||
|
"id": 7,
|
||||||
|
"targets": [{"expr": "{k8s_namespace_name=\"nereus\"} | json", "refId": "A"}],
|
||||||
|
"title": "Correlated API logs",
|
||||||
|
"type": "logs"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"refresh": "5s",
|
||||||
|
"schemaVersion": 42,
|
||||||
|
"tags": ["nereus"],
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"allValue": ".*",
|
||||||
|
"current": {"text": "All", "value": "$__all"},
|
||||||
|
"datasource": {"type": "prometheus", "uid": "prometheus"},
|
||||||
|
"definition": "label_values(nereus_http_requests_total, version)",
|
||||||
|
"includeAll": true,
|
||||||
|
"label": "Version",
|
||||||
|
"name": "version",
|
||||||
|
"query": {"query": "label_values(nereus_http_requests_total, version)", "refId": "StandardVariableQuery"},
|
||||||
|
"refresh": 1,
|
||||||
|
"type": "query"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": {"from": "now-30m", "to": "now"},
|
||||||
|
"timezone": "browser",
|
||||||
|
"title": "Nereus API",
|
||||||
|
"uid": "nereus-api",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: Nereus
|
||||||
|
folder: Nereus
|
||||||
|
type: file
|
||||||
|
disableDeletion: true
|
||||||
|
editable: false
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
uid: prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: false
|
||||||
|
jsonData:
|
||||||
|
timeInterval: 5s
|
||||||
|
|
||||||
|
- name: Loki
|
||||||
|
uid: loki
|
||||||
|
type: loki
|
||||||
|
access: proxy
|
||||||
|
url: http://loki:3100
|
||||||
|
editable: false
|
||||||
|
jsonData:
|
||||||
|
derivedFields:
|
||||||
|
- name: TraceID
|
||||||
|
matcherRegex: '"trace_id":"([a-f0-9]{32})"'
|
||||||
|
datasourceUid: tempo
|
||||||
|
url: '${__value.raw}'
|
||||||
|
|
||||||
|
- name: Tempo
|
||||||
|
uid: tempo
|
||||||
|
type: tempo
|
||||||
|
access: proxy
|
||||||
|
url: http://tempo:3200
|
||||||
|
editable: false
|
||||||
|
jsonData:
|
||||||
|
tracesToLogsV2:
|
||||||
|
datasourceUid: loki
|
||||||
|
spanStartTimeShift: -1m
|
||||||
|
spanEndTimeShift: 1m
|
||||||
|
tags:
|
||||||
|
- key: service.name
|
||||||
|
value: service_name
|
||||||
|
filterByTraceID: true
|
||||||
28
observability/loki/loki.yaml
Normal file
28
observability/loki/loki.yaml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
auth_enabled: false
|
||||||
|
|
||||||
|
server:
|
||||||
|
http_listen_port: 3100
|
||||||
|
|
||||||
|
common:
|
||||||
|
path_prefix: /var/loki
|
||||||
|
replication_factor: 1
|
||||||
|
ring:
|
||||||
|
kvstore:
|
||||||
|
store: inmemory
|
||||||
|
|
||||||
|
schema_config:
|
||||||
|
configs:
|
||||||
|
- from: 2024-01-01
|
||||||
|
store: tsdb
|
||||||
|
object_store: filesystem
|
||||||
|
schema: v13
|
||||||
|
index:
|
||||||
|
prefix: index_
|
||||||
|
period: 24h
|
||||||
|
|
||||||
|
storage_config:
|
||||||
|
filesystem:
|
||||||
|
directory: /var/loki/chunks
|
||||||
|
|
||||||
|
analytics:
|
||||||
|
reporting_enabled: false
|
||||||
44
observability/otel-collector/config.yaml
Normal file
44
observability/otel-collector/config.yaml
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc:
|
||||||
|
endpoint: 0.0.0.0:4317
|
||||||
|
file_log/nereus:
|
||||||
|
include:
|
||||||
|
- /var/log/pods/nereus_*/*/*.log
|
||||||
|
include_file_path: true
|
||||||
|
operators:
|
||||||
|
- type: container
|
||||||
|
id: parse-container-log
|
||||||
|
|
||||||
|
processors:
|
||||||
|
memory_limiter:
|
||||||
|
check_interval: 1s
|
||||||
|
limit_mib: 256
|
||||||
|
k8s_attributes:
|
||||||
|
extract:
|
||||||
|
metadata:
|
||||||
|
- k8s.namespace.name
|
||||||
|
- k8s.pod.name
|
||||||
|
- k8s.container.name
|
||||||
|
batch:
|
||||||
|
timeout: 5s
|
||||||
|
|
||||||
|
exporters:
|
||||||
|
otlp_grpc/tempo:
|
||||||
|
endpoint: tempo:4317
|
||||||
|
tls:
|
||||||
|
insecure: true
|
||||||
|
otlp_http/loki:
|
||||||
|
endpoint: http://loki:3100/otlp
|
||||||
|
|
||||||
|
service:
|
||||||
|
pipelines:
|
||||||
|
traces:
|
||||||
|
receivers: [otlp]
|
||||||
|
processors: [memory_limiter, k8s_attributes, batch]
|
||||||
|
exporters: [otlp_grpc/tempo]
|
||||||
|
logs:
|
||||||
|
receivers: [file_log/nereus]
|
||||||
|
processors: [memory_limiter, k8s_attributes, batch]
|
||||||
|
exporters: [otlp_http/loki]
|
||||||
25
observability/otel-collector/local.yaml
Normal file
25
observability/otel-collector/local.yaml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc:
|
||||||
|
endpoint: 0.0.0.0:4317
|
||||||
|
|
||||||
|
processors:
|
||||||
|
memory_limiter:
|
||||||
|
check_interval: 1s
|
||||||
|
limit_mib: 256
|
||||||
|
batch:
|
||||||
|
timeout: 5s
|
||||||
|
|
||||||
|
exporters:
|
||||||
|
otlp_grpc/tempo:
|
||||||
|
endpoint: tempo:4317
|
||||||
|
tls:
|
||||||
|
insecure: true
|
||||||
|
|
||||||
|
service:
|
||||||
|
pipelines:
|
||||||
|
traces:
|
||||||
|
receivers: [otlp]
|
||||||
|
processors: [memory_limiter, batch]
|
||||||
|
exporters: [otlp_grpc/tempo]
|
||||||
12
observability/prometheus/prometheus.yaml
Normal file
12
observability/prometheus/prometheus.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
global:
|
||||||
|
scrape_interval: 5s
|
||||||
|
evaluation_interval: 5s
|
||||||
|
|
||||||
|
rule_files:
|
||||||
|
- /etc/prometheus/rules/*.yaml
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: nereus-api
|
||||||
|
metrics_path: /metrics
|
||||||
|
static_configs:
|
||||||
|
- targets: [api:8080]
|
||||||
17
observability/tempo/tempo.yaml
Normal file
17
observability/tempo/tempo.yaml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
server:
|
||||||
|
http_listen_port: 3200
|
||||||
|
|
||||||
|
distributor:
|
||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc:
|
||||||
|
endpoint: 0.0.0.0:4317
|
||||||
|
|
||||||
|
storage:
|
||||||
|
trace:
|
||||||
|
backend: local
|
||||||
|
wal:
|
||||||
|
path: /var/tempo/wal
|
||||||
|
local:
|
||||||
|
path: /var/tempo/traces
|
||||||
33
scripts/k3d-nereus.yaml
Normal file
33
scripts/k3d-nereus.yaml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
apiVersion: k3d.io/v1alpha5
|
||||||
|
kind: Simple
|
||||||
|
metadata:
|
||||||
|
name: nereus
|
||||||
|
|
||||||
|
# Three nodes so this is a real multi-node k3s, not a single-node toy.
|
||||||
|
# Mirrors the two Fedora VMs plus room for a rollout to spread across nodes.
|
||||||
|
servers: 1
|
||||||
|
agents: 2
|
||||||
|
|
||||||
|
image: docker.io/rancher/k3s:v1.33.4-k3s1
|
||||||
|
|
||||||
|
ports:
|
||||||
|
# Traefik on 8080/8443 so it never fights the Mini PC stack or a local dev server.
|
||||||
|
- port: 8080:80
|
||||||
|
nodeFilters: [loadbalancer]
|
||||||
|
- port: 8443:443
|
||||||
|
nodeFilters: [loadbalancer]
|
||||||
|
|
||||||
|
options:
|
||||||
|
k3d:
|
||||||
|
wait: true
|
||||||
|
timeout: 180s
|
||||||
|
k3s:
|
||||||
|
extraArgs:
|
||||||
|
# kube-prometheus-stack scrapes these; k3s binds them to localhost by default.
|
||||||
|
- arg: --kube-controller-manager-arg=bind-address=0.0.0.0
|
||||||
|
nodeFilters: [server:*]
|
||||||
|
- arg: --kube-scheduler-arg=bind-address=0.0.0.0
|
||||||
|
nodeFilters: [server:*]
|
||||||
|
kubeconfig:
|
||||||
|
updateDefaultKubeconfig: true
|
||||||
|
switchCurrentContext: true
|
||||||
71
scripts/k3d/analysis-harness/harness.yaml
Normal file
71
scripts/k3d/analysis-harness/harness.yaml
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# Mechanism test for the automated rollback, run before the real API exists.
|
||||||
|
#
|
||||||
|
# It proves the exact chain the project depends on: Argo Rollouts pauses a
|
||||||
|
# blue-green promotion, runs an AnalysisRun, that run queries Prometheus over
|
||||||
|
# the network, evaluates the result against a threshold, and aborts the rollout
|
||||||
|
# on failure. The only thing faked is the metric itself.
|
||||||
|
#
|
||||||
|
# Throwaway. deploy/rollouts/ holds the real thing.
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata: {name: probe-active, namespace: nereus}
|
||||||
|
spec:
|
||||||
|
selector: {app: probe}
|
||||||
|
ports: [{port: 80, targetPort: 8080}]
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata: {name: probe-preview, namespace: nereus}
|
||||||
|
spec:
|
||||||
|
selector: {app: probe}
|
||||||
|
ports: [{port: 80, targetPort: 8080}]
|
||||||
|
---
|
||||||
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: AnalysisTemplate
|
||||||
|
metadata: {name: error-rate, namespace: nereus}
|
||||||
|
spec:
|
||||||
|
metrics:
|
||||||
|
- name: error-rate
|
||||||
|
interval: 10s
|
||||||
|
count: 3
|
||||||
|
# Same shape as the real query will use: fail when the error ratio is
|
||||||
|
# above the threshold. failureLimit 0 means one bad sample aborts.
|
||||||
|
successCondition: "result[0] < 0.05"
|
||||||
|
failureLimit: 0
|
||||||
|
provider:
|
||||||
|
prometheus:
|
||||||
|
address: http://kube-prometheus-stack-prometheus.observability.svc.cluster.local:9090
|
||||||
|
query: "{{args.query}}"
|
||||||
|
args:
|
||||||
|
- name: query
|
||||||
|
---
|
||||||
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: Rollout
|
||||||
|
metadata: {name: probe, namespace: nereus}
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
revisionHistoryLimit: 2
|
||||||
|
selector:
|
||||||
|
matchLabels: {app: probe}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels: {app: probe}
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: web
|
||||||
|
image: nginxinc/nginx-unprivileged:alpine
|
||||||
|
ports: [{containerPort: 8080}]
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 10m, memory: 16Mi}
|
||||||
|
strategy:
|
||||||
|
blueGreen:
|
||||||
|
activeService: probe-active
|
||||||
|
previewService: probe-preview
|
||||||
|
autoPromotionEnabled: true
|
||||||
|
prePromotionAnalysis:
|
||||||
|
templates:
|
||||||
|
- templateName: error-rate
|
||||||
|
args:
|
||||||
|
- name: query
|
||||||
|
value: "vector(0.0)"
|
||||||
65
scripts/k3d/kube-prometheus-stack.values.yaml
Normal file
65
scripts/k3d/kube-prometheus-stack.values.yaml
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# Values for the k3d (level 2) cluster only. Kept lean so the whole stack fits
|
||||||
|
# alongside the app on a laptop-sized machine.
|
||||||
|
#
|
||||||
|
# No credentials here. Grafana's admin password is generated by the chart into a
|
||||||
|
# secret; read it with:
|
||||||
|
# kubectl -n observability get secret kube-prometheus-stack-grafana \
|
||||||
|
# -o jsonpath='{.data.admin-password}' | base64 -d
|
||||||
|
|
||||||
|
# k3s does not expose these the way a kubeadm cluster does. Left enabled they
|
||||||
|
# produce permanently-firing "target down" alerts that bury the real ones.
|
||||||
|
kubeEtcd:
|
||||||
|
enabled: false
|
||||||
|
kubeProxy:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
kubeControllerManager:
|
||||||
|
service:
|
||||||
|
port: 10257
|
||||||
|
targetPort: 10257
|
||||||
|
serviceMonitor:
|
||||||
|
https: true
|
||||||
|
insecureSkipVerify: true
|
||||||
|
kubeScheduler:
|
||||||
|
service:
|
||||||
|
port: 10259
|
||||||
|
targetPort: 10259
|
||||||
|
serviceMonitor:
|
||||||
|
https: true
|
||||||
|
insecureSkipVerify: true
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
prometheusSpec:
|
||||||
|
retention: 6h
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 100m, memory: 512Mi}
|
||||||
|
limits: {memory: 1500Mi}
|
||||||
|
# Pick up ServiceMonitors from every namespace, not just the chart's own
|
||||||
|
# release. The app lives in `nereus` and must be scraped from there.
|
||||||
|
serviceMonitorSelectorNilUsesHelmValues: false
|
||||||
|
podMonitorSelectorNilUsesHelmValues: false
|
||||||
|
ruleSelectorNilUsesHelmValues: false
|
||||||
|
storageSpec:
|
||||||
|
volumeClaimTemplate:
|
||||||
|
spec:
|
||||||
|
accessModes: [ReadWriteOnce]
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
defaultDashboardsTimezone: browser
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 50m, memory: 128Mi}
|
||||||
|
limits: {memory: 512Mi}
|
||||||
|
|
||||||
|
alertmanager:
|
||||||
|
alertmanagerSpec:
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 25m, memory: 64Mi}
|
||||||
|
limits: {memory: 256Mi}
|
||||||
|
|
||||||
|
prometheusOperator:
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 50m, memory: 128Mi}
|
||||||
|
limits: {memory: 512Mi}
|
||||||
180
scripts/k3d/lab.sh
Executable file
180
scripts/k3d/lab.sh
Executable file
|
|
@ -0,0 +1,180 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
readonly REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||||
|
readonly CLUSTER_NAME="nereus"
|
||||||
|
readonly CONTEXT="k3d-${CLUSTER_NAME}"
|
||||||
|
readonly ARGO_CHART_VERSION="2.41.1"
|
||||||
|
readonly PROMETHEUS_CHART_VERSION="88.5.2"
|
||||||
|
|
||||||
|
require_commands() {
|
||||||
|
local command_name
|
||||||
|
for command_name in docker k3d helm kubectl; do
|
||||||
|
command -v "${command_name}" >/dev/null || {
|
||||||
|
echo "missing required command: ${command_name}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
|
docker info >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
use_context() {
|
||||||
|
kubectl config use-context "${CONTEXT}" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
cluster_exists() {
|
||||||
|
k3d cluster get "${CLUSTER_NAME}" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
up() {
|
||||||
|
require_commands
|
||||||
|
if ! cluster_exists; then
|
||||||
|
k3d cluster create --config "${REPO_ROOT}/scripts/k3d-nereus.yaml"
|
||||||
|
else
|
||||||
|
k3d cluster start "${CLUSTER_NAME}"
|
||||||
|
fi
|
||||||
|
use_context
|
||||||
|
|
||||||
|
helm repo add argo https://argoproj.github.io/argo-helm --force-update
|
||||||
|
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts --force-update
|
||||||
|
helm repo update
|
||||||
|
|
||||||
|
helm upgrade --install argo-rollouts argo/argo-rollouts \
|
||||||
|
--namespace argo-rollouts \
|
||||||
|
--create-namespace \
|
||||||
|
--version "${ARGO_CHART_VERSION}" \
|
||||||
|
--wait \
|
||||||
|
--timeout 5m
|
||||||
|
|
||||||
|
helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack \
|
||||||
|
--namespace observability \
|
||||||
|
--create-namespace \
|
||||||
|
--version "${PROMETHEUS_CHART_VERSION}" \
|
||||||
|
--values "${SCRIPT_DIR}/kube-prometheus-stack.values.yaml" \
|
||||||
|
--wait \
|
||||||
|
--timeout 10m
|
||||||
|
|
||||||
|
kubectl create namespace nereus --dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
kubectl apply -f "${SCRIPT_DIR}/analysis-harness/harness.yaml"
|
||||||
|
check
|
||||||
|
}
|
||||||
|
|
||||||
|
check() {
|
||||||
|
require_commands
|
||||||
|
cluster_exists || {
|
||||||
|
echo "cluster ${CLUSTER_NAME} does not exist; run $0 up" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
use_context
|
||||||
|
|
||||||
|
kubectl wait node --all --for=condition=Ready --timeout=3m
|
||||||
|
kubectl wait deployment --all --namespace argo-rollouts --for=condition=Available --timeout=3m
|
||||||
|
kubectl wait deployment --all --namespace observability --for=condition=Available --timeout=5m
|
||||||
|
kubectl get rollout probe --namespace nereus >/dev/null
|
||||||
|
kubectl get analysistemplate error-rate --namespace nereus >/dev/null
|
||||||
|
echo "k3d rollback lab is ready"
|
||||||
|
}
|
||||||
|
|
||||||
|
analysis_name() {
|
||||||
|
kubectl get rollout probe --namespace nereus \
|
||||||
|
-o jsonpath='{.status.blueGreen.prePromotionAnalysisRunStatus.name}'
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_analysis() {
|
||||||
|
local previous_name="$1"
|
||||||
|
local expected_phase="$2"
|
||||||
|
local analysis_run=""
|
||||||
|
local phase=""
|
||||||
|
local attempt
|
||||||
|
|
||||||
|
for attempt in {1..90}; do
|
||||||
|
analysis_run="$(analysis_name)"
|
||||||
|
if [[ -n "${analysis_run}" && "${analysis_run}" != "${previous_name}" ]]; then
|
||||||
|
phase="$(kubectl get analysisrun "${analysis_run}" --namespace nereus -o jsonpath='{.status.phase}')"
|
||||||
|
if [[ "${phase}" == "${expected_phase}" ]]; then
|
||||||
|
echo "${analysis_run} reached ${expected_phase}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ "${phase}" == "Error" || "${phase}" == "Inconclusive" ]]; then
|
||||||
|
echo "${analysis_run} ended unexpectedly with ${phase}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "analysis did not reach ${expected_phase} within 180 seconds" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
set_proof_revision() {
|
||||||
|
local query="$1"
|
||||||
|
local revision="$2"
|
||||||
|
kubectl patch rollout probe --namespace nereus --type=merge --patch \
|
||||||
|
"{\"spec\":{\"strategy\":{\"blueGreen\":{\"prePromotionAnalysis\":{\"args\":[{\"name\":\"query\",\"value\":\"${query}\"}]}}},\"template\":{\"metadata\":{\"annotations\":{\"nereus.fiwlabs.dev/proof\":\"${revision}\"}}}}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
prove() {
|
||||||
|
check
|
||||||
|
|
||||||
|
local proof_id
|
||||||
|
local previous_analysis
|
||||||
|
local healthy_analysis
|
||||||
|
local healthy_revision=""
|
||||||
|
local active_revision=""
|
||||||
|
local stable_revision=""
|
||||||
|
local attempt
|
||||||
|
proof_id="$(date -u +%Y%m%d%H%M%S)"
|
||||||
|
|
||||||
|
previous_analysis="$(analysis_name)"
|
||||||
|
set_proof_revision "vector(0.0)" "healthy-${proof_id}"
|
||||||
|
wait_for_analysis "${previous_analysis}" Successful
|
||||||
|
healthy_analysis="$(analysis_name)"
|
||||||
|
|
||||||
|
for attempt in {1..30}; do
|
||||||
|
healthy_revision="$(kubectl get rollout probe --namespace nereus -o jsonpath='{.status.stableRS}')"
|
||||||
|
active_revision="$(kubectl get service probe-active --namespace nereus -o jsonpath='{.spec.selector.rollouts-pod-template-hash}')"
|
||||||
|
if [[ -n "${healthy_revision}" && "${active_revision}" == "${healthy_revision}" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[[ -n "${healthy_revision}" && "${active_revision}" == "${healthy_revision}" ]] || {
|
||||||
|
echo "healthy revision was not promoted" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
set_proof_revision "vector(1.0)" "failing-${proof_id}"
|
||||||
|
wait_for_analysis "${healthy_analysis}" Failed
|
||||||
|
stable_revision="$(kubectl get rollout probe --namespace nereus -o jsonpath='{.status.stableRS}')"
|
||||||
|
active_revision="$(kubectl get service probe-active --namespace nereus -o jsonpath='{.spec.selector.rollouts-pod-template-hash}')"
|
||||||
|
[[ "${stable_revision}" == "${healthy_revision}" && "${active_revision}" == "${healthy_revision}" ]] || {
|
||||||
|
echo "failed revision replaced the active healthy revision" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
set_proof_revision "vector(0.0)" "healthy-${proof_id}"
|
||||||
|
echo "rollback proof passed; active revision stayed ${healthy_revision}"
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
require_commands
|
||||||
|
if cluster_exists; then
|
||||||
|
k3d cluster delete "${CLUSTER_NAME}"
|
||||||
|
else
|
||||||
|
echo "cluster ${CLUSTER_NAME} does not exist"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
up) up ;;
|
||||||
|
check) check ;;
|
||||||
|
prove) prove ;;
|
||||||
|
destroy) destroy ;;
|
||||||
|
*)
|
||||||
|
echo "usage: $0 {up|check|prove|destroy}" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
97
scripts/provision/README.md
Normal file
97
scripts/provision/README.md
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
# Fedora host provisioning
|
||||||
|
|
||||||
|
This directory configures already-installed Fedora 44 hosts. Infrastructure can
|
||||||
|
be created manually or by the hand-maintained Terraform configuration; both
|
||||||
|
paths produce the same input: two reachable hosts in `inventory.yml`.
|
||||||
|
|
||||||
|
Node 1 runs the k3s server. Node 2 runs a k3s agent. The playbook is idempotent
|
||||||
|
and may be rerun to converge package, firewall, k3s, and service state.
|
||||||
|
|
||||||
|
## Automated path
|
||||||
|
|
||||||
|
Copy `inventory.example.yml` to `inventory.yml`, replace the addresses and node
|
||||||
|
IP values, set `k3s_node_cidr` to the network containing only cluster nodes,
|
||||||
|
set `k3s_operator_cidrs` to the authorized administration networks, and set the
|
||||||
|
SSH user and connection options required by those hosts.
|
||||||
|
The managed machines may be physical servers, VMs from any provider, or
|
||||||
|
manually installed systems; the playbook does not depend on libvirt or the
|
||||||
|
local lab subnet. Then provide the existing cluster token only in the process
|
||||||
|
environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
K3S_TOKEN="$(openssl rand -hex 32)" ./bootstrap.sh inventory.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
The bootstrap checks for `ansible-playbook` and installs the declared Ansible
|
||||||
|
collections. It does not install system packages on the operator machine.
|
||||||
|
|
||||||
|
Do not save the token in the inventory or repository. Preserve it in the
|
||||||
|
operator's secret manager so a replacement agent can join the same cluster.
|
||||||
|
|
||||||
|
## Manual path
|
||||||
|
|
||||||
|
Use `manual-checklist.md` when configuration must be performed interactively.
|
||||||
|
It describes the same end state as the playbook, so a manually prepared host
|
||||||
|
can later be managed by Ansible without rebuilding it.
|
||||||
|
|
||||||
|
## Terraform path
|
||||||
|
|
||||||
|
Terraform is responsible only for creating machines, networks, and addresses.
|
||||||
|
After `terraform apply`, put its resulting addresses into `inventory.yml` and
|
||||||
|
run this playbook. Keeping configuration out of provisioner hooks makes the
|
||||||
|
same Ansible workflow usable for physical hardware, VMs, and manually created
|
||||||
|
hosts.
|
||||||
|
|
||||||
|
## One-command local QEMU lab
|
||||||
|
|
||||||
|
`lab.sh` is only a disposable integration harness for this workstation. Its
|
||||||
|
`virbr0` interface, fixed test addresses, UFW forwarding rules, cloud image,
|
||||||
|
and NetworkManager profiles are deliberately kept out of the reusable Ansible
|
||||||
|
roles. Do not run it on the two production machines; put their real addresses
|
||||||
|
in an inventory and run `bootstrap.sh` instead.
|
||||||
|
|
||||||
|
On an x86_64 Fedora or Arch-family workstation with hardware virtualization enabled:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./lab.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
To reuse an existing Fedora 44 Cloud Base Generic QCOW2 image instead of
|
||||||
|
downloading another copy:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FEDORA_IMAGE=/path/to/Fedora-Cloud-Base-Generic-44.x86_64.qcow2 ./lab.sh up
|
||||||
|
```
|
||||||
|
|
||||||
|
The lab intentionally supports x86_64 only. Both target Fedora hosts and the
|
||||||
|
workstation used for the final project run x86_64, so maintaining a separate
|
||||||
|
aarch64 image, firmware, and verification path would add an untested platform
|
||||||
|
without helping the deployment demonstration.
|
||||||
|
|
||||||
|
The Fedora Server Guest Generic image is not suitable for this workflow because
|
||||||
|
it starts the interactive initial-setup program instead of accepting cloud-init
|
||||||
|
configuration.
|
||||||
|
|
||||||
|
The command installs missing host packages, enables libvirt, uses the selected
|
||||||
|
local image or downloads the Fedora 44 cloud image, creates two reusable VMs,
|
||||||
|
waits for ping and SSH, runs the Ansible configuration, and verifies
|
||||||
|
applications, services, directories, ports, and Kubernetes node readiness. Its
|
||||||
|
VM metadata and SSH key live under
|
||||||
|
`${XDG_STATE_HOME:-$HOME/.local/state}/nereus-lab`, outside the repository.
|
||||||
|
|
||||||
|
Subsequent operations are `./lab.sh check`, `./lab.sh stop`, and the explicitly
|
||||||
|
destructive `./lab.sh destroy`.
|
||||||
|
|
||||||
|
Deleting a VM also deletes any k3s local-path volumes stored on that machine.
|
||||||
|
The lab recreates the host and its Kubernetes identity, but stateful demo data
|
||||||
|
on the deleted disk must be recreated separately. This is acceptable for the
|
||||||
|
disposable harness and is not a backup strategy.
|
||||||
|
|
||||||
|
### Tested lab capacity
|
||||||
|
|
||||||
|
The complete two-node stack passed provisioning, reboot, node-disconnect
|
||||||
|
recovery, workload readiness, and public API checks on 2026-08-24 with 2 vCPUs,
|
||||||
|
3 GiB RAM, and a 30 GiB virtual disk per node. These are the lowest settings
|
||||||
|
tested for this project, not a production sizing recommendation. The thin
|
||||||
|
QCOW2 files used approximately 2.0 GiB for node 1 and 3.3 GiB for node 2 during
|
||||||
|
that verification.
|
||||||
8
scripts/provision/ansible.cfg
Normal file
8
scripts/provision/ansible.cfg
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
[defaults]
|
||||||
|
inventory = inventory.yml
|
||||||
|
interpreter_python = auto_silent
|
||||||
|
retry_files_enabled = False
|
||||||
|
stdout_callback = default
|
||||||
|
|
||||||
|
[ssh_connection]
|
||||||
|
pipelining = True
|
||||||
25
scripts/provision/bootstrap.sh
Executable file
25
scripts/provision/bootstrap.sh
Executable file
|
|
@ -0,0 +1,25 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
inventory=${1:-"$script_dir/inventory.yml"}
|
||||||
|
|
||||||
|
if [[ ! -f "$inventory" ]]; then
|
||||||
|
printf 'Inventory not found: %s\nCopy %s/inventory.example.yml and edit it first.\n' "$inventory" "$script_dir" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
inventory=$(realpath -- "$inventory")
|
||||||
|
|
||||||
|
if [[ -z "${K3S_TOKEN:-}" ]]; then
|
||||||
|
printf 'K3S_TOKEN must be supplied through the environment.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v ansible-playbook >/dev/null 2>&1; then
|
||||||
|
printf 'ansible-playbook is required. Install ansible-core on this operator machine.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$script_dir"
|
||||||
|
ansible-galaxy collection install --requirements-file requirements.yml
|
||||||
|
ansible-playbook --inventory "$inventory" site.yml
|
||||||
20
scripts/provision/inventory.example.yml
Normal file
20
scripts/provision/inventory.example.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
all:
|
||||||
|
vars:
|
||||||
|
ansible_user: fedora
|
||||||
|
k3s_version: v1.33.4+k3s1
|
||||||
|
k3s_cluster_cidr: 10.42.0.0/16
|
||||||
|
k3s_service_cidr: 10.43.0.0/16
|
||||||
|
k3s_node_cidr: 192.0.2.0/24
|
||||||
|
k3s_operator_cidrs:
|
||||||
|
- 198.51.100.0/24
|
||||||
|
children:
|
||||||
|
k3s_server:
|
||||||
|
hosts:
|
||||||
|
node1:
|
||||||
|
ansible_host: 192.0.2.10
|
||||||
|
k3s_node_ip: 192.0.2.10
|
||||||
|
k3s_agent:
|
||||||
|
hosts:
|
||||||
|
node2:
|
||||||
|
ansible_host: 192.0.2.11
|
||||||
|
k3s_node_ip: 192.0.2.11
|
||||||
232
scripts/provision/lab.sh
Executable file
232
scripts/provision/lab.sh
Executable file
|
|
@ -0,0 +1,232 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
action=${1:-up}
|
||||||
|
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
state_root=${XDG_STATE_HOME:-"$HOME/.local/state"}/nereus-lab
|
||||||
|
image_root=/var/lib/libvirt/images/nereus-lab
|
||||||
|
inventory="$state_root/inventory.yml"
|
||||||
|
key="$state_root/id_ed25519"
|
||||||
|
known_hosts="$state_root/known_hosts"
|
||||||
|
token_file="$state_root/k3s-token"
|
||||||
|
fedora_release=44
|
||||||
|
nodes=(nereus-node1 nereus-node2)
|
||||||
|
created_nodes=()
|
||||||
|
|
||||||
|
log() { printf '[nereus-lab] %s\n' "$*"; }
|
||||||
|
fail() { printf '[nereus-lab] ERROR: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
install_dependencies() {
|
||||||
|
local missing=()
|
||||||
|
for tool in qemu-img virsh virt-install virt-customize cloud-localds ansible-playbook curl ssh-keygen openssl ping; do
|
||||||
|
command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
|
||||||
|
done
|
||||||
|
((${#missing[@]} == 0)) && return
|
||||||
|
[[ -r /etc/os-release ]] || fail "missing tools: ${missing[*]}"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. /etc/os-release
|
||||||
|
log "installing virtualization, cloud-init, and Ansible tooling"
|
||||||
|
case ${ID:-} in
|
||||||
|
fedora)
|
||||||
|
sudo dnf install -y @virtualization cloud-utils guestfs-tools ansible-core openssl iputils
|
||||||
|
;;
|
||||||
|
arch|cachyos)
|
||||||
|
sudo pacman -S --needed --noconfirm qemu-full libvirt virt-install cloud-image-utils guestfs-tools ansible-core openssl iputils dnsmasq
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "automatic dependency installation does not support ${ID:-this host}; missing: ${missing[*]}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_host() {
|
||||||
|
local uplink
|
||||||
|
install_dependencies
|
||||||
|
[[ $(uname -m) == x86_64 ]] || fail "the bundled Fedora image workflow currently supports x86_64 hosts"
|
||||||
|
[[ -e /dev/kvm ]] || fail "/dev/kvm is unavailable; enable CPU virtualization in firmware"
|
||||||
|
sudo systemctl enable --now libvirtd
|
||||||
|
if ! sudo virsh net-info default >/dev/null 2>&1; then
|
||||||
|
fail "libvirt's default network is missing"
|
||||||
|
fi
|
||||||
|
sudo virsh net-start default >/dev/null 2>&1 || true
|
||||||
|
sudo virsh net-autostart default >/dev/null
|
||||||
|
if command -v ufw >/dev/null 2>&1 && sudo ufw status | grep -q '^Status: active'; then
|
||||||
|
uplink=$(ip route show default | awk '/default/ {print $5; exit}')
|
||||||
|
[[ -n "$uplink" ]] || fail "could not determine the host's default network interface"
|
||||||
|
sudo ufw allow in on virbr0 to any port 53 proto udp >/dev/null
|
||||||
|
sudo ufw allow in on virbr0 to any port 53 proto tcp >/dev/null
|
||||||
|
sudo ufw allow in on virbr0 to any port 67 proto udp >/dev/null
|
||||||
|
sudo ufw route allow in on virbr0 out on virbr0 from 192.168.122.0/24 to 192.168.122.0/24 >/dev/null
|
||||||
|
sudo ufw route allow in on virbr0 out on "$uplink" from 192.168.122.0/24 >/dev/null
|
||||||
|
fi
|
||||||
|
mkdir -p "$state_root"
|
||||||
|
chmod 0700 "$state_root"
|
||||||
|
if [[ ! -f "$key" ]]; then
|
||||||
|
ssh-keygen -q -t ed25519 -N '' -f "$key"
|
||||||
|
fi
|
||||||
|
touch "$known_hosts"
|
||||||
|
chmod 0600 "$key" "$known_hosts"
|
||||||
|
if [[ ! -f "$token_file" ]]; then
|
||||||
|
umask 077
|
||||||
|
openssl rand -hex 32 >"$token_file"
|
||||||
|
fi
|
||||||
|
chmod 0600 "$token_file"
|
||||||
|
sudo install -d -m 0755 "$image_root"
|
||||||
|
}
|
||||||
|
|
||||||
|
image_url() {
|
||||||
|
local arch=x86_64 listing filename
|
||||||
|
listing="https://download.fedoraproject.org/pub/fedora/linux/releases/${fedora_release}/Cloud/${arch}/images/"
|
||||||
|
filename=$(curl -fsSL "$listing" | sed -nE "s/.*href=\"(Fedora-Cloud-Base-Generic-${fedora_release}-[0-9.]+\.${arch}\.qcow2)\".*/\1/p" | sort -V | tail -n1)
|
||||||
|
[[ -n "$filename" ]] || fail "could not discover the Fedora ${fedora_release} cloud image"
|
||||||
|
printf '%s%s\n' "$listing" "$filename"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_base_image() {
|
||||||
|
local cached="$state_root/fedora-${fedora_release}-cloud-base.qcow2" source=${FEDORA_IMAGE:-}
|
||||||
|
if [[ ! -s "$cached" ]]; then
|
||||||
|
if [[ -n "$source" ]]; then
|
||||||
|
[[ -f "$source" ]] || fail "FEDORA_IMAGE does not exist: $source"
|
||||||
|
[[ $(basename "$source") == Fedora-Cloud-Base-Generic-* ]] || fail "FEDORA_IMAGE must be a Fedora Cloud Base Generic QCOW2 image"
|
||||||
|
qemu-img check -q "$source" || fail "FEDORA_IMAGE is not a valid QCOW2 image"
|
||||||
|
log "using local Fedora ${fedora_release} image: $source"
|
||||||
|
install -m 0644 "$source" "$cached"
|
||||||
|
else
|
||||||
|
log "downloading Fedora ${fedora_release} cloud image"
|
||||||
|
curl -fL --retry 3 --continue-at - -o "$cached.partial" "$(image_url)"
|
||||||
|
if ! qemu-img check -q "$cached.partial"; then
|
||||||
|
rm -f "$cached.partial"
|
||||||
|
fail "downloaded Fedora image is not a valid QCOW2 image"
|
||||||
|
fi
|
||||||
|
mv "$cached.partial" "$cached"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
sudo install -m 0644 "$cached" "$image_root/fedora-${fedora_release}-cloud-base.qcow2"
|
||||||
|
}
|
||||||
|
|
||||||
|
node_mac() {
|
||||||
|
case $1 in
|
||||||
|
nereus-node1) printf '52:54:00:6e:01:01\n' ;;
|
||||||
|
nereus-node2) printf '52:54:00:6e:01:02\n' ;;
|
||||||
|
*) fail "no MAC address assigned for $1" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
create_seed() {
|
||||||
|
local node=$1 seed="$state_root/${node}-seed.iso" user_data="$state_root/${node}-user-data"
|
||||||
|
{
|
||||||
|
printf '#cloud-config\nhostname: %s\nmanage_etc_hosts: true\nusers:\n' "$node"
|
||||||
|
printf ' - name: fedora\n groups: [wheel]\n sudo: ALL=(ALL) NOPASSWD:ALL\n shell: /bin/bash\n ssh_authorized_keys:\n'
|
||||||
|
printf ' - %s\n' "$(<"$key.pub")"
|
||||||
|
printf 'ssh_pwauth: false\npackages: [qemu-guest-agent]\nruncmd:\n - [systemctl, enable, --now, qemu-guest-agent]\n'
|
||||||
|
} >"$user_data"
|
||||||
|
chmod 0600 "$user_data"
|
||||||
|
cloud-localds --dsmode local --hostname "$node" "$seed" "$user_data"
|
||||||
|
sudo install -m 0644 "$seed" "$image_root/${node}-seed.iso"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_network_service() {
|
||||||
|
local node=$1 disk=$2
|
||||||
|
sudo virt-customize -q -a "$disk" \
|
||||||
|
--copy-in "$script_dir/nereus-network.service:/etc/systemd/system" \
|
||||||
|
--copy-in "$script_dir/${node}.nmconnection:/etc/NetworkManager/system-connections" \
|
||||||
|
--chmod "0600:/etc/NetworkManager/system-connections/${node}.nmconnection" \
|
||||||
|
--run-command "chown root:root /etc/NetworkManager/system-connections/${node}.nmconnection" \
|
||||||
|
--run-command 'rm -f /etc/NetworkManager/system-connections/cloud-init-ens2.nmconnection' \
|
||||||
|
--run-command 'systemctl enable nereus-network.service'
|
||||||
|
}
|
||||||
|
|
||||||
|
create_vm() {
|
||||||
|
local node=$1 disk="$image_root/${node}.qcow2" mac
|
||||||
|
mac=$(node_mac "$node")
|
||||||
|
if sudo virsh dominfo "$node" >/dev/null 2>&1; then
|
||||||
|
if [[ $(sudo virsh domstate "$node") == "shut off" ]]; then
|
||||||
|
install_network_service "$node" "$disk"
|
||||||
|
fi
|
||||||
|
sudo virsh start "$node" >/dev/null 2>&1 || true
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
log "creating $node"
|
||||||
|
created_nodes+=("$node")
|
||||||
|
ssh-keygen -q -f "$known_hosts" -R "$(node_ip "$node")" >/dev/null 2>&1 || true
|
||||||
|
create_seed "$node"
|
||||||
|
sudo qemu-img create -q -f qcow2 -F qcow2 -b "$image_root/fedora-${fedora_release}-cloud-base.qcow2" "$disk" 30G
|
||||||
|
install_network_service "$node" "$disk"
|
||||||
|
sudo virt-install --name "$node" --memory 3072 --vcpus 2 --import \
|
||||||
|
--disk "path=$disk,format=qcow2,bus=virtio" \
|
||||||
|
--disk "path=$image_root/${node}-seed.iso,format=raw,bus=virtio,readonly=on" \
|
||||||
|
--network "network=default,model=virtio,mac=$mac" --graphics none --noautoconsole \
|
||||||
|
--boot uefi --osinfo detect=on,require=off
|
||||||
|
}
|
||||||
|
|
||||||
|
node_ip() {
|
||||||
|
case $1 in
|
||||||
|
nereus-node1) printf '192.168.122.10\n' ;;
|
||||||
|
nereus-node2) printf '192.168.122.11\n' ;;
|
||||||
|
*) fail "no address assigned for $1" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_ssh() {
|
||||||
|
local ip=$1
|
||||||
|
for _ in $(seq 1 90); do
|
||||||
|
if ssh -i "$key" -o BatchMode=yes -o ConnectTimeout=2 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$known_hosts" "fedora@$ip" true 2>/dev/null; then return; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
fail "SSH did not become ready at $ip"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_ping() {
|
||||||
|
local node=$1 ip=$2
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
ping -c 1 -W 1 "$ip" >/dev/null 2>&1 && return
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
fail "$node does not answer ping at $ip"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_inventory() {
|
||||||
|
local ip1=$1 ip2=$2
|
||||||
|
umask 077
|
||||||
|
printf 'all:\n vars:\n ansible_user: fedora\n ansible_ssh_private_key_file: %s\n ansible_ssh_common_args: "-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=%s"\n k3s_version: v1.33.4+k3s1\n k3s_cluster_cidr: 10.42.0.0/16\n k3s_service_cidr: 10.43.0.0/16\n k3s_node_cidr: 192.168.122.0/24\n k3s_operator_cidrs:\n - 192.168.122.1/32\n children:\n k3s_server:\n hosts:\n node1:\n ansible_host: %s\n k3s_node_ip: %s\n k3s_agent:\n hosts:\n node2:\n ansible_host: %s\n k3s_node_ip: %s\n' "$key" "$known_hosts" "$ip1" "$ip1" "$ip2" "$ip2" >"$inventory"
|
||||||
|
}
|
||||||
|
|
||||||
|
check_lab() {
|
||||||
|
[[ -f "$inventory" ]] || fail "run '$0 up' first"
|
||||||
|
log "checking reachability, services, ports, packages, and directories"
|
||||||
|
ansible all -i "$inventory" -m ansible.builtin.ping
|
||||||
|
ansible all -i "$inventory" -b -m ansible.builtin.shell -a 'for i in $(seq 1 60); do test -d /var/lib/rancher/k3s && command -v k3s >/dev/null && systemctl is-active --quiet firewalld && ss -lnt | grep -q ":10250 " && exit 0; sleep 2; done; exit 1'
|
||||||
|
ansible k3s_server -i "$inventory" -b -m ansible.builtin.shell -a 'for i in $(seq 1 60); do systemctl is-active --quiet k3s && ss -lnt | grep -q ":6443 " && exit 0; sleep 2; done; exit 1'
|
||||||
|
ansible k3s_agent -i "$inventory" -b -m ansible.builtin.shell -a 'for i in $(seq 1 60); do systemctl is-active --quiet k3s-agent && exit 0; sleep 2; done; exit 1'
|
||||||
|
ansible k3s_server -i "$inventory" -b -m ansible.builtin.command -a 'k3s kubectl get nodes -o wide'
|
||||||
|
ansible k3s_server -i "$inventory" -b -m ansible.builtin.command -a 'k3s kubectl wait --for=condition=Ready nodes --all --timeout=60s'
|
||||||
|
log "all checks passed"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$action" in
|
||||||
|
up)
|
||||||
|
ensure_host
|
||||||
|
ensure_base_image
|
||||||
|
for node in "${nodes[@]}"; do create_vm "$node"; done
|
||||||
|
ip1=$(node_ip "${nodes[0]}"); ip2=$(node_ip "${nodes[1]}")
|
||||||
|
log "node1=$ip1 node2=$ip2"
|
||||||
|
wait_ping node1 "$ip1"
|
||||||
|
wait_ping node2 "$ip2"
|
||||||
|
wait_ssh "$ip1"; wait_ssh "$ip2"
|
||||||
|
for node in "${created_nodes[@]}"; do
|
||||||
|
if [[ "$node" != "${nodes[0]}" ]]; then
|
||||||
|
ssh -i "$key" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="$known_hosts" \
|
||||||
|
"fedora@$ip1" "sudo -n k3s kubectl delete node '$node' --ignore-not-found=true"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
write_inventory "$ip1" "$ip2"
|
||||||
|
K3S_TOKEN=$(<"$token_file") "$script_dir/bootstrap.sh" "$inventory"
|
||||||
|
check_lab
|
||||||
|
;;
|
||||||
|
check) check_lab ;;
|
||||||
|
stop) for node in "${nodes[@]}"; do sudo virsh shutdown "$node" >/dev/null 2>&1 || true; done ;;
|
||||||
|
destroy)
|
||||||
|
for node in "${nodes[@]}"; do sudo virsh destroy "$node" >/dev/null 2>&1 || true; sudo virsh undefine "$node" --remove-all-storage; done
|
||||||
|
;;
|
||||||
|
*) fail "usage: $0 [up|check|stop|destroy]" ;;
|
||||||
|
esac
|
||||||
24
scripts/provision/manual-checklist.md
Normal file
24
scripts/provision/manual-checklist.md
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Manual Fedora 44 checklist
|
||||||
|
|
||||||
|
Use the values in `inventory.yml` as the source of truth. The manual result must
|
||||||
|
match the Ansible result: one server named `node1`, one agent named `node2`, the
|
||||||
|
same pinned k3s version, and both nodes reporting Ready.
|
||||||
|
|
||||||
|
1. Install Fedora Server 44 on both hosts, apply system updates, assign stable
|
||||||
|
addresses, and confirm SSH access with sudo privileges.
|
||||||
|
2. Install `curl`, `firewalld`, and `policycoreutils-python-utils` on both hosts.
|
||||||
|
3. Enable firewalld. Trust the configured pod and service CIDRs, then allow
|
||||||
|
`8472/udp` and `10250/tcp` between the two nodes. Allow `6443/tcp` to node 1
|
||||||
|
from node 2 and authorized operator networks.
|
||||||
|
4. Generate a cluster token in a secret manager. Never place it in shell
|
||||||
|
history, an inventory file, or this repository.
|
||||||
|
5. Download the official installer from `https://get.k3s.io`. On node 1, install
|
||||||
|
the pinned version in server mode with its stable node IP.
|
||||||
|
6. On node 2, run the same pinned installer in agent mode with node 1's
|
||||||
|
`https://ADDRESS:6443` URL and the runtime cluster token.
|
||||||
|
7. Enable and start `k3s.service` on node 1 and `k3s-agent.service` on node 2.
|
||||||
|
8. On node 1, run `k3s kubectl get nodes -o wide` and verify both nodes are
|
||||||
|
Ready before installing platform components.
|
||||||
|
|
||||||
|
Prefer the playbook for the actual command details. This checklist deliberately
|
||||||
|
does not encourage copying a cluster token into an interactive command line.
|
||||||
14
scripts/provision/nereus-network.service
Normal file
14
scripts/provision/nereus-network.service
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Activate the Nereus NetworkManager profile
|
||||||
|
After=NetworkManager.service cloud-init-local.service
|
||||||
|
Requires=NetworkManager.service
|
||||||
|
Before=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/nmcli connection reload
|
||||||
|
ExecStart=/usr/bin/nmcli connection up nereus-static
|
||||||
|
RemainAfterExit=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=network-online.target
|
||||||
22
scripts/provision/nereus-node1.nmconnection
Normal file
22
scripts/provision/nereus-node1.nmconnection
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
[connection]
|
||||||
|
id=nereus-static
|
||||||
|
uuid=9b589a0e-fca8-4a73-82eb-e42601dbfa01
|
||||||
|
type=ethernet
|
||||||
|
interface-name=ens2
|
||||||
|
autoconnect=true
|
||||||
|
autoconnect-priority=999
|
||||||
|
|
||||||
|
[ethernet]
|
||||||
|
mac-address=52:54:00:6E:01:01
|
||||||
|
|
||||||
|
[ipv4]
|
||||||
|
address1=192.168.122.10/24
|
||||||
|
dns=192.168.122.1;
|
||||||
|
gateway=192.168.122.1
|
||||||
|
method=manual
|
||||||
|
|
||||||
|
[ipv6]
|
||||||
|
addr-gen-mode=default
|
||||||
|
method=disabled
|
||||||
|
|
||||||
|
[proxy]
|
||||||
22
scripts/provision/nereus-node2.nmconnection
Normal file
22
scripts/provision/nereus-node2.nmconnection
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
[connection]
|
||||||
|
id=nereus-static
|
||||||
|
uuid=9b589a0e-fca8-4a73-82eb-e42601dbfa02
|
||||||
|
type=ethernet
|
||||||
|
interface-name=ens2
|
||||||
|
autoconnect=true
|
||||||
|
autoconnect-priority=999
|
||||||
|
|
||||||
|
[ethernet]
|
||||||
|
mac-address=52:54:00:6E:01:02
|
||||||
|
|
||||||
|
[ipv4]
|
||||||
|
address1=192.168.122.11/24
|
||||||
|
dns=192.168.122.1;
|
||||||
|
gateway=192.168.122.1
|
||||||
|
method=manual
|
||||||
|
|
||||||
|
[ipv6]
|
||||||
|
addr-gen-mode=default
|
||||||
|
method=disabled
|
||||||
|
|
||||||
|
[proxy]
|
||||||
3
scripts/provision/requirements.yml
Normal file
3
scripts/provision/requirements.yml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
---
|
||||||
|
collections:
|
||||||
|
- name: ansible.posix
|
||||||
68
scripts/provision/roles/common/tasks/main.yml
Normal file
68
scripts/provision/roles/common/tasks/main.yml
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
---
|
||||||
|
- name: Require a node address
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- k3s_node_ip is defined
|
||||||
|
- k3s_node_ip | string | length > 0
|
||||||
|
- k3s_node_cidr is defined
|
||||||
|
- k3s_node_cidr | string | length > 0
|
||||||
|
- k3s_operator_cidrs is defined
|
||||||
|
- k3s_operator_cidrs | length > 0
|
||||||
|
|
||||||
|
- name: Install host dependencies
|
||||||
|
ansible.builtin.dnf:
|
||||||
|
name:
|
||||||
|
- curl
|
||||||
|
- firewalld
|
||||||
|
- policycoreutils-python-utils
|
||||||
|
state: present
|
||||||
|
|
||||||
|
- name: Enable firewalld
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: firewalld
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
|
|
||||||
|
- name: Trust the k3s pod network
|
||||||
|
ansible.posix.firewalld:
|
||||||
|
source: "{{ k3s_cluster_cidr }}"
|
||||||
|
zone: trusted
|
||||||
|
permanent: true
|
||||||
|
immediate: true
|
||||||
|
state: enabled
|
||||||
|
|
||||||
|
- name: Trust the k3s service network
|
||||||
|
ansible.posix.firewalld:
|
||||||
|
source: "{{ k3s_service_cidr }}"
|
||||||
|
zone: trusted
|
||||||
|
permanent: true
|
||||||
|
immediate: true
|
||||||
|
state: enabled
|
||||||
|
|
||||||
|
- name: Remove globally open node-to-node k3s ports
|
||||||
|
ansible.posix.firewalld:
|
||||||
|
port: "{{ item }}"
|
||||||
|
permanent: true
|
||||||
|
immediate: true
|
||||||
|
state: disabled
|
||||||
|
loop:
|
||||||
|
- 8472/udp
|
||||||
|
- 10250/tcp
|
||||||
|
|
||||||
|
- name: Allow node-to-node k3s ports from the node network
|
||||||
|
ansible.posix.firewalld:
|
||||||
|
rich_rule: 'rule family="ipv4" source address="{{ k3s_node_cidr }}" port port="{{ item.port }}" protocol="{{ item.protocol }}" accept'
|
||||||
|
permanent: true
|
||||||
|
immediate: true
|
||||||
|
state: enabled
|
||||||
|
loop:
|
||||||
|
- {port: "8472", protocol: udp}
|
||||||
|
- {port: "10250", protocol: tcp}
|
||||||
|
|
||||||
|
- name: Download the official k3s installer
|
||||||
|
ansible.builtin.get_url:
|
||||||
|
url: https://get.k3s.io
|
||||||
|
dest: /var/tmp/k3s-install.sh
|
||||||
|
mode: "0755"
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
22
scripts/provision/roles/k3s_agent/tasks/main.yml
Normal file
22
scripts/provision/roles/k3s_agent/tasks/main.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
---
|
||||||
|
- name: Require the runtime cluster token
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that: lookup('ansible.builtin.env', 'K3S_TOKEN') | length >= 32
|
||||||
|
fail_msg: "K3S_TOKEN must contain at least 32 characters."
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Install or reconcile the k3s agent
|
||||||
|
ansible.builtin.command: /var/tmp/k3s-install.sh
|
||||||
|
environment:
|
||||||
|
INSTALL_K3S_VERSION: "{{ k3s_version }}"
|
||||||
|
INSTALL_K3S_EXEC: "agent --node-ip {{ k3s_node_ip }}"
|
||||||
|
K3S_URL: "https://{{ hostvars[groups['k3s_server'][0]].k3s_node_ip }}:6443"
|
||||||
|
K3S_TOKEN: "{{ lookup('ansible.builtin.env', 'K3S_TOKEN') }}"
|
||||||
|
changed_when: false
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Enable the k3s agent
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: k3s-agent
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
36
scripts/provision/roles/k3s_server/tasks/main.yml
Normal file
36
scripts/provision/roles/k3s_server/tasks/main.yml
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
---
|
||||||
|
- name: Require the runtime cluster token
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that: lookup('ansible.builtin.env', 'K3S_TOKEN') | length >= 32
|
||||||
|
fail_msg: "K3S_TOKEN must contain at least 32 characters."
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Remove the globally open k3s API port
|
||||||
|
ansible.posix.firewalld:
|
||||||
|
port: 6443/tcp
|
||||||
|
permanent: true
|
||||||
|
immediate: true
|
||||||
|
state: disabled
|
||||||
|
|
||||||
|
- name: Allow the k3s API from node and operator networks
|
||||||
|
ansible.posix.firewalld:
|
||||||
|
rich_rule: 'rule family="ipv4" source address="{{ item }}" port port="6443" protocol="tcp" accept'
|
||||||
|
permanent: true
|
||||||
|
immediate: true
|
||||||
|
state: enabled
|
||||||
|
loop: "{{ [k3s_node_cidr] + k3s_operator_cidrs }}"
|
||||||
|
|
||||||
|
- name: Install or reconcile the k3s server
|
||||||
|
ansible.builtin.command: /var/tmp/k3s-install.sh
|
||||||
|
environment:
|
||||||
|
INSTALL_K3S_VERSION: "{{ k3s_version }}"
|
||||||
|
INSTALL_K3S_EXEC: "server --node-ip {{ k3s_node_ip }} --write-kubeconfig-mode 0640"
|
||||||
|
K3S_TOKEN: "{{ lookup('ansible.builtin.env', 'K3S_TOKEN') }}"
|
||||||
|
changed_when: false
|
||||||
|
no_log: true
|
||||||
|
|
||||||
|
- name: Enable the k3s server
|
||||||
|
ansible.builtin.systemd_service:
|
||||||
|
name: k3s
|
||||||
|
enabled: true
|
||||||
|
state: started
|
||||||
13
scripts/provision/roles/validation/tasks/main.yml
Normal file
13
scripts/provision/roles/validation/tasks/main.yml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
---
|
||||||
|
- name: Wait for both nodes to report Ready
|
||||||
|
ansible.builtin.command: k3s kubectl wait --for=condition=Ready nodes --all --timeout=180s
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Read the node list
|
||||||
|
ansible.builtin.command: k3s kubectl get nodes -o wide
|
||||||
|
register: node_list
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Show the resulting cluster
|
||||||
|
ansible.builtin.debug:
|
||||||
|
var: node_list.stdout_lines
|
||||||
38
scripts/provision/site.yml
Normal file
38
scripts/provision/site.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
---
|
||||||
|
- name: Prepare Fedora k3s nodes
|
||||||
|
hosts: all
|
||||||
|
become: true
|
||||||
|
gather_facts: true
|
||||||
|
pre_tasks:
|
||||||
|
- name: Require Fedora 44
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- ansible_distribution == "Fedora"
|
||||||
|
- ansible_distribution_major_version == "44"
|
||||||
|
fail_msg: "This playbook supports Fedora 44 only."
|
||||||
|
- name: Require one server and at least one agent
|
||||||
|
ansible.builtin.assert:
|
||||||
|
that:
|
||||||
|
- groups['k3s_server'] | length == 1
|
||||||
|
- groups['k3s_agent'] | length >= 1
|
||||||
|
run_once: true
|
||||||
|
roles:
|
||||||
|
- common
|
||||||
|
|
||||||
|
- name: Configure the k3s server
|
||||||
|
hosts: k3s_server
|
||||||
|
become: true
|
||||||
|
roles:
|
||||||
|
- k3s_server
|
||||||
|
|
||||||
|
- name: Configure k3s agents
|
||||||
|
hosts: k3s_agent
|
||||||
|
become: true
|
||||||
|
roles:
|
||||||
|
- k3s_agent
|
||||||
|
|
||||||
|
- name: Validate the cluster
|
||||||
|
hosts: k3s_server
|
||||||
|
become: true
|
||||||
|
roles:
|
||||||
|
- validation
|
||||||
Loading…
Add table
Reference in a new issue