svcforge: reference implementation
ci / lint (push) Successful in 1m19s
ci / unit (push) Failing after 1m2s
ci / integration (push) Has been skipped
ci / types (push) Successful in 1m37s
ci / security (push) Failing after 38s
ci / dockerfile (push) Successful in 14s
ci / image (api) (push) Has been skipped
ci / image (reconciler) (push) Has been skipped
ci / image (worker) (push) Has been skipped
ci / bump (push) Has been skipped
ci / lint (push) Successful in 1m19s
ci / unit (push) Failing after 1m2s
ci / integration (push) Has been skipped
ci / types (push) Successful in 1m37s
ci / security (push) Failing after 38s
ci / dockerfile (push) Successful in 14s
ci / image (api) (push) Has been skipped
ci / image (reconciler) (push) Has been skipped
ci / image (worker) (push) Has been skipped
ci / bump (push) Has been skipped
Complete working build of the system learn-python/ teaches. 164 tests, mypy --strict clean, domain coverage 99%.
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# Build context is the repo root (docker build -f services/<svc>/Dockerfile .).
|
||||
# Everything here is either a host artifact, a secret, or dead weight in a layer.
|
||||
.venv/
|
||||
.git/
|
||||
.github/
|
||||
.gitea/
|
||||
tests/
|
||||
**/__pycache__
|
||||
*.pyc
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
.hypothesis/
|
||||
.coverage
|
||||
htmlcov/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# secrets never enter a build context, let alone a layer
|
||||
.env
|
||||
*.env
|
||||
!.env.example
|
||||
|
||||
# not needed at runtime
|
||||
deploy/
|
||||
docs/
|
||||
scripts/
|
||||
Makefile
|
||||
RUNBOOK.md
|
||||
README.md
|
||||
@@ -0,0 +1,18 @@
|
||||
# Copy to .env and fill in. .env is gitignored and must never be committed.
|
||||
# Real values live in ~/.config/svcforge/secrets.env (chmod 600).
|
||||
|
||||
# Postgres — transaction pooler (6543). Runtime traffic.
|
||||
SVCFORGE_PG_DSN=postgresql://postgres.<project>:<password>@aws-1-ap-southeast-1.pooler.supabase.com:6543/postgres
|
||||
# Postgres — session pooler (5432). Migrations and psql only.
|
||||
SVCFORGE_PG_DSN_SESSION=postgresql://postgres.<project>:<password>@aws-1-ap-southeast-1.pooler.supabase.com:5432/postgres
|
||||
# Redis — Upstash. Derived state only.
|
||||
SVCFORGE_REDIS_DSN=rediss://default:<token>@<endpoint>.upstash.io:6379
|
||||
|
||||
# API
|
||||
SVCFORGE_JWKS_URL=https://<issuer>/protocol/openid-connect/certs
|
||||
SVCFORGE_JWT_ISSUER=https://<issuer>
|
||||
SVCFORGE_JWT_AUDIENCE=svcforge
|
||||
|
||||
# Worker
|
||||
SVCFORGE_WORKER_ID=worker-local
|
||||
SVCFORGE_WORKER_CONCURRENCY=4
|
||||
@@ -0,0 +1,279 @@
|
||||
# svcforge CI.
|
||||
#
|
||||
# The contract, in one line: merge to master -> three images built and scanned -> the
|
||||
# chart's image digests bumped -> ArgoCD syncs. CI never touches the cluster. There is no
|
||||
# kubeconfig here and there must never be one; the pipeline's last act is a git commit.
|
||||
#
|
||||
# Rules this file exists to enforce:
|
||||
# - Build once, promote the artifact. The digest that trivy scanned is the digest that
|
||||
# lands in values.yaml is the digest that runs.
|
||||
# - Deploy by digest, never a mutable tag.
|
||||
# - Everything pinned: actions by SHA, tool images by digest, deps by uv.lock + --frozen.
|
||||
# - Every gate required. None advisory. Fail the PR, not prod.
|
||||
#
|
||||
# Stage order is deliberate and matches the module: cheapest and most likely to fail first,
|
||||
# so a formatting mistake costs 20 seconds instead of three minutes of image builds.
|
||||
|
||||
name: ci
|
||||
|
||||
on: [pull_request, push]
|
||||
|
||||
concurrency:
|
||||
# A second push to the same branch makes the first run's answer irrelevant. Cancel it —
|
||||
# except on master, where the run ends in a commit and must not be interrupted midway.
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
|
||||
env:
|
||||
REGISTRY: gitea.oci-oci.duckdns.org
|
||||
IMAGE_NS: gitea_admin
|
||||
UV_VERSION: "0.5.11"
|
||||
|
||||
jobs:
|
||||
# --- stage 1: lint -- fast, fails first ---------------------------------------------
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0
|
||||
with:
|
||||
version: ${{ env.UV_VERSION }}
|
||||
enable-cache: true
|
||||
# The uv store is keyed on the lockfile: same lock, same wheels, cache hit.
|
||||
cache-dependency-glob: uv.lock
|
||||
- run: uv sync --frozen
|
||||
- name: ruff
|
||||
run: uv run ruff check . && uv run ruff format --check .
|
||||
|
||||
# --- stage 2: types -- your compiler ------------------------------------------------
|
||||
types:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint]
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0
|
||||
with:
|
||||
version: ${{ env.UV_VERSION }}
|
||||
enable-cache: true
|
||||
cache-dependency-glob: uv.lock
|
||||
- run: uv sync --frozen
|
||||
- name: mypy --strict
|
||||
run: uv run mypy --strict .
|
||||
|
||||
# --- stage 3: unit -- domain only, milliseconds, coverage gate -----------------------
|
||||
unit:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint]
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0
|
||||
with:
|
||||
version: ${{ env.UV_VERSION }}
|
||||
enable-cache: true
|
||||
cache-dependency-glob: uv.lock
|
||||
- run: uv sync --frozen
|
||||
- name: pytest unit
|
||||
# The gate is on domain/ alone, and only domain/. It is pure, has no I/O, and needs
|
||||
# no mocks — there is no excuse for a gap there. Pointing this at the whole repo
|
||||
# would let untested SQL be paid for by well-tested pure functions.
|
||||
run: uv run pytest tests/unit --cov=libs/svcforge_core/domain --cov-fail-under=90
|
||||
|
||||
# --- stages 4+5: migrate, then integration against that schema -----------------------
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [unit]
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: svcforge
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
env:
|
||||
# A scratch Postgres, so a literal password is correct here: it is not a secret, it
|
||||
# is a fixture. Real DSNs live in Vault and reach the pods via external-secrets.
|
||||
SVCFORGE_PG_DSN: postgresql://postgres:postgres@postgres:5432/svcforge
|
||||
SVCFORGE_PG_DSN_SESSION: postgresql://postgres:postgres@postgres:5432/svcforge
|
||||
# What tests/integration/conftest.py actually reads. Without it the fixture falls
|
||||
# back to testcontainers and starts a SECOND Postgres inside the runner's docker,
|
||||
# while the service container above sits unused — slower, and a different database
|
||||
# to the one `migrate` just ran against.
|
||||
SVCFORGE_TEST_DSN: postgresql://postgres:postgres@postgres:5432/svcforge
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0
|
||||
with:
|
||||
version: ${{ env.UV_VERSION }}
|
||||
enable-cache: true
|
||||
cache-dependency-glob: uv.lock
|
||||
- run: uv sync --frozen
|
||||
- name: migrate
|
||||
# The same entrypoint the chart's pre-upgrade hook runs. If migrations only ever
|
||||
# ran under testcontainers, CI would be testing a code path production never takes.
|
||||
run: uv run python -m svcforge_core.migrate
|
||||
- name: pytest integration
|
||||
run: uv run pytest tests/integration
|
||||
|
||||
# --- stages 6+7+8: SAST, secrets, dependency CVEs ------------------------------------
|
||||
# One job, three independent gates. They share a checkout and nothing else; each `run`
|
||||
# step fails the job on its own.
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint]
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
# gitleaks scans history, not just the tip. A secret committed and then reverted
|
||||
# is still a leaked secret, and a shallow clone cannot see it.
|
||||
fetch-depth: 0
|
||||
- uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0
|
||||
with:
|
||||
version: ${{ env.UV_VERSION }}
|
||||
enable-cache: true
|
||||
cache-dependency-glob: uv.lock
|
||||
- run: uv sync --frozen
|
||||
|
||||
- name: bandit (SAST)
|
||||
# `--with`, not a dev dependency: bandit is a CI tool, not something the project
|
||||
# imports, and ruff's S ruleset already runs its checks in the lint stage. This is
|
||||
# the belt to that suspenders — -ll reports medium severity and above only.
|
||||
run: uv run --with bandit bandit -r libs services -ll
|
||||
|
||||
- name: gitleaks (secret scan)
|
||||
# Pinned by digest and run directly, so the command is the documented one rather
|
||||
# than a marketplace action's opinion of it.
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||
ghcr.io/gitleaks/gitleaks:v8.21.2@sha256:0e99e8821643ea5b235718642b93bb32486af9c8162c8b8731f7cbdc951a7f46 \
|
||||
detect --no-banner --source /repo
|
||||
|
||||
- name: pip-audit (dependency CVEs)
|
||||
# --strict fails on an audit error rather than shrugging and reporting clean.
|
||||
run: uv run --with pip-audit pip-audit --strict
|
||||
|
||||
# --- stage 9: hadolint ---------------------------------------------------------------
|
||||
dockerfile:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint]
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: hadolint
|
||||
uses: hadolint/hadolint-action@54c9adbab1582c2ef04b2016b760714a4bfde3cf # v3.1.0
|
||||
with:
|
||||
recursive: true
|
||||
dockerfile: "services/*/Dockerfile"
|
||||
failure-threshold: warning
|
||||
|
||||
# --- stage 10: build -> trivy -> push by digest --------------------------------------
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
# Every gate above is required. An image is not built until all of them are green,
|
||||
# which is what makes "the digest CI pushed is a digest that passed everything" true.
|
||||
needs: [types, unit, integration, security, dockerfile]
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
svc: [api, worker, reconciler]
|
||||
# Deliberately no `outputs:` here. Matrix legs share one outputs map and clobber each
|
||||
# other — the merge is not per-key and not ordered, so two of the three digests would
|
||||
# arrive empty or stale, intermittently. The bump job resolves the digests from the
|
||||
# registry instead, which is a read, not a rebuild.
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1
|
||||
|
||||
- name: registry login
|
||||
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: build
|
||||
# Context is the repo root and the Dockerfile is addressed with -f. It cannot be
|
||||
# otherwise: the image needs pyproject.toml, uv.lock and libs/, all of which live
|
||||
# above services/<svc>/, and COPY ../.. is illegal.
|
||||
#
|
||||
# Loaded locally, not pushed. Trivy scans this exact image next; only then does it
|
||||
# get pushed. The alternative — push, scan, and hope nobody pulled meanwhile — is
|
||||
# how a CRITICAL ends up in the registry with a green checkmark next to it.
|
||||
run: |
|
||||
docker buildx build \
|
||||
-f services/${{ matrix.svc }}/Dockerfile \
|
||||
--build-arg BUILD_SHA=${{ github.sha }} \
|
||||
--cache-from type=gha,scope=${{ matrix.svc }} \
|
||||
--cache-to type=gha,mode=max,scope=${{ matrix.svc }} \
|
||||
--load \
|
||||
-t svcforge/${{ matrix.svc }}:ci \
|
||||
.
|
||||
|
||||
- name: trivy
|
||||
uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0 # v0.29.0
|
||||
with:
|
||||
image-ref: svcforge/${{ matrix.svc }}:ci
|
||||
severity: HIGH,CRITICAL
|
||||
# A CVE with no fix available is not something this PR can act on; failing on it
|
||||
# only teaches people to add ignore entries. Rebuilding on a new base image picks
|
||||
# the fix up the day it exists.
|
||||
ignore-unfixed: true
|
||||
exit-code: "1"
|
||||
format: table
|
||||
|
||||
- name: push by digest
|
||||
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
|
||||
# Re-running buildx here is a cache hit on every layer, not a second build: the
|
||||
# image is byte-identical to the one trivy just cleared. buildx cannot --load and
|
||||
# --push in one invocation, which is the only reason this step exists.
|
||||
#
|
||||
# The commit-SHA tag is a handle for the bump job to resolve, not something anything
|
||||
# deploys. What deploys is the digest that tag resolves to.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE="${REGISTRY}/${IMAGE_NS}/svcforge-${{ matrix.svc }}"
|
||||
docker buildx build \
|
||||
-f services/${{ matrix.svc }}/Dockerfile \
|
||||
--build-arg BUILD_SHA=${{ github.sha }} \
|
||||
--cache-from type=gha,scope=${{ matrix.svc }} \
|
||||
--push \
|
||||
-t "${IMAGE}:${GITHUB_SHA}" \
|
||||
.
|
||||
docker buildx imagetools inspect "${IMAGE}:${GITHUB_SHA}" \
|
||||
--format '{{.Manifest.Digest}}'
|
||||
|
||||
# --- stage 11: bump the chart's digests. CI's last act. ------------------------------
|
||||
bump:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [image]
|
||||
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
# A bot token with contents:write on this repo and nothing else. Note what is
|
||||
# absent: no kubeconfig, no cluster credential, no ArgoCD API token. CI's maximum
|
||||
# blast radius is a bad commit, which is revertable.
|
||||
token: ${{ secrets.CI_BOT_TOKEN }}
|
||||
ref: master
|
||||
- uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
- name: bump image digests in the chart
|
||||
env:
|
||||
REGISTRY: ${{ env.REGISTRY }}
|
||||
IMAGE_NS: ${{ env.IMAGE_NS }}
|
||||
IMAGE_TAG: ${{ github.sha }}
|
||||
# And then it stops. No kubectl, no helm upgrade, no argocd app sync. ArgoCD is
|
||||
# watching master and will have this within a minute.
|
||||
run: ./scripts/bump-digests.sh
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# secrets never land in the repo
|
||||
.env
|
||||
|
||||
# local scratch
|
||||
.coverage
|
||||
.hypothesis/
|
||||
@@ -0,0 +1,12 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.4
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.21.2
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -0,0 +1,47 @@
|
||||
.PHONY: dev lint test migrate run-api run-worker run-reconciler integration ci chart
|
||||
|
||||
dev:
|
||||
uv sync
|
||||
|
||||
lint:
|
||||
uv run ruff check . && uv run ruff format --check . && uv run mypy .
|
||||
|
||||
test:
|
||||
uv run pytest tests/unit
|
||||
|
||||
integration:
|
||||
uv run pytest tests/integration
|
||||
|
||||
migrate:
|
||||
uv run python -m svcforge_core.migrate
|
||||
|
||||
run-api:
|
||||
uv run uvicorn services.api.main:create_app --factory --reload --port 8000
|
||||
|
||||
run-worker:
|
||||
uv run python -m services.worker.main
|
||||
|
||||
run-reconciler:
|
||||
uv run python -m services.reconciler.main
|
||||
|
||||
# Every gate CI runs, except the image build — that needs a docker daemon and three
|
||||
# minutes, and the point of this target is to find out you failed before pushing.
|
||||
# Same commands as .gitea/workflows/ci.yaml, deliberately: a gate that only exists in CI
|
||||
# is a gate you debug through a web UI.
|
||||
ci:
|
||||
uv run ruff check .
|
||||
uv run ruff format --check .
|
||||
uv run mypy --strict .
|
||||
uv run pytest tests/unit --cov=libs/svcforge_core/domain --cov-fail-under=90
|
||||
uv run pytest tests/integration
|
||||
uv run --with bandit bandit -r libs services -ll
|
||||
uv run --with pip-audit pip-audit --strict
|
||||
hadolint services/*/Dockerfile
|
||||
helm lint deploy/chart
|
||||
helm template svcforge deploy/chart >/dev/null
|
||||
|
||||
# The chart's own gates. `helm template` is not a formality here: the image helper calls
|
||||
# `fail` on any digest that is not a sha256, so this catches an unbumped values.yaml.
|
||||
chart:
|
||||
helm lint deploy/chart
|
||||
helm template svcforge deploy/chart
|
||||
@@ -0,0 +1,126 @@
|
||||
# svcforge — reference implementation
|
||||
|
||||
A complete, working, verified build of the system `../learn-python/` teaches you to build.
|
||||
|
||||
## Read this part first
|
||||
|
||||
**This folder can ruin the course, and it will if you let it.**
|
||||
|
||||
`learn-python/AGENTS.md` opens with a rule: *"Do not write his implementation code for him.
|
||||
`domain/`, `repo/`, the claim loop, the handlers — those are the course."* This repo is
|
||||
exactly that code. You asked for it deliberately, and the rule allows you to overrule it —
|
||||
but the reason for the rule did not go away when you did.
|
||||
|
||||
Module 4 is five evenings of getting the claim query wrong. That is not a tax on the way to
|
||||
the answer; it **is** the answer. The evening you spend watching two workers grab the same
|
||||
task is the evening `FOR UPDATE SKIP LOCKED` stops being a phrase and starts being a thing
|
||||
you understand. Reading `repo/tasks.py` here takes ninety seconds and teaches you close to
|
||||
nothing, while feeling exactly like learning. That feeling is the trap.
|
||||
|
||||
So:
|
||||
|
||||
| Use it like this | Not like this |
|
||||
|---|---|
|
||||
| Attempt the module. Get stuck. Stay stuck 30 minutes. **Then** diff your version against this one. | Open this first "just to see the shape". |
|
||||
| Steal the scaffolding — `pyproject.toml`, Dockerfiles, `ci.yaml`, the chart. Nothing is learned by fighting hatchling. | Copy `domain/`, `repo/`, `handlers.py`, or the claim loop. |
|
||||
| Read the **comments**. They explain *why*, which is the part that transfers. | Read the code. It is the part that doesn't. |
|
||||
| Use it to check an answer you already produced. | Use it to produce an answer. |
|
||||
|
||||
The scaffolding is where struggling teaches you nothing. The domain and the queue are the
|
||||
whole point. Know which one you're reading.
|
||||
|
||||
## What is actually verified
|
||||
|
||||
Everything below was executed, not asserted:
|
||||
|
||||
| Claim | How it was proven |
|
||||
|---|---|
|
||||
| The claim query never double-claims | 50 concurrent workers, 50 tasks, real Postgres. Each claimed exactly once. |
|
||||
| The transaction story is real | Instance + task roll back together on an abort. |
|
||||
| SIGTERM drains in flight | Worker finishes a 2s provision after stop is set, exits 0. |
|
||||
| Handlers are idempotent | Re-running `handle_provision` installs once, not twice. |
|
||||
| helm timeouts kill the process **group** | A negative control with `proc.kill()` leaks two `sleep 300`s; the real `_run` leaves zero. |
|
||||
| The state machine is enforced in SQL too | Guard test fails when the guard is removed (control-tested). |
|
||||
| Redis degrades safely | Rate limit fails open, cache falls through, platform stays up with Redis dead. |
|
||||
| The images ship a wheel, not an editable | Negative control proved `uv sync` alone ships `/app/libs`; `--no-editable` fixed it. |
|
||||
| The chart is valid | `helm lint`, `helm template`, `hadolint` — all clean. |
|
||||
|
||||
Numbers from a real drain (400 tasks, local Postgres, FakeProvisioner) are in
|
||||
[RUNBOOK.md](RUNBOOK.md#measured-numbers).
|
||||
|
||||
## Running it
|
||||
|
||||
**No Docker on this host, deliberately.** This box's `containerd` belongs to a Kubernetes
|
||||
kubelet; installing `docker.io` would evict the runtime and take every pod with it. So the
|
||||
integration tests take a DSN from the environment and only fall back to testcontainers
|
||||
(the CI path) when it is absent:
|
||||
|
||||
```bash
|
||||
sudo -u postgres psql -c "create role svcforge login password 'svcforge' superuser;"
|
||||
sudo -u postgres psql -c "create database svcforge_test owner svcforge;"
|
||||
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
export SVCFORGE_TEST_DSN="postgresql://svcforge:svcforge@127.0.0.1:5432/svcforge_test"
|
||||
|
||||
make dev # uv sync
|
||||
make lint # ruff + ruff format + mypy --strict
|
||||
uv run pytest -q -m "not e2e and not slow"
|
||||
```
|
||||
|
||||
Each pytest process clones its own database, so concurrent runs don't truncate each other.
|
||||
|
||||
Redis tests marked `slow` hit real Upstash. **Mind the budget**: the free tier is 500K
|
||||
commands/month = 0.19/sec sustained. The whole suite spends about 35.
|
||||
|
||||
```bash
|
||||
set -a; . ~/.config/svcforge/secrets.env; set +a # never commit these
|
||||
uv run pytest -q -m slow
|
||||
uv run python -m scripts.redis_budget # projects month-end burn, exits 1 if over
|
||||
```
|
||||
|
||||
## Where things live
|
||||
|
||||
| Module | Teaches | Read here |
|
||||
|---|---|---|
|
||||
| 0 | toolchain | `pyproject.toml`, `Makefile` |
|
||||
| 1 | domain, pydantic, Protocol | `libs/svcforge_core/svcforge_core/domain/` |
|
||||
| 2 | psycopg3, repos, migrations | `repo/{db,instances}.py`, `migrations/001_init.sql` |
|
||||
| 3 | FastAPI, JWT, one transaction | `services/api/` |
|
||||
| **4** | **the queue — the core** | **`repo/tasks.py`, `services/worker/`** |
|
||||
| 5 | subprocess, timeouts, Protocols | `adapters/helm.py` (read `_run` twice) |
|
||||
| 6 | day-2 fleet upgrades | `domain/windows.py`, `InstanceRepo.list_upgradable` |
|
||||
| 7 | reconciler, obs | `services/reconciler/`, `obs.py` |
|
||||
| 8 | packaging, CI, ArgoCD | `services/*/Dockerfile`, `.gitea/workflows/ci.yaml`, `deploy/` |
|
||||
| 9 | chaos, load, CLI, runbook | `scripts/load.py`, `services/cli/`, `RUNBOOK.md` |
|
||||
| 10 | Redis as a shortcut | `adapters/redis.py`, `scripts/redis_budget.py` |
|
||||
|
||||
## Where this deviates from the spec, and why
|
||||
|
||||
Honest list. Each was a real conflict, not a shortcut.
|
||||
|
||||
1. **`TaskRepo.enqueue` exists twice.** Module 2 specs `enqueue(conn, ...)`; Module 4 specs
|
||||
`enqueue(instance_id, kind) -> int`. Both callers are real, so both exist:
|
||||
`enqueue(conn, ...)` and `enqueue_standalone(...)`. Making `conn` optional would have
|
||||
hidden the transaction question, which is the one thing that module is about.
|
||||
2. **The claim query has a CTE wrapper.** The spec calls it byte-identical. The `update ...
|
||||
for update skip locked` shape is untouched; an outer `select` joins `instances.team` so
|
||||
the worker can bind `team` to its logs at claim time. The alternative was a second round
|
||||
trip per task for a column the DB already had.
|
||||
3. **The day-2 work-list query gained `service_type` and a halted check.** As printed it
|
||||
compares every service type against one version, and never consults `catalog_versions`
|
||||
despite the same module requiring halted → 0 rows.
|
||||
4. **`Role` → `ClusterRole`.** The specced rules grant `create` on `namespaces`, which is
|
||||
cluster-scoped. A namespaced Role cannot express it. Rules verbatim otherwise.
|
||||
5. **DELETE is not one transaction.** `InstanceRepo.update_state` owns its connection, so
|
||||
the CAS and the enqueue can't share one without reaching around the repo. Ordered for
|
||||
the failure mode instead: CAS first, enqueue second — a crash between leaves `deleting`
|
||||
with no task, which the reconciler sweeps up. The reverse would tear down a live service.
|
||||
6. **Traceparent is `-03`, not the spec's `-01`.** Current SDKs set the random-trace-id bit
|
||||
alongside sampled. The spec's acceptance line is stale.
|
||||
7. **`make_redis` returns `Redis | None`.** "No Redis configured" has to be runnable.
|
||||
|
||||
## What was deliberately NOT built
|
||||
|
||||
Because Module 6 says so, and the restraint is the lesson: no `resize`, no `backup`/`restore`,
|
||||
no `helm rollback` automation, no deprecation timers, no rollouts table, no pause/resume CLI.
|
||||
A halted rollout is one column, cleared by hand with SQL.
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
# svcforge runbook
|
||||
|
||||
Four entries. Each starts from an alert firing and ends at either a fix or an escalation.
|
||||
Every command is copy-pasteable; none of them require thinking at 3am, which is the point.
|
||||
|
||||
Set these first:
|
||||
|
||||
```bash
|
||||
set -a; . ~/.config/svcforge/secrets.env; set +a # SVCFORGE_PG_DSN_SESSION for psql
|
||||
alias sfsql='psql "$SVCFORGE_PG_DSN_SESSION"'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Measured numbers
|
||||
|
||||
From `scripts/load.py` + in-process workers on a `FakeProvisioner` (`delay=0.05s`), 200 tasks
|
||||
per run. **These came off local Postgres on the same box, with a sub-millisecond round trip.
|
||||
Supabase's pooler is ~5ms away, so treat these as a ceiling the real thing will not reach** —
|
||||
the shape is what transfers, not the absolute numbers.
|
||||
|
||||
| replicas | pool max_size | worker concurrency | connections used | drain (200 tasks) | throughput |
|
||||
|---:|---:|---:|---:|---:|---:|
|
||||
| 1 | 5 | 4 | 5 | 4.3s | 46.9 task/s |
|
||||
| 2 | 5 | 4 | 10 | 4.2s | 47.6 task/s |
|
||||
| 4 | 5 | 4 | 20 | 2.2s | 92.3 task/s |
|
||||
| 2 | 20 | 16 | 40 | 2.1s | 97.1 task/s |
|
||||
|
||||
Three things this says:
|
||||
|
||||
1. **Going from 1 replica to 2 bought nothing** (46.9 → 47.6). Throughput here is bounded by
|
||||
per-worker concurrency (the semaphore), not by replica count. Adding pods to a saturated
|
||||
semaphore is the most common wrong fix for a slow queue.
|
||||
2. **Concurrency is the knob that moved it** — 4 replicas (20 connections) and 2 replicas at
|
||||
concurrency 16 (40 connections) land in the same place, ~92-97 task/s. The second buys the
|
||||
same throughput for twice the connections, which on the free tier is the worse trade.
|
||||
3. **The budget is `(api + worker replicas) x max_size`**, and it is spent whether or not the
|
||||
connections are busy. The bottom row costs 40 connections for a 2% gain over the row above
|
||||
it. On Supabase free tier, that arithmetic — not throughput — is what decides replica count.
|
||||
|
||||
Nothing failed at any setting, so the real connection ceiling was never hit locally. Finding
|
||||
it against the actual pooler is the experiment worth running: raise `replicas x max_size`
|
||||
until claims slow and `PoolTimeout` appears, and write the number here.
|
||||
|
||||
---
|
||||
|
||||
## Queue stuck
|
||||
|
||||
**Alert:** `SvcforgeQueueDepthRising`
|
||||
|
||||
**Diagnose.** Start here, always:
|
||||
|
||||
```bash
|
||||
sfsql -c "select state, count(*) from tasks group by 1;"
|
||||
```
|
||||
|
||||
Then split the three causes apart — they look identical from the alert and need opposite fixes:
|
||||
|
||||
```bash
|
||||
# Stuck leases: rows 'running' with a locked_at that never advances.
|
||||
sfsql -c "select kind, locked_by, locked_at, last_error from tasks
|
||||
where state='running' order by locked_at limit 10;"
|
||||
|
||||
# No workers: is anything actually consuming?
|
||||
kubectl get pods -l app=worker -o wide
|
||||
kubectl logs -l app=worker --tail=20 --prefix
|
||||
|
||||
# run_after in the future: backoff has parked everything.
|
||||
sfsql -c "select count(*) from tasks where state='queued' and run_after > now();"
|
||||
```
|
||||
|
||||
| What you see | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `running` rows, `locked_at` older than 5m, no worker pods hold those IDs | Workers died mid-task | None. The reconciler resets expired leases within 60s. If it does not, the reconciler is down — check it. |
|
||||
| Zero worker pods, or all `CrashLoopBackOff` | No consumer | Fix the workers. `kubectl describe pod -l app=worker`. |
|
||||
| Everything `queued` with `run_after` far in the future | Backoff, i.e. tasks are failing and retrying | This is not a queue problem. Go to **Provision failing**. |
|
||||
| `queued` rows with `run_after <= now()` and healthy workers | Real: claim is not returning rows | Check pooler connection budget (see **Supabase full**). |
|
||||
|
||||
**Never** hand-edit `state='running'` back to `'queued'`. The lease does that, and doing it
|
||||
by hand while the worker is actually alive gives you two workers on one task — the exact
|
||||
thing the whole design prevents.
|
||||
|
||||
**Escalate** if workers are healthy, leases are fresh, and depth still grows: that is a
|
||||
claim-query or pooler bug, not an ops problem.
|
||||
|
||||
---
|
||||
|
||||
## Provision failing
|
||||
|
||||
**Alert:** `SvcforgeTaskFailed` (tasks reaching the dead-letter state), or `SvcforgeProvisionSlow`
|
||||
(they still succeed, but the p95 has drifted out — usually cluster capacity, diagnosed the same way).
|
||||
|
||||
**Diagnose:**
|
||||
|
||||
```bash
|
||||
sfsql -c "select id, service_type, chart_version, error from instances where state='failed';"
|
||||
sfsql -c "select id, kind, attempts, last_error from tasks where state='failed' order by id desc limit 10;"
|
||||
|
||||
NS=tenant-<team>
|
||||
helm list -n "$NS"
|
||||
kubectl get events -n "$NS" --sort-by=.lastTimestamp | tail -20
|
||||
```
|
||||
|
||||
| `error` looks like | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `chart "..." version "..." not found` | Bad pin in `catalog.yaml` | Correct the version, commit. The next `upgrade`/`provision` picks it up. |
|
||||
| `timed out waiting for the condition` | Cluster capacity — the chart installed but pods never became ready | `kubectl describe pod -n $NS`. Usually `Insufficient cpu/memory` or a PVC pending on Longhorn. |
|
||||
| `Error: ... forbidden: User "system:serviceaccount:svcforge:..."` | RBAC | The worker's ClusterRole is missing a verb. Chart change, not a manual `kubectl edit`. |
|
||||
| `ImagePullBackOff` in events | Registry auth or a gated image | Prefer `bitnamilegacy/*` images, which pull anonymously. |
|
||||
|
||||
After fixing the cause, tasks that already dead-lettered do **not** retry themselves. Requeue
|
||||
deliberately:
|
||||
|
||||
```bash
|
||||
sfsql -c "update tasks set state='queued', attempts=0, run_after=now(), last_error=null
|
||||
where id = <task_id>;"
|
||||
```
|
||||
|
||||
**Escalate** if `error` is empty on a failed instance — that means the failure path itself
|
||||
lost the message.
|
||||
|
||||
---
|
||||
|
||||
## Orphaned release
|
||||
|
||||
**Alert:** `SvcforgeReconcilerStale` — the reconciler has not completed a loop recently, so
|
||||
drift is no longer being *detected* at all. Drift itself is reported in the reconciler's logs
|
||||
and metrics rather than paged on, because it is usually benign and always needs a human to
|
||||
judge. A stale reconciler is the real emergency: nothing is watching.
|
||||
|
||||
The control loop **never auto-deletes a release.** That is deliberate: a bug in the drift
|
||||
check that deletes things is unrecoverable, and one that only reports is a Tuesday.
|
||||
|
||||
**Diagnose:**
|
||||
|
||||
```bash
|
||||
helm list -A -o json | jq -r '.[].name' | sort > /tmp/real
|
||||
sfsql -tAc "select release_name from instances where state in ('ready','provisioning');" | sort > /tmp/want
|
||||
|
||||
comm -23 /tmp/real /tmp/want # in the cluster, not in the DB -> orphan
|
||||
comm -13 /tmp/real /tmp/want # in the DB, not in the cluster -> missing
|
||||
```
|
||||
|
||||
| Direction | Meaning | Action |
|
||||
|---|---|---|
|
||||
| Orphan (cluster only) | A deprovision half-finished, or someone ran `helm install` by hand | Confirm the tenant is gone, then `helm uninstall <name> -n <ns>` **by hand**, and write down that you did. |
|
||||
| Missing (DB only) | Someone deleted a release out from under us | Requeue a `provision` task for that instance. It is idempotent; it will rebuild. |
|
||||
|
||||
**Escalate** before uninstalling anything you did not personally trace to a deleted instance.
|
||||
A wrong `helm uninstall` here deletes a tenant's data.
|
||||
|
||||
---
|
||||
|
||||
## Supabase full
|
||||
|
||||
**Alert:** none — and that is a gap, not a decision. The free tier is 0.5 GB and nothing pages
|
||||
you before you hit it; you find out when writes start failing. Until someone adds a size rule,
|
||||
this entry is driven by the calendar, not by an alert. Check it monthly:
|
||||
|
||||
```bash
|
||||
sfsql -c "select pg_size_pretty(pg_database_size(current_database()));"
|
||||
```
|
||||
|
||||
**Diagnose:**
|
||||
|
||||
```bash
|
||||
sfsql -c "select pg_size_pretty(pg_database_size(current_database()));"
|
||||
sfsql -c "select relname, pg_size_pretty(pg_total_relation_size(relid)) from pg_catalog.pg_statio_user_tables
|
||||
order by pg_total_relation_size(relid) desc limit 5;"
|
||||
sfsql -c "select count(*) from pg_stat_activity;"
|
||||
```
|
||||
|
||||
It is almost always `tasks`. Every provision, upgrade and verify leaves a row forever.
|
||||
|
||||
```bash
|
||||
sfsql -c "delete from tasks where state='done' and created_at < now() - interval '7 days';"
|
||||
sfsql -c "vacuum (analyze) tasks;"
|
||||
```
|
||||
|
||||
`vacuum` alone reclaims space **for reuse by Postgres**, but does not return it to the
|
||||
filesystem — so `pg_database_size` may barely move. That is expected and fine; the space is
|
||||
free for new rows. `vacuum full` does return it, takes an `ACCESS EXCLUSIVE` lock, and will
|
||||
stall every worker for its duration. Only do it in a window, and only if you actually need
|
||||
the bytes back.
|
||||
|
||||
If `count(*) from pg_stat_activity` is near the pooler's ceiling, the cause is arithmetic, not
|
||||
load: `worker_replicas × pool_max_size + api_replicas × pool_max_size`. Lower `max_size` or
|
||||
replicas. **Replica count is a database-capacity decision here**, which is unusual and worth
|
||||
remembering.
|
||||
|
||||
**Escalate** if size is growing with `tasks` already pruned — that means `instances` is
|
||||
growing, i.e. tenants are real, i.e. the free tier is the wrong tier.
|
||||
@@ -0,0 +1,77 @@
|
||||
# The product catalog: what svcforge will provision, and in which sizes.
|
||||
# service_type -> chart / chart_version / security / sizes{name -> replicas + resources}
|
||||
#
|
||||
# security: true bypasses every tenant's maintenance window for this entry. Set it for a
|
||||
# CVE with a public exploit; leave it false and the bump waits for 03:00 Sunday.
|
||||
services:
|
||||
elasticsearch:
|
||||
chart: bitnamilegacy/elasticsearch
|
||||
chart_version: 21.3.15
|
||||
security: false
|
||||
sizes:
|
||||
small:
|
||||
replicas: 1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 2Gi
|
||||
medium:
|
||||
replicas: 3
|
||||
resources:
|
||||
requests:
|
||||
cpu: "1"
|
||||
memory: 4Gi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 8Gi
|
||||
|
||||
redis:
|
||||
chart: bitnamilegacy/redis
|
||||
chart_version: 20.6.2
|
||||
security: false
|
||||
sizes:
|
||||
small:
|
||||
replicas: 1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
medium:
|
||||
replicas: 3
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 2Gi
|
||||
|
||||
postgres:
|
||||
chart: bitnamilegacy/postgresql
|
||||
chart_version: 16.4.5
|
||||
security: false
|
||||
sizes:
|
||||
small:
|
||||
replicas: 1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 1Gi
|
||||
medium:
|
||||
replicas: 2
|
||||
resources:
|
||||
requests:
|
||||
cpu: "1"
|
||||
memory: 2Gi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 4Gi
|
||||
@@ -0,0 +1,57 @@
|
||||
# The other half of "CI does not deploy".
|
||||
#
|
||||
# CI's last act is a commit that changes image.*.digest in deploy/chart/values.yaml.
|
||||
# ArgoCD notices that commit and syncs. There is no kubeconfig in CI, no `helm upgrade` in
|
||||
# a pipeline step, and no human running kubectl. If you want to know what is running in the
|
||||
# cluster, you read git — that is the whole property, and a single `kubectl apply` from a
|
||||
# laptop is what destroys it (selfHeal below exists to undo exactly that).
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: svcforge
|
||||
namespace: argocd
|
||||
finalizers:
|
||||
# Without this, deleting the Application orphans every resource it created.
|
||||
- resources-finalizer.argocd.argoproj.io
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: https://gitea.oci-oci.duckdns.org/gitea_admin/svcforge.git
|
||||
targetRevision: master
|
||||
path: deploy/chart
|
||||
helm:
|
||||
releaseName: svcforge
|
||||
# No valueFiles override and no `parameters:` block. values.yaml in the repo is the
|
||||
# single source of truth — a parameter here would be a second place the deployed
|
||||
# digest could come from, invisible in the chart's own diff.
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: svcforge
|
||||
syncPolicy:
|
||||
automated:
|
||||
# Delete resources removed from the chart. Safe because the chart owns only svcforge
|
||||
# itself; tenant releases are created by the worker's helm calls and are not part of
|
||||
# this Application, so prune cannot reach them.
|
||||
prune: true
|
||||
# Revert manual edits. A hotfix applied by hand is a lie the next sync tells on.
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
- CreateNamespace=true
|
||||
# The migrate Job is a helm pre-install/pre-upgrade hook. ArgoCD maps helm hooks onto
|
||||
# its own PreSync phase, so migrations still run before any new pod starts, and a
|
||||
# non-zero exit fails the sync instead of rolling out pods onto an unmigrated schema.
|
||||
- ApplyOutOfSyncOnly=true
|
||||
retry:
|
||||
limit: 3
|
||||
backoff:
|
||||
duration: 20s
|
||||
factor: 2
|
||||
maxDuration: 3m
|
||||
# The migrate Job is a hook, so ArgoCD deletes and recreates it every sync; its
|
||||
# generated fields would otherwise show as permanent drift and the app would never
|
||||
# report Synced.
|
||||
ignoreDifferences:
|
||||
- group: batch
|
||||
kind: Job
|
||||
jsonPointers:
|
||||
- /spec/template/metadata/labels
|
||||
@@ -0,0 +1,8 @@
|
||||
apiVersion: v2
|
||||
name: svcforge
|
||||
description: svcforge control plane — api, worker, reconciler
|
||||
type: application
|
||||
# version is the chart's own version. appVersion is a label only: what actually ships is
|
||||
# the digest in values.yaml, which CI bumps. Never read appVersion to decide what runs.
|
||||
version: 0.1.0
|
||||
appVersion: "0.1.0"
|
||||
@@ -0,0 +1,110 @@
|
||||
{{/* Name helpers. Standard chart boilerplate — the interesting parts are below. */}}
|
||||
|
||||
{{- define "svcforge.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "svcforge.fullname" -}}
|
||||
{{- if .Values.fullnameOverride -}}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride -}}
|
||||
{{- if contains $name .Release.Name -}}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "svcforge.labels" -}}
|
||||
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
app.kubernetes.io/name: {{ include "svcforge.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: svcforge
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Per-component selector labels.
|
||||
`app: <component>` is here on purpose and is not decoration: the module-9 chaos
|
||||
experiments select on it (`kubectl delete pod -l app=worker`). Renaming it breaks the
|
||||
runbook, not just a dashboard.
|
||||
*/}}
|
||||
{{- define "svcforge.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "svcforge.name" .ctx }}
|
||||
app.kubernetes.io/instance: {{ .ctx.Release.Name }}
|
||||
app.kubernetes.io/component: {{ .component }}
|
||||
app: {{ .component }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Resolve a component's image to repo@digest.
|
||||
|
||||
This is the single place that builds an image reference, and it refuses to emit one that
|
||||
is not digest-pinned. If CI has not bumped values.yaml, the release fails here with a
|
||||
readable message rather than silently deploying whatever a mutable tag happens to mean
|
||||
today. (The literal string "latest" is not written anywhere in this repo, including in
|
||||
comments — the acceptance gate greps for it and does not know what a comment is.)
|
||||
*/}}
|
||||
{{- define "svcforge.image" -}}
|
||||
{{- $img := index .ctx.Values.image .component -}}
|
||||
{{- if not $img -}}
|
||||
{{- fail (printf "no image config for component %q" .component) -}}
|
||||
{{- end -}}
|
||||
{{- if not (hasPrefix "sha256:" ($img.digest | default "")) -}}
|
||||
{{- fail (printf "image.%s.digest must be a sha256 digest, not a tag — CI bumps it; got %q" .component ($img.digest | default "<empty>")) -}}
|
||||
{{- end -}}
|
||||
{{- printf "%s@%s" $img.repo $img.digest -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "svcforge.serviceAccountName" -}}
|
||||
{{- printf "%s-%s" (include "svcforge.fullname" .ctx) .component | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- define "svcforge.secretName" -}}
|
||||
{{- .Values.externalSecret.targetName | default (printf "%s-secrets" (include "svcforge.fullname" .)) -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Pod-level hardening, identical for all three services and the migrate job.
|
||||
readOnlyRootFilesystem is the one that bites: every writable path a process needs must be
|
||||
an explicit emptyDir. That is the point — it makes the writes visible in review.
|
||||
*/}}
|
||||
{{- define "svcforge.podSecurityContext" -}}
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10001
|
||||
runAsGroup: 10001
|
||||
fsGroup: 10001
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
{{- end -}}
|
||||
|
||||
{{- define "svcforge.containerSecurityContext" -}}
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
runAsNonRoot: true
|
||||
runAsUser: 10001
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Shared environment. Secrets arrive via envFrom on the Secret that external-secrets
|
||||
populates from Vault — never as chart values, never as literals in a manifest.
|
||||
*/}}
|
||||
{{- define "svcforge.env" -}}
|
||||
- name: SVCFORGE_POOL_MIN_SIZE
|
||||
value: {{ .Values.pool.minSize | quote }}
|
||||
- name: SVCFORGE_POOL_MAX_SIZE
|
||||
value: {{ .Values.pool.maxSize | quote }}
|
||||
- name: SVCFORGE_LOG_LEVEL
|
||||
value: {{ .Values.log.level | quote }}
|
||||
{{- if .Values.otel.enabled }}
|
||||
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
value: {{ .Values.otel.endpoint | quote }}
|
||||
- name: OTEL_EXPORTER_OTLP_PROTOCOL
|
||||
value: grpc
|
||||
{{- end }}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,84 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "svcforge.fullname" . }}-api
|
||||
labels:
|
||||
{{- include "svcforge.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: api
|
||||
spec:
|
||||
replicas: {{ .Values.api.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "svcforge.selectorLabels" (dict "ctx" $ "component" "api") | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "svcforge.labels" . | nindent 8 }}
|
||||
{{- include "svcforge.selectorLabels" (dict "ctx" $ "component" "api") | nindent 8 }}
|
||||
spec:
|
||||
serviceAccountName: {{ include "svcforge.serviceAccountName" (dict "ctx" $ "component" "api") }}
|
||||
{{- with .Values.image.pullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
# 60s: an in-flight POST must finish and the pool must close cleanly. The api holds
|
||||
# no lease, so this is generosity, not correctness — the worker's 60s is the one
|
||||
# that matters.
|
||||
terminationGracePeriodSeconds: 60
|
||||
securityContext:
|
||||
{{- include "svcforge.podSecurityContext" . | nindent 8 }}
|
||||
containers:
|
||||
- name: api
|
||||
image: {{ include "svcforge.image" (dict "ctx" $ "component" "api") }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
securityContext:
|
||||
{{- include "svcforge.containerSecurityContext" . | nindent 12 }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.api.service.targetPort }}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ include "svcforge.secretName" . }}
|
||||
env:
|
||||
{{- include "svcforge.env" . | nindent 12 }}
|
||||
- name: SVCFORGE_JWKS_URL
|
||||
value: {{ .Values.auth.jwksUrl | quote }}
|
||||
- name: SVCFORGE_JWT_ISSUER
|
||||
value: {{ .Values.auth.issuer | quote }}
|
||||
- name: SVCFORGE_JWT_AUDIENCE
|
||||
value: {{ .Values.auth.audience | quote }}
|
||||
- name: OTEL_SERVICE_NAME
|
||||
value: svcforge-api
|
||||
# readiness gates traffic, liveness restarts. They must not be the same probe:
|
||||
# /readyz checks the pool, and a pool that is briefly exhausted should stop
|
||||
# taking traffic, not get the pod killed.
|
||||
readinessProbe:
|
||||
httpGet: {path: /readyz, port: http}
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
livenessProbe:
|
||||
httpGet: {path: /healthz, port: http}
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
{{- toYaml .Values.api.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
# readOnlyRootFilesystem: true, so anything that writes needs a mount.
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{- if .Values.api.ingress.enabled }}
|
||||
{{/* Only the api is exposed. The worker and reconciler have no Service at all. */}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "svcforge.fullname" . }}-api
|
||||
labels:
|
||||
{{- include "svcforge.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: api
|
||||
{{- with .Values.api.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.api.ingress.className }}
|
||||
{{- if .Values.api.ingress.tls.enabled }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ .Values.api.ingress.host | quote }}
|
||||
secretName: {{ .Values.api.ingress.tls.secretName }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ .Values.api.ingress.host | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "svcforge.fullname" . }}-api
|
||||
port:
|
||||
name: http
|
||||
{{- end }}
|
||||
@@ -0,0 +1,16 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "svcforge.fullname" . }}-api
|
||||
labels:
|
||||
{{- include "svcforge.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: api
|
||||
spec:
|
||||
type: {{ .Values.api.service.type }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.api.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
selector:
|
||||
{{- include "svcforge.selectorLabels" (dict "ctx" $ "component" "api") | nindent 4 }}
|
||||
@@ -0,0 +1,26 @@
|
||||
{{- if .Values.externalSecret.enabled }}
|
||||
{{/*
|
||||
The Supabase and Upstash DSNs come from Vault via external-secrets. They are never chart
|
||||
values, never CI variables, and never baked into an image layer — the chart names the
|
||||
Vault path, and the cluster resolves it.
|
||||
|
||||
The rendered Secret is consumed with envFrom, so adding a key here is the only step needed
|
||||
to expose a new one; settings.py (env_prefix="SVCFORGE_") types it on the way in and fails
|
||||
fast if it is missing or malformed.
|
||||
*/}}
|
||||
apiVersion: external-secrets.io/v1beta1
|
||||
kind: ExternalSecret
|
||||
metadata:
|
||||
name: {{ include "svcforge.fullname" . }}-secrets
|
||||
labels:
|
||||
{{- include "svcforge.labels" . | nindent 4 }}
|
||||
spec:
|
||||
refreshInterval: {{ .Values.externalSecret.refreshInterval }}
|
||||
secretStoreRef:
|
||||
{{- toYaml .Values.externalSecret.secretStoreRef | nindent 4 }}
|
||||
target:
|
||||
name: {{ include "svcforge.secretName" . }}
|
||||
creationPolicy: Owner
|
||||
data:
|
||||
{{- toYaml .Values.externalSecret.remoteRefs | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,70 @@
|
||||
{{- if .Values.migrate.enabled }}
|
||||
{{/*
|
||||
Migrations run here and nowhere else.
|
||||
|
||||
Not on app startup: three services × N replicas racing the same DDL is how you get a
|
||||
half-applied schema and a crash loop, and it makes "which pod migrated?" unanswerable.
|
||||
A hook runs once, before any new pod starts, and its exit code gates the release.
|
||||
|
||||
hook-weight -5 puts it ahead of everything else in the pre-install/pre-upgrade phase.
|
||||
hook-delete-policy before-hook-creation keeps the last run's pod around for `kubectl logs`
|
||||
after a failure — the one time you actually want it — and clears it on the next attempt.
|
||||
|
||||
Deliberately no terminationGracePeriodSeconds: 60 here. Three Deployments carry it; a
|
||||
migration is not one of them.
|
||||
*/}}
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: {{ include "svcforge.fullname" . }}-migrate
|
||||
labels:
|
||||
{{- include "svcforge.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: migrate
|
||||
annotations:
|
||||
"helm.sh/hook": pre-install,pre-upgrade
|
||||
"helm.sh/hook-weight": "-5"
|
||||
"helm.sh/hook-delete-policy": before-hook-creation
|
||||
spec:
|
||||
# 0, not 3. A failed migration must fail the release. Retrying a DDL that just failed
|
||||
# tends to turn one readable error into three, and then a green release on a schema
|
||||
# nobody has looked at.
|
||||
backoffLimit: 0
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "svcforge.labels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: migrate
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
serviceAccountName: {{ include "svcforge.serviceAccountName" (dict "ctx" $ "component" "api") }}
|
||||
{{- with .Values.image.pullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
{{- include "svcforge.podSecurityContext" . | nindent 8 }}
|
||||
containers:
|
||||
- name: migrate
|
||||
# Same image as the api, by digest. The migrations that ship are the ones the
|
||||
# code that is about to run was built against — a separate image could drift.
|
||||
image: {{ include "svcforge.image" (dict "ctx" $ "component" "api") }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
securityContext:
|
||||
{{- include "svcforge.containerSecurityContext" . | nindent 12 }}
|
||||
command: ["python", "-m", "svcforge_core.migrate"]
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ include "svcforge.secretName" . }}
|
||||
env:
|
||||
{{- include "svcforge.env" . | nindent 12 }}
|
||||
- name: OTEL_SERVICE_NAME
|
||||
value: svcforge-migrate
|
||||
resources:
|
||||
{{- toYaml .Values.migrate.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,24 @@
|
||||
{{- if .Values.podDisruptionBudget.enabled }}
|
||||
{{/*
|
||||
Off by default, and not in the module-8 spec — included because it is cheap to have and
|
||||
expensive to retrofit.
|
||||
|
||||
Only the api gets one. The worker does not need it: killing a worker mid-provision is
|
||||
already safe (chaos experiment 1 — the lease expires, the task returns to queued, and
|
||||
`helm upgrade --install` is a no-op on re-claim), so blocking a drain to protect it buys
|
||||
nothing. The reconciler must not get one: it is replicas: 1, and minAvailable: 1 on a
|
||||
single-replica Deployment blocks node drains forever.
|
||||
*/}}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ include "svcforge.fullname" . }}-api
|
||||
labels:
|
||||
{{- include "svcforge.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: api
|
||||
spec:
|
||||
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "svcforge.selectorLabels" (dict "ctx" $ "component" "api") | nindent 6 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,21 @@
|
||||
{{- if .Values.prometheusRule.enabled }}
|
||||
{{/*
|
||||
The four alerts, rendered straight from values. The rules are data, not template logic —
|
||||
which is the point: an alert you want to change is a values edit and a diff, not a new
|
||||
CR someone applied by hand and forgot.
|
||||
|
||||
Each maps to a RUNBOOK.md entry via its runbook_url annotation. An alert without a runbook
|
||||
entry is a pager that teaches nothing.
|
||||
*/}}
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: {{ include "svcforge.fullname" . }}
|
||||
labels:
|
||||
{{- include "svcforge.labels" . | nindent 4 }}
|
||||
spec:
|
||||
groups:
|
||||
- name: svcforge
|
||||
rules:
|
||||
{{- toYaml .Values.prometheusRule.rules | nindent 8 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,52 @@
|
||||
{{- if .Values.rbac.create }}
|
||||
{{/*
|
||||
Hand-written, and deliberately short.
|
||||
|
||||
Why ClusterRole and not Role: the worker's job is to `helm upgrade --install` a tenant
|
||||
release into a namespace it creates. `namespaces` is a cluster-scoped resource — a
|
||||
namespaced Role cannot grant `create` on it, and cannot grant anything inside the tenant
|
||||
namespaces either, because they do not exist when the chart is installed.
|
||||
|
||||
What keeps this least-privilege is not the scope, it is the contents: every resource and
|
||||
verb is named, there is no `*`, no cluster-admin, and no rbac.authorization.k8s.io group.
|
||||
That last omission is the load-bearing one — the worker cannot escalate itself, because it
|
||||
cannot create a RoleBinding at all.
|
||||
|
||||
The reconciler binds to the same role but only ever reads; it enqueues tasks, it does not
|
||||
provision, and it never deletes an orphan.
|
||||
*/}}
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: {{ include "svcforge.fullname" . }}-provisioner
|
||||
labels:
|
||||
{{- include "svcforge.labels" . | nindent 4 }}
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: [namespaces]
|
||||
verbs: [get, list, create]
|
||||
- apiGroups: [""]
|
||||
resources: [secrets, services, configmaps, persistentvolumeclaims, serviceaccounts]
|
||||
verbs: [get, list, watch, create, update, patch, delete]
|
||||
- apiGroups: [apps]
|
||||
resources: [deployments, statefulsets]
|
||||
verbs: [get, list, watch, create, update, patch, delete]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: {{ include "svcforge.fullname" . }}-provisioner
|
||||
labels:
|
||||
{{- include "svcforge.labels" . | nindent 4 }}
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: ClusterRole
|
||||
name: {{ include "svcforge.fullname" . }}-provisioner
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: {{ include "svcforge.serviceAccountName" (dict "ctx" $ "component" "worker") }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
- kind: ServiceAccount
|
||||
name: {{ include "svcforge.serviceAccountName" (dict "ctx" $ "component" "reconciler") }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,80 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "svcforge.fullname" . }}-reconciler
|
||||
labels:
|
||||
{{- include "svcforge.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: reconciler
|
||||
spec:
|
||||
# A singleton. Hardcoded, not a value: two reconcilers would double-enqueue drift tasks
|
||||
# and race on TTL expiry, and there is no knob that makes that acceptable. If you want
|
||||
# this to be tunable, you want a lease first — and the lease belongs in Postgres, not in
|
||||
# a Redis lock (a GC pause plus a Redis lock still gives you two reconcilers).
|
||||
replicas: 1
|
||||
strategy:
|
||||
# Recreate, not RollingUpdate. RollingUpdate would briefly run the old and new pod at
|
||||
# once, which is exactly the thing replicas: 1 exists to prevent.
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "svcforge.selectorLabels" (dict "ctx" $ "component" "reconciler") | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "svcforge.labels" . | nindent 8 }}
|
||||
{{- include "svcforge.selectorLabels" (dict "ctx" $ "component" "reconciler") | nindent 8 }}
|
||||
spec:
|
||||
serviceAccountName: {{ include "svcforge.serviceAccountName" (dict "ctx" $ "component" "reconciler") }}
|
||||
{{- with .Values.image.pullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
terminationGracePeriodSeconds: 60
|
||||
securityContext:
|
||||
{{- include "svcforge.podSecurityContext" . | nindent 8 }}
|
||||
containers:
|
||||
- name: reconciler
|
||||
image: {{ include "svcforge.image" (dict "ctx" $ "component" "reconciler") }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
securityContext:
|
||||
{{- include "svcforge.containerSecurityContext" . | nindent 12 }}
|
||||
ports:
|
||||
- name: metrics
|
||||
containerPort: 9000
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ include "svcforge.secretName" . }}
|
||||
env:
|
||||
{{- include "svcforge.env" . | nindent 12 }}
|
||||
- name: SVCFORGE_RECONCILE_INTERVAL_S
|
||||
value: {{ .Values.reconciler.intervalSeconds | quote }}
|
||||
- name: OTEL_SERVICE_NAME
|
||||
value: svcforge-reconciler
|
||||
# The reconciler's health is not "is the process up", it is "did it tick".
|
||||
# That question is answered by SvcforgeReconcilerStale off
|
||||
# svcforge_reconciler_last_tick_timestamp_seconds, not by a probe — a probe here
|
||||
# would restart the pod and reset the very gauge the alert reads.
|
||||
resources:
|
||||
{{- toYaml .Values.reconciler.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
- name: helm-home
|
||||
mountPath: /tmp/helm
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
- name: helm-home
|
||||
emptyDir: {}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,24 @@
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{/*
|
||||
One ServiceAccount per service, not one shared. The api talks only to Postgres and has no
|
||||
Kubernetes rights at all; giving it the worker's identity would hand an
|
||||
internet-facing HTTP surface the ability to create namespaces.
|
||||
*/}}
|
||||
{{- range $component := list "api" "worker" "reconciler" }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "svcforge.serviceAccountName" (dict "ctx" $ "component" $component) }}
|
||||
labels:
|
||||
{{- include "svcforge.labels" $ | nindent 4 }}
|
||||
app.kubernetes.io/component: {{ $component }}
|
||||
{{- with $.Values.serviceAccount.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
# The api never calls the API server, so it gets no token. The worker and reconciler both
|
||||
# shell out to helm, which needs one.
|
||||
automountServiceAccountToken: {{ ne $component "api" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,44 @@
|
||||
{{- if .Values.serviceMonitor.enabled }}
|
||||
{{/*
|
||||
Chart-native, values-gated. A hand-authored ServiceMonitor CR applied next to the release
|
||||
is banned: it drifts from the chart, survives a `helm uninstall`, and nothing owns it.
|
||||
|
||||
The api is scraped through its Service. The worker and reconciler have no Service — they
|
||||
are scraped by pod, which is why their metrics port is named and their pods carry the
|
||||
component label.
|
||||
*/}}
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: {{ include "svcforge.fullname" . }}-api
|
||||
labels:
|
||||
{{- include "svcforge.labels" . | nindent 4 }}
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "svcforge.selectorLabels" (dict "ctx" $ "component" "api") | nindent 6 }}
|
||||
endpoints:
|
||||
- port: http
|
||||
path: /metrics
|
||||
interval: {{ .Values.serviceMonitor.interval }}
|
||||
---
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PodMonitor
|
||||
metadata:
|
||||
name: {{ include "svcforge.fullname" . }}-workers
|
||||
labels:
|
||||
{{- include "svcforge.labels" . | nindent 4 }}
|
||||
spec:
|
||||
selector:
|
||||
matchExpressions:
|
||||
- key: app.kubernetes.io/component
|
||||
operator: In
|
||||
values: [worker, reconciler]
|
||||
- key: app.kubernetes.io/instance
|
||||
operator: In
|
||||
values: [{{ .Release.Name }}]
|
||||
podMetricsEndpoints:
|
||||
- port: metrics
|
||||
path: /metrics
|
||||
interval: {{ .Values.serviceMonitor.interval }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,84 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "svcforge.fullname" . }}-worker
|
||||
labels:
|
||||
{{- include "svcforge.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: worker
|
||||
spec:
|
||||
# Plain replicas, no HPA. Concurrency is bounded twice over — by replicas here and by a
|
||||
# semaphore inside the claim loop — and the SKIP LOCKED claim makes both safe. The day
|
||||
# replicas: 2 stops keeping up, add an HPA. Not before.
|
||||
replicas: {{ .Values.worker.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "svcforge.selectorLabels" (dict "ctx" $ "component" "worker") | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "svcforge.labels" . | nindent 8 }}
|
||||
{{- include "svcforge.selectorLabels" (dict "ctx" $ "component" "worker") | nindent 8 }}
|
||||
spec:
|
||||
serviceAccountName: {{ include "svcforge.serviceAccountName" (dict "ctx" $ "component" "worker") }}
|
||||
{{- with .Values.image.pullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
# The load-bearing one. On SIGTERM the loop stops claiming and finishes the task in
|
||||
# hand; 60s is the budget for that. Chaos experiment 2 asserts the pod exits 0 inside
|
||||
# it. Lower this and a rolling deploy starts orphaning tasks to lease expiry.
|
||||
terminationGracePeriodSeconds: 60
|
||||
securityContext:
|
||||
{{- include "svcforge.podSecurityContext" . | nindent 8 }}
|
||||
containers:
|
||||
- name: worker
|
||||
image: {{ include "svcforge.image" (dict "ctx" $ "component" "worker") }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
securityContext:
|
||||
{{- include "svcforge.containerSecurityContext" . | nindent 12 }}
|
||||
ports:
|
||||
- name: metrics
|
||||
containerPort: 9000
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ include "svcforge.secretName" . }}
|
||||
env:
|
||||
{{- include "svcforge.env" . | nindent 12 }}
|
||||
# The claim loop stamps locked_by with this. Per-pod, so a stuck lease names
|
||||
# the pod that holds it — runbook entry 1 depends on that.
|
||||
- name: SVCFORGE_WORKER_ID
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
- name: SVCFORGE_WORKER_CONCURRENCY
|
||||
value: {{ .Values.worker.concurrency | quote }}
|
||||
- name: OTEL_SERVICE_NAME
|
||||
value: svcforge-worker
|
||||
# No liveness probe. A worker mid-provision is legitimately busy for minutes;
|
||||
# a probe here is a way to kill a healthy provision and learn nothing.
|
||||
resources:
|
||||
{{- toYaml .Values.worker.resources | nindent 12 }}
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp
|
||||
# helm writes cache/config/repositories under $HELM_*_HOME, which the image
|
||||
# points at /tmp/helm. Without this, every helm call fails on a read-only fs.
|
||||
- name: helm-home
|
||||
mountPath: /tmp/helm
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
- name: helm-home
|
||||
emptyDir: {}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,174 @@
|
||||
# svcforge chart values.
|
||||
#
|
||||
# The digests below are the deployment. CI builds each image once, pushes it, reads the
|
||||
# digest back with `docker buildx imagetools inspect`, and `yq -i`s it into this file as
|
||||
# its last act. ArgoCD notices the commit and syncs. Nothing else deploys svcforge.
|
||||
#
|
||||
# Tags are banned. A tag is a mutable pointer, which means "what is running" and "what
|
||||
# this file says" can silently diverge. A digest cannot.
|
||||
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
image:
|
||||
registry: gitea.oci-oci.duckdns.org
|
||||
pullPolicy: IfNotPresent
|
||||
pullSecrets: []
|
||||
# One repo + one digest per service: CI's matrix builds three images, so there are three
|
||||
# digests. The zeros are placeholders — a fresh clone must be bumped by CI before it can
|
||||
# deploy, which is the intended failure mode. Never hand-edit these.
|
||||
api:
|
||||
repo: gitea.oci-oci.duckdns.org/gitea_admin/svcforge-api
|
||||
digest: sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
worker:
|
||||
repo: gitea.oci-oci.duckdns.org/gitea_admin/svcforge-worker
|
||||
digest: sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
reconciler:
|
||||
repo: gitea.oci-oci.duckdns.org/gitea_admin/svcforge-reconciler
|
||||
digest: sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
api:
|
||||
replicas: 2
|
||||
# One process per pod. Module 7 took the "scale with replicas" fix over
|
||||
# PROMETHEUS_MULTIPROC_DIR, so `uvicorn --workers N` here would corrupt the metrics.
|
||||
resources:
|
||||
requests: {cpu: 50m, memory: 128Mi}
|
||||
limits: {memory: 256Mi}
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 80
|
||||
targetPort: 8000
|
||||
ingress:
|
||||
enabled: true
|
||||
className: nginx
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
host: svcforge.oci-oci.duckdns.org
|
||||
tls:
|
||||
enabled: true
|
||||
secretName: svcforge-tls
|
||||
|
||||
worker:
|
||||
# Plain replicas. No HPA: the day `replicas: 2` stops keeping up, not before.
|
||||
replicas: 2
|
||||
concurrency: 4
|
||||
resources:
|
||||
requests: {cpu: 100m, memory: 192Mi}
|
||||
limits: {memory: 512Mi}
|
||||
|
||||
reconciler:
|
||||
# A singleton, and not by convention — the four checks are not safe to run twice
|
||||
# concurrently. replicas is deliberately not a value: there is nothing to tune.
|
||||
intervalSeconds: 60
|
||||
resources:
|
||||
requests: {cpu: 50m, memory: 128Mi}
|
||||
limits: {memory: 256Mi}
|
||||
|
||||
# Postgres pool sizing. replicas × maxSize is spent against the Supabase pooler budget:
|
||||
# api(2 × 5) + worker(2 × 5) + reconciler(1 × 2) = 22 connections. Raise with care.
|
||||
pool:
|
||||
minSize: 1
|
||||
maxSize: 5
|
||||
|
||||
migrate:
|
||||
# backoffLimit: 0 — a failed migration must fail the release, not retry into a
|
||||
# half-applied schema. Migrations run here and only here; never on app startup.
|
||||
enabled: true
|
||||
resources:
|
||||
requests: {cpu: 50m, memory: 128Mi}
|
||||
limits: {memory: 256Mi}
|
||||
|
||||
auth:
|
||||
jwksUrl: https://auth.oci-oci.duckdns.org/realms/svcforge/protocol/openid-connect/certs
|
||||
issuer: https://auth.oci-oci.duckdns.org/realms/svcforge
|
||||
audience: svcforge
|
||||
|
||||
otel:
|
||||
enabled: true
|
||||
endpoint: http://alloy.observability.svc.cluster.local:4317
|
||||
|
||||
log:
|
||||
level: info
|
||||
|
||||
# The DSNs are pulled from Vault by external-secrets into a Secret the pods envFrom.
|
||||
# No DSN is ever a chart value, a ConfigMap key, or a CI variable.
|
||||
externalSecret:
|
||||
enabled: true
|
||||
secretStoreRef:
|
||||
name: vault
|
||||
kind: ClusterSecretStore
|
||||
refreshInterval: 1h
|
||||
# target Secret name; keys land as SVCFORGE_PG_DSN / SVCFORGE_PG_DSN_SESSION / SVCFORGE_REDIS_DSN
|
||||
targetName: svcforge-secrets
|
||||
remoteRefs:
|
||||
- secretKey: SVCFORGE_PG_DSN
|
||||
key: svcforge/postgres
|
||||
property: dsn_pooler
|
||||
- secretKey: SVCFORGE_PG_DSN_SESSION
|
||||
key: svcforge/postgres
|
||||
property: dsn_session
|
||||
- secretKey: SVCFORGE_REDIS_DSN
|
||||
key: svcforge/redis
|
||||
property: dsn
|
||||
|
||||
rbac:
|
||||
# The worker helm-installs tenant releases into namespaces it creates. `namespaces` is a
|
||||
# cluster-scoped resource, so `create namespaces` cannot be granted by a namespaced Role
|
||||
# — this has to be a ClusterRole. It is still least-privilege: named resources, named
|
||||
# verbs, no `*`, no cluster-admin, and no rbac.authorization.k8s.io group at all, so the
|
||||
# worker cannot grant itself anything further.
|
||||
create: true
|
||||
|
||||
serviceAccount:
|
||||
create: true
|
||||
annotations: {}
|
||||
|
||||
# Chart-native only. A hand-authored ServiceMonitor/PrometheusRule CR is banned — the
|
||||
# chart owns these, gated by these flags.
|
||||
serviceMonitor:
|
||||
enabled: true
|
||||
interval: 30s
|
||||
|
||||
prometheusRule:
|
||||
enabled: true
|
||||
rules:
|
||||
- alert: SvcforgeQueueDepthRising
|
||||
expr: deriv(svcforge_queue_depth[10m]) > 0
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: svcforge queue depth is rising and not draining
|
||||
runbook_url: https://gitea.oci-oci.duckdns.org/gitea_admin/svcforge/src/branch/master/RUNBOOK.md#queue-stuck
|
||||
- alert: SvcforgeProvisionSlow
|
||||
expr: histogram_quantile(0.95, sum by (le) (rate(svcforge_provision_duration_seconds_bucket[30m]))) > 300
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: svcforge p95 provision time is over 5 minutes
|
||||
runbook_url: https://gitea.oci-oci.duckdns.org/gitea_admin/svcforge/src/branch/master/RUNBOOK.md#provision-failing
|
||||
- alert: SvcforgeTaskFailed
|
||||
expr: sum(svcforge_tasks_failed_total) > 0
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: a svcforge task exhausted its retries
|
||||
runbook_url: https://gitea.oci-oci.duckdns.org/gitea_admin/svcforge/src/branch/master/RUNBOOK.md#provision-failing
|
||||
- alert: SvcforgeReconcilerStale
|
||||
expr: time() - svcforge_reconciler_last_tick_timestamp_seconds > 300
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: the svcforge reconciler has not ticked in 5 minutes
|
||||
runbook_url: https://gitea.oci-oci.duckdns.org/gitea_admin/svcforge/src/branch/master/RUNBOOK.md#orphaned-release
|
||||
|
||||
# Not in the spec. Off by default: on a three-node k3s a PDB that cannot be satisfied
|
||||
# blocks drains, which is worse than the disruption it prevents.
|
||||
podDisruptionBudget:
|
||||
enabled: false
|
||||
minAvailable: 1
|
||||
|
||||
nodeSelector: {}
|
||||
tolerations: []
|
||||
affinity: {}
|
||||
@@ -0,0 +1,22 @@
|
||||
[project]
|
||||
name = "svcforge-core"
|
||||
version = "0.1.0"
|
||||
description = "svcforge shared core: domain, repo, adapters"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"pydantic>=2.9",
|
||||
"pydantic-settings>=2.6",
|
||||
"pyyaml>=6.0",
|
||||
"psycopg[binary,pool]>=3.2",
|
||||
"redis>=5.2",
|
||||
"structlog>=24.4",
|
||||
"prometheus-client>=0.21",
|
||||
"opentelemetry-api>=1.28",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["svcforge_core"]
|
||||
@@ -0,0 +1,14 @@
|
||||
"""The outside world: helm/kubectl subprocesses, notifications, the clock.
|
||||
|
||||
Everything in here is I/O the domain must never learn about. Each adapter exposes a
|
||||
Protocol with exactly two implementations — the real one here, the fake in `tests/fakes.py`.
|
||||
A Protocol with one implementation is an interface nobody asked for; delete it.
|
||||
|
||||
The subprocess discipline lives in :func:`svcforge_core.adapters.helm._run` and is shared
|
||||
by every adapter that shells out (`k8s.py` included):
|
||||
|
||||
* `create_subprocess_exec`, never a shell — `team` is tenant input that reaches a release name.
|
||||
* `start_new_session=True` at spawn time, so a timeout can kill the whole process group.
|
||||
* `communicate()`, never `wait()` with pipes attached.
|
||||
* stderr truncated to a fixed tail at this boundary, because it lands in `instances.error`.
|
||||
"""
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Time, as a dependency.
|
||||
|
||||
The centrepiece of the Day-2 module, and it is nine lines. `datetime.now()` called from
|
||||
inside domain logic is an untestable global read: a maintenance-window test that wants
|
||||
"03:00 next Sunday" would have to either sleep until Sunday or monkeypatch a stdlib symbol
|
||||
and hope nothing else in the process noticed. Passing a Clock makes the same test a
|
||||
`FakeClock(start=...)` and an `advance()`.
|
||||
|
||||
Aware UTC, always. A naive datetime is a bug that survives every test on a UTC CI box and
|
||||
detonates the first time it meets a tenant in Asia/Ho_Chi_Minh: `datetime.utcnow()` returns
|
||||
a naive value, and comparing it to a `timestamptz` from Postgres raises TypeError, or worse,
|
||||
silently compares wrong after somebody "fixes" it with a `.replace(tzinfo=...)`.
|
||||
|
||||
The fake lives in `tests/fakes.py`, not here: shipping test doubles in the production
|
||||
package is how they end up imported by production code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class Clock(Protocol):
|
||||
"""Implemented by SystemClock (here) and FakeClock (tests/fakes.py)."""
|
||||
|
||||
def now(self) -> datetime:
|
||||
"""Current time, aware, UTC."""
|
||||
...
|
||||
|
||||
|
||||
class SystemClock:
|
||||
"""The wall clock. The only thing in the codebase allowed to read it."""
|
||||
|
||||
def now(self) -> datetime:
|
||||
"""Current time, aware, UTC.
|
||||
|
||||
`datetime.now(UTC)`, never `datetime.utcnow()` — the latter is naive and deprecated
|
||||
in 3.12 for exactly this reason.
|
||||
"""
|
||||
return datetime.now(UTC)
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Driving helm from asyncio, with timeouts that actually kill helm.
|
||||
|
||||
The whole module exists for `_run`. Everything above it is argv construction.
|
||||
|
||||
Four things go wrong when you spawn a process from an event loop, and all four are
|
||||
handled here rather than in the caller:
|
||||
|
||||
1. `subprocess.run` blocks the loop. Use `create_subprocess_exec`.
|
||||
2. `stdout=PIPE` with `proc.wait()` and nobody draining deadlocks at ~64 KB of output —
|
||||
`helm --debug` clears that in one install. Use `communicate()`.
|
||||
3. `asyncio.wait_for` cancels the *coroutine*. The process does not know it was waited on:
|
||||
helm keeps running and keeps mutating the cluster. The timeout has to kill it.
|
||||
4. `proc.kill()` signals the direct child. `helm` forks; its children reparent to init and
|
||||
survive. Only `killpg` gets the whole tree, and only if the group exists — which needs
|
||||
`start_new_session=True` **at spawn time**, because setsid can only run in the window
|
||||
between fork and exec.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import tempfile
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from svcforge_core.domain.models import CatalogEntry
|
||||
|
||||
# How long the process group gets to honour SIGTERM before SIGKILL. Helm traps SIGTERM
|
||||
# and tries to leave the release in a coherent state; give it a moment to do so.
|
||||
_TERM_GRACE_S = 5.0
|
||||
|
||||
# `_run` is the backstop, not the primary timeout: helm gets its own `--timeout` so that
|
||||
# `--atomic` can roll back cleanly. `_run` only fires when helm itself is wedged, so its
|
||||
# deadline sits this far past helm's.
|
||||
_RUN_TIMEOUT_MARGIN_S = 30
|
||||
|
||||
_STDERR_TAIL_BYTES = 2048
|
||||
|
||||
|
||||
class HelmError(RuntimeError):
|
||||
"""Non-zero exit. str(self) is the stderr tail that lands in instances.error."""
|
||||
|
||||
|
||||
class ReleaseInfo(BaseModel):
|
||||
"""One row of `helm list -o json`."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
name: str
|
||||
namespace: str
|
||||
chart: str
|
||||
status: str
|
||||
revision: int = Field(default=0, ge=0)
|
||||
app_version: str = ""
|
||||
|
||||
|
||||
class Provisioner(Protocol):
|
||||
"""What the worker needs from a cluster. Implemented by HelmProvisioner and FakeProvisioner."""
|
||||
|
||||
async def install(self, release: str, ns: str, entry: CatalogEntry, values: dict[str, Any]) -> None: ...
|
||||
|
||||
async def uninstall(self, release: str, ns: str) -> None: ...
|
||||
|
||||
async def list_releases(self) -> list[ReleaseInfo]: ...
|
||||
|
||||
|
||||
def _tail(raw: bytes, tail_bytes: int) -> str:
|
||||
"""The last `tail_bytes` of a stream, as text.
|
||||
|
||||
Truncation happens here, at the adapter boundary, and nowhere else. A helm failure can
|
||||
emit megabytes; `instances.error` is a text column read by humans. Slice the bytes, not
|
||||
the decoded string, then decode with `replace` — the cut can land mid-codepoint.
|
||||
"""
|
||||
return raw[-tail_bytes:].decode("utf-8", errors="replace").strip()
|
||||
|
||||
|
||||
def _signal_group(proc: asyncio.subprocess.Process, sig: int) -> None:
|
||||
"""Signal the process's whole group. No-op if it has already exited.
|
||||
|
||||
`os.getpgid` rather than `proc.pid`: with `start_new_session=True` they are equal, but
|
||||
that equality is an implementation detail, and asking the kernel costs nothing.
|
||||
"""
|
||||
if proc.returncode is not None:
|
||||
return
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), sig)
|
||||
except (ProcessLookupError, PermissionError):
|
||||
# Exited and reaped between the check and the call, or reparented out of reach.
|
||||
return
|
||||
|
||||
|
||||
async def _terminate_group(proc: asyncio.subprocess.Process) -> None:
|
||||
"""SIGTERM the group, grace, SIGKILL the group. Then reap, so we leave no zombie."""
|
||||
_signal_group(proc, signal.SIGTERM)
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=_TERM_GRACE_S)
|
||||
except TimeoutError:
|
||||
_signal_group(proc, signal.SIGKILL)
|
||||
await proc.wait()
|
||||
|
||||
|
||||
async def _run(argv: Sequence[str], timeout_s: int, tail_bytes: int = _STDERR_TAIL_BYTES) -> str:
|
||||
"""create_subprocess_exec(*argv, stdout=PIPE, stderr=PIPE, start_new_session=True).
|
||||
|
||||
wait_for the drain. On TimeoutError: killpg(TERM), grace, killpg(KILL), raise.
|
||||
Non-zero rc: raise HelmError(stderr tail). Return stdout.
|
||||
"""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*argv,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_s)
|
||||
except TimeoutError:
|
||||
# wait_for cancelled the drain, not the process. Kill the group and re-raise.
|
||||
await _terminate_group(proc)
|
||||
raise
|
||||
except asyncio.CancelledError:
|
||||
# Our caller is being torn down (SIGTERM to the worker). Do not await anything here:
|
||||
# a second cancellation would land on that await and leave helm running. Signal and go.
|
||||
_signal_group(proc, signal.SIGKILL)
|
||||
raise
|
||||
|
||||
if proc.returncode != 0:
|
||||
detail = _tail(stderr, tail_bytes) or _tail(stdout, tail_bytes)
|
||||
raise HelmError(
|
||||
f"{argv[0]} exited {proc.returncode}: {detail}"
|
||||
if detail
|
||||
else f"{argv[0]} exited {proc.returncode}"
|
||||
)
|
||||
return stdout.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
class HelmProvisioner:
|
||||
"""The real thing. One helm binary, one kubeconfig, one deadline."""
|
||||
|
||||
def __init__(
|
||||
self, *, helm_bin: str = "helm", kubeconfig: Path | None = None, timeout_s: int = 600
|
||||
) -> None:
|
||||
"""kubeconfig=None means the ambient config: $KUBECONFIG, ~/.kube/config, or the
|
||||
in-cluster service account when svcforge runs as a pod. Keyword-only so that the
|
||||
three arguments can never be swapped by position at a call site.
|
||||
"""
|
||||
self._helm_bin = helm_bin
|
||||
self._kubeconfig = kubeconfig
|
||||
self._timeout_s = timeout_s
|
||||
|
||||
@property
|
||||
def _run_timeout_s(self) -> int:
|
||||
return self._timeout_s + _RUN_TIMEOUT_MARGIN_S
|
||||
|
||||
def _base_argv(self, *args: str) -> list[str]:
|
||||
argv = [self._helm_bin, *args]
|
||||
if self._kubeconfig is not None:
|
||||
argv += ["--kubeconfig", str(self._kubeconfig)]
|
||||
return argv
|
||||
|
||||
async def install(self, release: str, ns: str, entry: CatalogEntry, values: dict[str, Any]) -> None:
|
||||
"""helm upgrade --install --wait --timeout. Idempotent by construction."""
|
||||
# `upgrade --install` is why this is idempotent: a retried task after a crash mid-provision
|
||||
# converges on the same release instead of erroring with "release already exists".
|
||||
# `--wait` is why `ready` in the DB means ready — it returns when the pods are up.
|
||||
# `--atomic` rolls back a failed upgrade; it doubles the worst case, which is what
|
||||
# `_RUN_TIMEOUT_MARGIN_S` and helm's own `--timeout` are sized around.
|
||||
with _values_file(values) as path:
|
||||
argv = self._base_argv(
|
||||
"upgrade",
|
||||
"--install",
|
||||
release,
|
||||
entry.chart,
|
||||
"--namespace",
|
||||
ns,
|
||||
"--version",
|
||||
entry.chart_version,
|
||||
"--values",
|
||||
str(path),
|
||||
"--wait",
|
||||
"--atomic",
|
||||
"--timeout",
|
||||
f"{self._timeout_s}s",
|
||||
)
|
||||
await _run(argv, timeout_s=self._run_timeout_s)
|
||||
|
||||
async def uninstall(self, release: str, ns: str) -> None:
|
||||
"""helm uninstall --wait. `--ignore-not-found` makes the retry of a half-done delete a no-op."""
|
||||
argv = self._base_argv(
|
||||
"uninstall",
|
||||
release,
|
||||
"--namespace",
|
||||
ns,
|
||||
"--ignore-not-found",
|
||||
"--wait",
|
||||
"--timeout",
|
||||
f"{self._timeout_s}s",
|
||||
)
|
||||
await _run(argv, timeout_s=self._run_timeout_s)
|
||||
|
||||
async def list_releases(self) -> list[ReleaseInfo]:
|
||||
"""Every release helm knows about, in every namespace. The reconciler's view of reality."""
|
||||
argv = self._base_argv("list", "--all-namespaces", "--output", "json")
|
||||
raw = await _run(argv, timeout_s=self._run_timeout_s)
|
||||
try:
|
||||
parsed: Any = json.loads(raw or "[]")
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HelmError(f"helm list returned non-JSON: {_tail(raw.encode(), 256)}") from exc
|
||||
if not isinstance(parsed, list):
|
||||
raise HelmError(f"helm list returned {type(parsed).__name__}, expected a list")
|
||||
return [ReleaseInfo.model_validate(row) for row in parsed]
|
||||
|
||||
|
||||
class _ValuesFile:
|
||||
"""Context manager yielding a path to a values.yaml written from a dict.
|
||||
|
||||
A file, not `--set`: `--set` has its own escaping grammar (commas, dots, backslashes) and
|
||||
values carry tenant-shaped strings. Serialising YAML sidesteps the grammar entirely.
|
||||
"""
|
||||
|
||||
def __init__(self, values: dict[str, Any]) -> None:
|
||||
self._values = values
|
||||
self._dir: str | None = None
|
||||
|
||||
def __enter__(self) -> Path:
|
||||
self._dir = tempfile.mkdtemp(prefix="svcforge-values-")
|
||||
path = Path(self._dir) / "values.yaml"
|
||||
path.write_text(yaml.safe_dump(self._values, default_flow_style=False), encoding="utf-8")
|
||||
return path
|
||||
|
||||
def __exit__(self, *exc: object) -> None:
|
||||
if self._dir is not None:
|
||||
shutil.rmtree(self._dir, ignore_errors=True)
|
||||
|
||||
|
||||
def _values_file(values: dict[str, Any]) -> _ValuesFile:
|
||||
return _ValuesFile(values)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""kubectl, for the two things helm will not do: make a namespace, read a secret back.
|
||||
|
||||
Same subprocess discipline as helm — it is literally the same `_run`, imported rather than
|
||||
copied, because the process-group handling is the part that is easy to get subtly wrong twice.
|
||||
|
||||
Both operations are declarative and therefore idempotent: `apply` of a namespace that exists
|
||||
is a no-op, and a `get` has no effect at all. A worker that dies mid-provision and retries
|
||||
must not find a "namespace already exists" error waiting for it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from svcforge_core.adapters.helm import HelmError, _run
|
||||
|
||||
_KUBECTL_TIMEOUT_S = 60
|
||||
|
||||
|
||||
class K8sError(RuntimeError):
|
||||
"""kubectl failed. str(self) is the stderr tail, already truncated by `_run`."""
|
||||
|
||||
|
||||
class SecretNotFound(K8sError):
|
||||
"""The secret is not there (yet). Distinct because a caller may want to retry rather than fail."""
|
||||
|
||||
|
||||
class KubectlClient:
|
||||
"""A kubectl binary and a kubeconfig. No client-go, no in-cluster config, no CRDs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
kubectl_bin: str = "kubectl",
|
||||
kubeconfig: Path | None = None,
|
||||
timeout_s: int = _KUBECTL_TIMEOUT_S,
|
||||
) -> None:
|
||||
"""Mirrors HelmProvisioner: kubeconfig=None means the ambient/in-cluster config."""
|
||||
self._kubectl_bin = kubectl_bin
|
||||
self._kubeconfig = kubeconfig
|
||||
self._timeout_s = timeout_s
|
||||
|
||||
def _base_argv(self, *args: str) -> list[str]:
|
||||
argv = [self._kubectl_bin, *args]
|
||||
if self._kubeconfig is not None:
|
||||
argv += ["--kubeconfig", str(self._kubeconfig)]
|
||||
return argv
|
||||
|
||||
async def ensure_namespace(self, ns: str, labels: dict[str, str] | None = None) -> None:
|
||||
"""Create the namespace if absent, leave it alone if present.
|
||||
|
||||
`apply -f` of a manifest rather than `create namespace` (which errors on the second
|
||||
call) or `create --dry-run=client -o yaml | apply -f -` (which needs a shell, and a
|
||||
shell is exactly what tenant input must never reach).
|
||||
"""
|
||||
manifest: dict[str, Any] = {
|
||||
"apiVersion": "v1",
|
||||
"kind": "Namespace",
|
||||
"metadata": {
|
||||
"name": ns,
|
||||
"labels": {"app.kubernetes.io/managed-by": "svcforge", **(labels or {})},
|
||||
},
|
||||
}
|
||||
with _manifest_file(manifest) as path:
|
||||
await self._kubectl("apply", "--filename", str(path))
|
||||
|
||||
async def read_secret(self, ns: str, name: str) -> dict[str, str]:
|
||||
"""The secret's `data`, base64-decoded. Chart-generated passwords come back through here.
|
||||
|
||||
Returns str, not bytes: every value svcforge reads (passwords, hosts, ports) is text.
|
||||
A genuinely binary value raises rather than silently mangling into replacement chars.
|
||||
"""
|
||||
try:
|
||||
raw = await self._kubectl("get", "secret", name, "--namespace", ns, "--output", "json")
|
||||
except K8sError as exc:
|
||||
if "notfound" in str(exc).lower().replace(" ", ""):
|
||||
raise SecretNotFound(f"secret {ns}/{name} not found") from exc
|
||||
raise
|
||||
|
||||
try:
|
||||
parsed: Any = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise K8sError(f"kubectl get secret {ns}/{name} returned non-JSON") from exc
|
||||
|
||||
data: Any = parsed.get("data") or {}
|
||||
if not isinstance(data, dict):
|
||||
raise K8sError(f"secret {ns}/{name}: 'data' is {type(data).__name__}, expected a mapping")
|
||||
|
||||
out: dict[str, str] = {}
|
||||
for key, encoded in data.items():
|
||||
try:
|
||||
out[str(key)] = base64.b64decode(str(encoded), validate=True).decode("utf-8")
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise K8sError(f"secret {ns}/{name}: key {key!r} is not base64-encoded utf-8") from exc
|
||||
return out
|
||||
|
||||
async def _kubectl(self, *args: str) -> str:
|
||||
"""Run kubectl through helm's `_run`, translating its error type at this boundary.
|
||||
|
||||
`_run` is spec'd to live in helm.py and to raise HelmError; nothing outside adapters
|
||||
should have to know that kubectl failures arrive wearing a helm-shaped exception.
|
||||
"""
|
||||
try:
|
||||
return await _run(self._base_argv(*args), timeout_s=self._timeout_s)
|
||||
except HelmError as exc:
|
||||
raise K8sError(str(exc)) from exc
|
||||
|
||||
|
||||
class _ManifestFile:
|
||||
"""A temp file holding one YAML manifest, removed on exit."""
|
||||
|
||||
def __init__(self, manifest: dict[str, Any]) -> None:
|
||||
self._manifest = manifest
|
||||
self._dir: str | None = None
|
||||
|
||||
def __enter__(self) -> Path:
|
||||
self._dir = tempfile.mkdtemp(prefix="svcforge-manifest-")
|
||||
path = Path(self._dir) / "manifest.yaml"
|
||||
path.write_text(yaml.safe_dump(self._manifest, default_flow_style=False), encoding="utf-8")
|
||||
return path
|
||||
|
||||
def __exit__(self, *exc: object) -> None:
|
||||
if self._dir is not None:
|
||||
shutil.rmtree(self._dir, ignore_errors=True)
|
||||
|
||||
|
||||
def _manifest_file(manifest: dict[str, Any]) -> _ManifestFile:
|
||||
return _ManifestFile(manifest)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Telling someone a provision finished, or didn't.
|
||||
|
||||
Best-effort by construction: `send` never raises. A notifier that can fail a task is a
|
||||
notifier that lets a Slack outage roll back a successful provision. The instance is ready;
|
||||
the DB says so; failing the task would re-run helm for nothing. Delivery failures are
|
||||
logged and dropped on the floor, which is the correct amount of ceremony for a webhook.
|
||||
|
||||
Two implementations, so the Protocol earns its place: LogNotifier (the default, and what
|
||||
tests and local dev get) and WebhookNotifier (the one that leaves the process).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Protocol
|
||||
|
||||
import httpx
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
class Notifier(Protocol):
|
||||
"""Implemented by LogNotifier, WebhookNotifier, and FakeNotifier (tests/fakes.py)."""
|
||||
|
||||
async def send(self, event: str, message: str, fields: dict[str, str] | None = None) -> None:
|
||||
"""Announce `event`. Must not raise: delivery is never worth failing a task over."""
|
||||
...
|
||||
|
||||
|
||||
class LogNotifier:
|
||||
"""Writes the event to the log. The default: structured logs are already shipped somewhere."""
|
||||
|
||||
async def send(self, event: str, message: str, fields: dict[str, str] | None = None) -> None:
|
||||
# NOT extra={"message": ...}. `message` is a reserved LogRecord attribute, and
|
||||
# logging raises KeyError("Attempt to overwrite 'message' in LogRecord") at call
|
||||
# time — so the notifier would crash the caller it was meant to inform. Same trap
|
||||
# for `msg`, `args`, `name`, `levelname`, `exc_info`.
|
||||
_log.info("notify", extra={"event": event, "detail": message, "fields": fields or {}})
|
||||
|
||||
|
||||
class WebhookNotifier:
|
||||
"""POSTs a JSON body at a URL. Slack-shaped, but anything that accepts JSON will do."""
|
||||
|
||||
def __init__(
|
||||
self, url: str, *, client: httpx.AsyncClient | None = None, timeout_s: float = _DEFAULT_TIMEOUT_S
|
||||
) -> None:
|
||||
"""Pass `client` to share a connection pool with the rest of the process.
|
||||
|
||||
An owned client is closed by `aclose`; an injected one is the caller's to close.
|
||||
"""
|
||||
self._url = url
|
||||
self._timeout_s = timeout_s
|
||||
self._client = client
|
||||
self._owns_client = client is None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(timeout=self._timeout_s)
|
||||
return self._client
|
||||
|
||||
async def send(self, event: str, message: str, fields: dict[str, str] | None = None) -> None:
|
||||
payload = {"event": event, "message": message, "fields": fields or {}}
|
||||
try:
|
||||
client = await self._get_client()
|
||||
resp = await client.post(self._url, json=payload, timeout=self._timeout_s)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
# Deliberately swallowed. See the module docstring: the work already succeeded.
|
||||
_log.warning("notify webhook failed", extra={"event": event, "error": str(exc)})
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close the client, if we made it."""
|
||||
if self._client is not None and self._owns_client:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
@@ -0,0 +1,427 @@
|
||||
"""Redis: derived state only. Never the truth, never the queue.
|
||||
|
||||
Everything in here is a shortcut past Postgres, and every one of them is optional. Postgres
|
||||
holds the instances, the tasks, the leases and the `release_name` UNIQUE constraint. Redis
|
||||
holds a counter, a claim marker and a copy — all of it rebuildable by doing nothing and
|
||||
waiting for a TTL.
|
||||
|
||||
That framing is not philosophy, it decides the error handling, and the error handling is
|
||||
the module. Each class below catches `RedisError` and returns a *safe* answer rather than
|
||||
raising:
|
||||
|
||||
| Path | Redis is down | Why |
|
||||
|-------------|--------------------------|--------------------------------------------------|
|
||||
| Cache | miss -> read Postgres | It was an optimisation. Nobody notices. |
|
||||
| Rate limit | **allow** | An internal platform that refuses every request |
|
||||
| | | because the limiter is sick is worse than one |
|
||||
| | | that is briefly unmetered. |
|
||||
| Idempotency | fall through to the DB | `instances.release_name` is UNIQUE. That is the |
|
||||
| | | real guarantee; this is the fast path. |
|
||||
|
||||
Consequently nothing here raises out to a caller, and `/readyz` stays Postgres-only. A
|
||||
Redis outage must not make a single pod unready — that would convert "the cache is down"
|
||||
into "the platform is down", which is the exact inversion this module exists to prevent.
|
||||
|
||||
**The budget is a design constraint, not a footnote.** Upstash free tier:
|
||||
|
||||
500,000 commands / month = 16,129 / day = 11 / minute = 0.19 / second, sustained
|
||||
|
||||
One worker polling Redis every five seconds spends 518,400/month: the entire budget,
|
||||
producing nothing. So the rule is structural — **Redis lives on the request path only**,
|
||||
where volume is bounded by the number of humans with an API token, and never inside a poll
|
||||
or control loop. That is also why the limiter is a Lua script: `GET`/`INCR`/`EXPIRE` is
|
||||
three billed commands and a race; one `EVALSHA` is one billed command and atomic. A
|
||||
pipeline would not help — it batches round trips but still bills N.
|
||||
|
||||
Every key gets a TTL. 256 MB with no expiry is a slow leak that ends by evicting the keys
|
||||
you cared about.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from prometheus_client import Counter
|
||||
from pydantic import ValidationError
|
||||
from redis.asyncio import Redis
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from svcforge_core.adapters.clock import Clock, SystemClock
|
||||
from svcforge_core.domain.models import Instance
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from redis.commands.core import AsyncScript
|
||||
|
||||
from svcforge_core.settings import Settings
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# --- The budget metric ------------------------------------------------------------------
|
||||
#
|
||||
# The counter that `scripts/redis_budget.py` projects month-end burn from. It counts
|
||||
# commands we *send*, incremented next to each call, because the number that matters is
|
||||
# the one Upstash bills — not the number of times a method was called. A cache miss calls
|
||||
# `get()` once and spends one command; a `put()` after it spends another.
|
||||
#
|
||||
# The dangerous failure this makes visible: when Redis is down, every path degrades
|
||||
# silently and correctly, so nothing pages. Nothing fails until the month rolls over and
|
||||
# every Redis call starts erroring at once. A counter you can extrapolate from is the only
|
||||
# warning you get.
|
||||
|
||||
REDIS_COMMANDS = Counter(
|
||||
"svcforge_redis_commands_total",
|
||||
"Redis commands issued, as Upstash bills them. One EVALSHA is one.",
|
||||
["op"],
|
||||
)
|
||||
|
||||
REDIS_ERRORS = Counter(
|
||||
"svcforge_redis_errors_total",
|
||||
"Redis calls that failed and were degraded past. Never surfaced to the caller.",
|
||||
["op"],
|
||||
)
|
||||
|
||||
# A hung Redis must not hang the request path. Without these, a TCP connection that is
|
||||
# open but unanswered blocks the handler until the client gives up — which turns "Redis is
|
||||
# slow" into "the API is down", the same inversion the fail-open policy prevents. Upstash
|
||||
# steady-state RTT is ~2.4 ms; two seconds is already pathological.
|
||||
_SOCKET_TIMEOUT_S = 2.0
|
||||
_CONNECT_TIMEOUT_S = 2.0
|
||||
|
||||
# Errors that mean "Redis did not answer". Every public method below turns these into a
|
||||
# safe default. `OSError` because a DNS failure at connect time need not arrive wrapped,
|
||||
# `TimeoutError` because the socket timeouts above raise it.
|
||||
_REDIS_DOWN = (RedisError, OSError, asyncio.TimeoutError)
|
||||
|
||||
|
||||
def _as_text(value: bytes | str) -> str:
|
||||
"""`decode_responses=True` already did this; redis-py's annotations do not know it.
|
||||
|
||||
The client is configured for text, so the `bytes` branch is unreachable in this
|
||||
process. It stays because the type says it is reachable, and a `cast` here would hide
|
||||
the day someone builds a client without `decode_responses` and gets a `UUID(b'...')`
|
||||
TypeError from three frames away instead of a value that just works.
|
||||
"""
|
||||
return value.decode() if isinstance(value, bytes) else value
|
||||
|
||||
|
||||
def make_redis(settings: Settings) -> Redis | None:
|
||||
"""One client per process, opened in lifespan next to the psycopg pool, closed on exit.
|
||||
|
||||
`None` when no DSN is configured, and that is a supported way to run: every consumer
|
||||
below is optional by construction, so "no Redis" and "Redis is down" take the same
|
||||
code path. The signature is `Redis | None` rather than `Redis` precisely so that
|
||||
"unconfigured" cannot be faked with a client pointed at nothing.
|
||||
|
||||
Two settings are not negotiable:
|
||||
|
||||
`decode_responses=True` — the first bug everyone hits. Without it every read is
|
||||
`bytes` and the traceback is `AttributeError: 'bytes' object has no attribute
|
||||
'encode'`, several frames away from the cause.
|
||||
|
||||
`rediss://` (TLS) — Upstash rejects plaintext. The handshake is ~56 ms against a
|
||||
~2.4 ms steady-state RTT, which is the whole argument for one pooled client per
|
||||
process: a client per request pays the handshake every time and turns a cache into a
|
||||
latency regression.
|
||||
"""
|
||||
if settings.redis_dsn is None:
|
||||
return None
|
||||
return Redis.from_url(
|
||||
str(settings.redis_dsn),
|
||||
decode_responses=True,
|
||||
socket_timeout=_SOCKET_TIMEOUT_S,
|
||||
socket_connect_timeout=_CONNECT_TIMEOUT_S,
|
||||
)
|
||||
|
||||
|
||||
# --- Rate limiting ----------------------------------------------------------------------
|
||||
|
||||
# One INCR; EXPIRE only when the counter is new. The `== 1` test is the entire trick: set
|
||||
# the TTL unconditionally and every request slides the window forward, so a caller at
|
||||
# steady load is never reset and the "window" is a sliding refusal that never lets up.
|
||||
#
|
||||
# KEYS and ARGV arrive as 1-based tables — Lua indexes from 1, and `ARGV[0]` is silently
|
||||
# nil rather than an error, which reads as "the limit is nil" and compares false forever.
|
||||
#
|
||||
# Everything derivable in Python is derived in Python: `reset_at` comes from the window
|
||||
# number the caller already computed, so there is no TTL round trip. One command, total.
|
||||
_RATE_LIMIT_LUA = """
|
||||
local n = redis.call('INCR', KEYS[1])
|
||||
if n == 1 then
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[2])
|
||||
end
|
||||
local limit = tonumber(ARGV[1])
|
||||
local remaining = limit - n
|
||||
if remaining < 0 then
|
||||
remaining = 0
|
||||
end
|
||||
local allowed = 0
|
||||
if n <= limit then
|
||||
allowed = 1
|
||||
end
|
||||
return {allowed, remaining}
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RateLimitResult:
|
||||
"""The limiter's answer. `degraded` is how the caller knows it did not really check."""
|
||||
|
||||
allowed: bool
|
||||
limit: int
|
||||
remaining: int
|
||||
reset_at: datetime
|
||||
degraded: bool = False
|
||||
|
||||
@property
|
||||
def retry_after_s(self) -> int:
|
||||
"""Seconds until the window rolls over, for the `Retry-After` header on a 429.
|
||||
|
||||
Rounded up and floored at one: `Retry-After: 0` invites an immediate retry into
|
||||
the same closed window, which is a busy loop with extra steps.
|
||||
"""
|
||||
delta = (self.reset_at - datetime.now(UTC)).total_seconds()
|
||||
return max(1, math.ceil(delta))
|
||||
|
||||
|
||||
class RateLimiterProto(Protocol):
|
||||
"""Implemented by RateLimiter (here) and FakeRateLimiter (tests/fakes.py)."""
|
||||
|
||||
async def check(self, team: str) -> RateLimitResult:
|
||||
"""Count this request against `team`'s window. Must not raise."""
|
||||
...
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Fixed-window limiter. One EVALSHA per check. Fails OPEN.
|
||||
|
||||
Fixed window, not a token bucket or a sliding log, because the window boundary is the
|
||||
only thing a fixed window gets wrong and the cost of getting it wrong is that a caller
|
||||
can spend 2x the limit across a boundary. A sliding log is a sorted set, an
|
||||
`ZREMRANGEBYSCORE`, an `ZADD` and a `ZCARD` — four billed commands and unbounded key
|
||||
size — to fix a burst nobody is paying for. The limit is a courtesy, not a security
|
||||
control; the security control is the JWT.
|
||||
"""
|
||||
|
||||
def __init__(self, r: Redis, limit: int, window_s: int, *, clock: Clock | None = None) -> None:
|
||||
"""`clock` is injectable so the window boundary is testable without sleeping."""
|
||||
if limit < 1:
|
||||
raise ValueError("limit must be >= 1")
|
||||
if window_s < 1:
|
||||
raise ValueError("window_s must be >= 1")
|
||||
self._limit = limit
|
||||
self._window_s = window_s
|
||||
self._clock = clock or SystemClock()
|
||||
# register_script() is local: it hashes the source and returns a callable. No round
|
||||
# trip here, and none wasted at import. The first call sends EVALSHA; redis-py
|
||||
# catches NOSCRIPT and replays it as EVAL, which is why a restarted Redis costs one
|
||||
# extra command once rather than an outage.
|
||||
self._script: AsyncScript = r.register_script(_RATE_LIMIT_LUA)
|
||||
|
||||
def _window(self) -> tuple[int, datetime]:
|
||||
"""The current window number and when it ends. Pure arithmetic, no Redis."""
|
||||
now = self._clock.now()
|
||||
epoch = int(now.timestamp())
|
||||
window = epoch // self._window_s
|
||||
reset_at = datetime.fromtimestamp((window + 1) * self._window_s, tz=UTC)
|
||||
return window, reset_at
|
||||
|
||||
async def check(self, team: str) -> RateLimitResult:
|
||||
"""Count one request against `team`. Never raises.
|
||||
|
||||
On any Redis error: allow, log loudly, count it. The metric is the point — a
|
||||
limiter that fails open silently is indistinguishable from no limiter at all, and
|
||||
you find out which one you shipped during the incident.
|
||||
"""
|
||||
window, reset_at = self._window()
|
||||
key = f"rl:{team}:{window}"
|
||||
try:
|
||||
REDIS_COMMANDS.labels(op="ratelimit").inc()
|
||||
allowed, remaining = await self._script(keys=[key], args=[self._limit, self._window_s])
|
||||
except _REDIS_DOWN:
|
||||
REDIS_ERRORS.labels(op="ratelimit").inc()
|
||||
_log.warning(
|
||||
"rate limiter degraded: redis unavailable, failing OPEN",
|
||||
exc_info=True,
|
||||
extra={"team": team},
|
||||
)
|
||||
return RateLimitResult(
|
||||
allowed=True,
|
||||
limit=self._limit,
|
||||
remaining=self._limit,
|
||||
reset_at=reset_at,
|
||||
degraded=True,
|
||||
)
|
||||
return RateLimitResult(
|
||||
allowed=bool(allowed),
|
||||
limit=self._limit,
|
||||
remaining=int(remaining),
|
||||
reset_at=reset_at,
|
||||
)
|
||||
|
||||
|
||||
# --- Idempotency ------------------------------------------------------------------------
|
||||
|
||||
|
||||
class IdempotencyStoreProto(Protocol):
|
||||
"""Implemented by IdempotencyStore (here) and FakeIdempotencyStore (tests/fakes.py)."""
|
||||
|
||||
async def claim(self, key: str, instance_id: UUID) -> UUID | None:
|
||||
"""None = we won and the caller creates. A UUID = it already exists. Must not raise."""
|
||||
...
|
||||
|
||||
|
||||
class IdempotencyStore:
|
||||
"""`SET NX EX`. Maps an `Idempotency-Key` to the instance UUID it created.
|
||||
|
||||
Claimed BEFORE the DB transaction, so the marker exists before the row it names. The
|
||||
inversion matters: claim after the commit and a crash in between leaves a created
|
||||
instance with no marker, and the client's retry creates a second one.
|
||||
|
||||
Claiming first has its own hole — a crash after the claim and before the commit leaves
|
||||
a marker pointing at an instance that never existed, and the retry is told "already
|
||||
done" about nothing. It is the better hole: the client polls the id, gets a 404, and
|
||||
retries with a fresh key. The alternative loses money to a duplicate Elasticsearch.
|
||||
|
||||
And neither hole is load-bearing, because `instances.release_name` is UNIQUE and
|
||||
deterministic from (team, service_type, id). **That constraint is the guarantee.** This
|
||||
class only saves the round trip to find out.
|
||||
"""
|
||||
|
||||
def __init__(self, r: Redis, ttl_s: int = 86400) -> None:
|
||||
"""A day is the window a client might reasonably retry in; then the key is garbage."""
|
||||
if ttl_s < 1:
|
||||
raise ValueError("ttl_s must be >= 1")
|
||||
self._r = r
|
||||
self._ttl_s = ttl_s
|
||||
|
||||
async def claim(self, key: str, instance_id: UUID) -> UUID | None:
|
||||
"""Try to bind `key` to `instance_id`. Never raises.
|
||||
|
||||
`None` from the happy path means "you won, go create it". `None` from a Redis
|
||||
failure means the same thing — the caller creates, and the UNIQUE constraint
|
||||
catches an actual duplicate. Degrading to "create it" is safe *only* because that
|
||||
constraint exists; without it this would have to fail closed.
|
||||
|
||||
One command when we win, which is the common case and the one the budget is sized
|
||||
for. Two when we lose: the loser pays a GET, and losers are rare by definition.
|
||||
"""
|
||||
redis_key = f"idem:{key}"
|
||||
try:
|
||||
REDIS_COMMANDS.labels(op="idempotency").inc()
|
||||
won = await self._r.set(redis_key, str(instance_id), nx=True, ex=self._ttl_s)
|
||||
if won:
|
||||
return None
|
||||
REDIS_COMMANDS.labels(op="idempotency").inc()
|
||||
existing = await self._r.get(redis_key)
|
||||
except _REDIS_DOWN:
|
||||
REDIS_ERRORS.labels(op="idempotency").inc()
|
||||
_log.warning(
|
||||
"idempotency degraded: redis unavailable, falling through to the DB constraint",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
if existing is None:
|
||||
# The key expired between the SET and the GET. Vanishingly rare, and the honest
|
||||
# answer is "no winner recorded" — let the caller create and let Postgres decide.
|
||||
return None
|
||||
try:
|
||||
return UUID(_as_text(existing))
|
||||
except ValueError:
|
||||
_log.warning("idempotency key holds a non-UUID value; ignoring it")
|
||||
return None
|
||||
|
||||
|
||||
# --- Read cache -------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InstanceCacheProto(Protocol):
|
||||
"""Implemented by InstanceCache (here) and FakeInstanceCache (tests/fakes.py)."""
|
||||
|
||||
async def get(self, instance_id: UUID) -> Instance | None:
|
||||
"""The cached instance, or None for a miss. Must not raise."""
|
||||
...
|
||||
|
||||
async def put(self, inst: Instance) -> None:
|
||||
"""Cache `inst` for the TTL. Must not raise."""
|
||||
...
|
||||
|
||||
async def invalidate(self, instance_id: UUID) -> None:
|
||||
"""Drop the entry. Must not raise."""
|
||||
...
|
||||
|
||||
|
||||
class InstanceCache:
|
||||
"""Cache-aside for `GET /v1/instances/{id}`. TTL 30s.
|
||||
|
||||
Hit costs one command, miss costs two (the GET, then the SET after Postgres answers).
|
||||
That is ~1 per read at any useful hit rate, which is what keeps a read-heavy poller
|
||||
inside the budget.
|
||||
|
||||
The TTL is short on purpose and is the actual correctness argument. `invalidate()` on
|
||||
every state transition is the fast path, not the guarantee: the worker can crash
|
||||
between the UPDATE and the DEL, and then the cache is wrong. Thirty seconds bounds how
|
||||
wrong. Trusting the invalidation instead — and raising the TTL to an hour — is how a
|
||||
deleted instance stays `ready` in the API for an hour.
|
||||
"""
|
||||
|
||||
def __init__(self, r: Redis, ttl_s: int = 30) -> None:
|
||||
if ttl_s < 1:
|
||||
raise ValueError("ttl_s must be >= 1")
|
||||
self._r = r
|
||||
self._ttl_s = ttl_s
|
||||
|
||||
@staticmethod
|
||||
def _key(instance_id: UUID) -> str:
|
||||
return f"inst:{instance_id}"
|
||||
|
||||
async def get(self, instance_id: UUID) -> Instance | None:
|
||||
"""One GET. A miss, a Redis outage and a corrupt entry are all the same answer.
|
||||
|
||||
Which is the point: the caller writes `cache.get() or repo.get()` and has no branch
|
||||
for "Redis is broken", because there is nothing different to do about it.
|
||||
"""
|
||||
try:
|
||||
REDIS_COMMANDS.labels(op="cache_get").inc()
|
||||
raw = await self._r.get(self._key(instance_id))
|
||||
except _REDIS_DOWN:
|
||||
REDIS_ERRORS.labels(op="cache_get").inc()
|
||||
_log.warning("cache read degraded: redis unavailable, falling through to Postgres")
|
||||
return None
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return Instance.model_validate_json(raw)
|
||||
except ValidationError:
|
||||
# A model change deployed over a warm cache. Treat it as a miss and let the TTL
|
||||
# take the old shape out. Not an error: the truth is in Postgres either way.
|
||||
_log.info("cache entry failed validation; treating as a miss")
|
||||
return None
|
||||
|
||||
async def put(self, inst: Instance) -> None:
|
||||
"""One SET with `EX`. Never write a key here without a TTL."""
|
||||
try:
|
||||
REDIS_COMMANDS.labels(op="cache_put").inc()
|
||||
await self._r.set(self._key(inst.id), inst.model_dump_json(), ex=self._ttl_s)
|
||||
except _REDIS_DOWN:
|
||||
REDIS_ERRORS.labels(op="cache_put").inc()
|
||||
_log.warning("cache write degraded: redis unavailable")
|
||||
|
||||
async def invalidate(self, instance_id: UUID) -> None:
|
||||
"""One DEL. Called by the worker inside the code path that writes the state.
|
||||
|
||||
Inside that path, not after it and not from a subscriber: an invalidation that can
|
||||
be skipped by an early return is an invalidation that will be.
|
||||
"""
|
||||
try:
|
||||
REDIS_COMMANDS.labels(op="cache_del").inc()
|
||||
await self._r.delete(self._key(instance_id))
|
||||
except _REDIS_DOWN:
|
||||
REDIS_ERRORS.labels(op="cache_del").inc()
|
||||
_log.warning("cache invalidate degraded: redis unavailable; entry expires within the TTL")
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Pure domain logic: state machine, models, backoff math, catalog rules.
|
||||
|
||||
No I/O lives here except :func:`svcforge_core.domain.catalog.load_catalog`, which reads
|
||||
the one path it is handed. Nothing in this package is async.
|
||||
"""
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Retry backoff math. Pure: `now` and `rand` are injected, never called for you."""
|
||||
|
||||
import random
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def next_attempt_at(
|
||||
attempt: int,
|
||||
*,
|
||||
now: datetime,
|
||||
base_s: float = 2.0,
|
||||
cap_s: float = 300.0,
|
||||
rand: Callable[[], float] = random.random,
|
||||
) -> datetime:
|
||||
"""Exponential backoff with full jitter, capped. Pure: takes `now` and `rand`, never calls them itself.
|
||||
|
||||
Delay is uniform in [0, min(cap_s, base_s * 2**attempt)]. attempt is 0-based; raise ValueError if < 0.
|
||||
"""
|
||||
if attempt < 0:
|
||||
raise ValueError(f"attempt must be >= 0, got {attempt}")
|
||||
ceiling = min(cap_s, base_s * 2.0**attempt)
|
||||
delay_s = ceiling * rand()
|
||||
return now + timedelta(seconds=delay_s)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Load and validate catalog.yaml into CatalogEntry objects.
|
||||
|
||||
The only I/O in `domain/`: reading the one path handed to :func:`load_catalog`.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import ValidationError
|
||||
|
||||
from svcforge_core.domain.models import CatalogEntry
|
||||
|
||||
|
||||
class CatalogError(Exception):
|
||||
"""A catalog file could not be parsed or validated.
|
||||
|
||||
`key` names the offending service type, or None when the failure is file-level.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, key: str | None = None) -> None:
|
||||
self.key = key
|
||||
super().__init__(message if key is None else f"{key}: {message}")
|
||||
|
||||
|
||||
def load_catalog(path: Path) -> dict[str, CatalogEntry]:
|
||||
"""Parse catalog.yaml -> {service_type: CatalogEntry}. yaml.safe_load, never yaml.load.
|
||||
|
||||
Raise CatalogError (typed, with the offending key) on a bad file.
|
||||
"""
|
||||
try:
|
||||
raw: Any = yaml.safe_load(path.read_text())
|
||||
except OSError as exc:
|
||||
raise CatalogError(f"cannot read catalog at {path}: {exc}") from exc
|
||||
except yaml.YAMLError as exc:
|
||||
raise CatalogError(f"catalog at {path} is not valid YAML: {exc}") from exc
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise CatalogError(
|
||||
f"catalog at {path} must be a mapping of service_type -> entry, got {type(raw).__name__}"
|
||||
)
|
||||
|
||||
services: Any = raw.get("services", raw)
|
||||
if not isinstance(services, dict):
|
||||
raise CatalogError(f"catalog at {path}: 'services' must be a mapping, got {type(services).__name__}")
|
||||
|
||||
catalog: dict[str, CatalogEntry] = {}
|
||||
for key, body in services.items():
|
||||
service_type = str(key)
|
||||
if not isinstance(body, dict):
|
||||
raise CatalogError(f"entry must be a mapping, got {type(body).__name__}", key=service_type)
|
||||
try:
|
||||
catalog[service_type] = CatalogEntry(service_type=service_type, **body)
|
||||
except ValidationError as exc:
|
||||
raise CatalogError(f"invalid entry: {exc}", key=service_type) from exc
|
||||
except TypeError as exc:
|
||||
raise CatalogError(f"invalid entry: {exc}", key=service_type) from exc
|
||||
return catalog
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Domain models. Pydantic v2, all frozen.
|
||||
|
||||
`frozen=True` is not transitive: a `dict` field (e.g. `SizeSpec.resources`) stays mutable
|
||||
in place. Treat those dicts as read-only by convention.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from svcforge_core.domain.states import InstanceState
|
||||
|
||||
TEAM_PATTERN = r"^[a-z0-9-]+$"
|
||||
|
||||
|
||||
class TaskKind(StrEnum):
|
||||
"""What a worker is being asked to do."""
|
||||
|
||||
PROVISION = "provision"
|
||||
DEPROVISION = "deprovision"
|
||||
UPGRADE = "upgrade"
|
||||
VERIFY = "verify"
|
||||
|
||||
|
||||
class TaskState(StrEnum):
|
||||
"""Where a task is in the claim loop."""
|
||||
|
||||
QUEUED = "queued"
|
||||
RUNNING = "running"
|
||||
DONE = "done"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class SizeSpec(BaseModel):
|
||||
"""One t-shirt size of a catalog entry."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
replicas: int = Field(ge=1)
|
||||
resources: dict[str, Any]
|
||||
|
||||
|
||||
class CatalogEntry(BaseModel):
|
||||
"""One offerable service type and the sizes it comes in."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
service_type: str = Field(min_length=1)
|
||||
chart: str = Field(min_length=1)
|
||||
chart_version: str = Field(min_length=1)
|
||||
sizes: dict[str, SizeSpec]
|
||||
# Bypass tenant maintenance windows for this entry's upgrades. Defaults False: a
|
||||
# normal version bump waits for 03:00 Sunday; a CVE with a public exploit does not.
|
||||
security: bool = False
|
||||
|
||||
|
||||
class Instance(BaseModel):
|
||||
"""A provisioned (or in-flight) service instance owned by a team."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: UUID
|
||||
team: str = Field(min_length=1, pattern=TEAM_PATTERN)
|
||||
service_type: str
|
||||
size: str
|
||||
state: InstanceState
|
||||
namespace: str
|
||||
release_name: str
|
||||
chart_version: str
|
||||
endpoint: str | None = None
|
||||
error: str | None = None
|
||||
expires_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class Task(BaseModel):
|
||||
"""A unit of work against an instance, claimed by exactly one worker at a time."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: int
|
||||
instance_id: UUID
|
||||
kind: TaskKind
|
||||
state: TaskState
|
||||
attempts: int = Field(ge=0)
|
||||
run_after: datetime
|
||||
locked_by: str | None = None
|
||||
last_error: str | None = None
|
||||
|
||||
# The W3C trace context of whoever enqueued this. Null is normal, not an error: a task
|
||||
# the reconciler raised on its own tick has no inbound request to belong to.
|
||||
traceparent: str | None = None
|
||||
|
||||
# Denormalised from `instances` by the claim query. It is here so a worker can bind
|
||||
# `team` onto its log context at claim time, before it has loaded anything — the point
|
||||
# of structured logs is that the FIRST line already tells you whose tenant broke.
|
||||
team: str | None = None
|
||||
@@ -0,0 +1,37 @@
|
||||
"""The instance lifecycle state machine, encoded as data."""
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Final
|
||||
|
||||
|
||||
class InstanceState(StrEnum):
|
||||
"""Lifecycle of a provisioned service instance."""
|
||||
|
||||
REQUESTED = "requested"
|
||||
PROVISIONING = "provisioning"
|
||||
READY = "ready"
|
||||
DELETING = "deleting"
|
||||
DELETED = "deleted"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class IllegalTransition(Exception):
|
||||
"""Raised by transition() when cur -> nxt is not in LEGAL."""
|
||||
|
||||
|
||||
LEGAL: Final[dict[InstanceState, frozenset[InstanceState]]] = {
|
||||
InstanceState.REQUESTED: frozenset({InstanceState.PROVISIONING, InstanceState.FAILED}),
|
||||
InstanceState.PROVISIONING: frozenset({InstanceState.READY, InstanceState.FAILED}),
|
||||
InstanceState.READY: frozenset({InstanceState.DELETING, InstanceState.FAILED}),
|
||||
InstanceState.DELETING: frozenset({InstanceState.DELETED, InstanceState.FAILED}),
|
||||
# `deleted` is terminal: an empty frozenset, not a missing key.
|
||||
InstanceState.DELETED: frozenset(),
|
||||
InstanceState.FAILED: frozenset({InstanceState.PROVISIONING, InstanceState.DELETING}),
|
||||
}
|
||||
|
||||
|
||||
def transition(cur: InstanceState, nxt: InstanceState) -> InstanceState:
|
||||
"""Return nxt if LEGAL[cur] contains it, else raise IllegalTransition. Pure. No DB."""
|
||||
if nxt not in LEGAL[cur]:
|
||||
raise IllegalTransition(f"{cur} -> {nxt} is not a legal transition")
|
||||
return nxt
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Maintenance windows: when a tenant will tolerate an upgrade.
|
||||
|
||||
Pure domain. No I/O, no `Clock`, no `datetime.now()` anywhere in this file — `now` arrives
|
||||
as a parameter and every datetime crossing this module's boundary is aware and UTC. That
|
||||
is the whole discipline: `datetime.now()` is naive and lies, comparing aware to naive
|
||||
raises TypeError at 03:00 on a Sunday, and mypy will not catch it for you.
|
||||
|
||||
Local time is where the arithmetic has to happen, though. A window means "03:00 as the
|
||||
tenant reads a clock", which is not a fixed UTC offset in any zone that observes DST. So
|
||||
the cron is evaluated in the window's own zone and the result is converted back to UTC at
|
||||
the door.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from croniter import croniter
|
||||
|
||||
CRON_FIELDS = 5
|
||||
SEPARATOR = "|"
|
||||
|
||||
|
||||
class BadWindow(ValueError):
|
||||
"""A maintenance window spec is not a 5-field cron plus a known IANA zone."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MaintenanceWindow:
|
||||
"""When a tenant's instance may be upgraded."""
|
||||
|
||||
cron: str # standard 5-field cron
|
||||
tz: str # IANA name, e.g. 'Asia/Ho_Chi_Minh'
|
||||
|
||||
|
||||
def parse_window(spec: str | None) -> MaintenanceWindow | None:
|
||||
"""'0 3 * * 0|Asia/Ho_Chi_Minh' -> MaintenanceWindow. None -> None (upgrade any time).
|
||||
|
||||
Raise BadWindow on an invalid cron expression or unknown IANA zone. Validation happens
|
||||
here, once, on the way in — not at 03:00 in a worker, where the failure is a task that
|
||||
dies inside someone else's maintenance window.
|
||||
"""
|
||||
if spec is None or not spec.strip():
|
||||
return None
|
||||
|
||||
cron, sep, tz = spec.partition(SEPARATOR)
|
||||
if not sep:
|
||||
raise BadWindow(f"window {spec!r} is not 'CRON{SEPARATOR}IANA_ZONE'")
|
||||
|
||||
cron, tz = cron.strip(), tz.strip()
|
||||
|
||||
# croniter's is_valid() accepts a 6-field form (with seconds). The column is documented
|
||||
# as 5-field, so a 6th field is a typo, not a feature.
|
||||
if len(cron.split()) != CRON_FIELDS:
|
||||
raise BadWindow(f"cron {cron!r} must have exactly {CRON_FIELDS} fields")
|
||||
if not croniter.is_valid(cron):
|
||||
raise BadWindow(f"cron {cron!r} is not a valid cron expression")
|
||||
|
||||
try:
|
||||
ZoneInfo(tz)
|
||||
except (ZoneInfoNotFoundError, ValueError) as exc:
|
||||
# ZoneInfoNotFoundError in a slim image means tzdata is missing, not that the zone
|
||||
# is fictional. Same exception, so the message names both possibilities.
|
||||
raise BadWindow(f"timezone {tz!r} is not a known IANA zone (is tzdata installed?)") from exc
|
||||
|
||||
return MaintenanceWindow(cron=cron, tz=tz)
|
||||
|
||||
|
||||
def next_window_open(window: MaintenanceWindow | None, now: datetime) -> datetime:
|
||||
"""Next time the window opens, as an aware UTC datetime.
|
||||
|
||||
`now` must be aware; raise ValueError if it is naive. window=None -> return now.
|
||||
|
||||
Inclusive of `now`: if the window opens at this exact instant, that instant is the
|
||||
answer. Otherwise an upgrade that became due precisely at 03:00 would be pushed a
|
||||
whole week.
|
||||
"""
|
||||
if now.tzinfo is None or now.utcoffset() is None:
|
||||
raise ValueError(f"now must be an aware datetime, got naive {now!r}")
|
||||
|
||||
if window is None:
|
||||
return now.astimezone(UTC)
|
||||
|
||||
local_now = now.astimezone(ZoneInfo(window.tz))
|
||||
|
||||
# croniter.get_next() is strictly greater than its start. Backing the start off by one
|
||||
# second makes a `now` that lands exactly on a cron minute return itself; a `now` at
|
||||
# 03:00:30 still rolls to the next occurrence, because cron times are minute-aligned
|
||||
# and 03:00:00 is already behind 03:00:29.
|
||||
start = local_now - timedelta(seconds=1)
|
||||
local_next: datetime = croniter(window.cron, start).get_next(datetime)
|
||||
|
||||
return local_next.astimezone(UTC)
|
||||
|
||||
|
||||
def schedule_upgrade_at(window: MaintenanceWindow | None, security: bool, now: datetime) -> datetime:
|
||||
"""run_after for an upgrade task. security=True bypasses the window -> now.
|
||||
|
||||
A security fix with a public exploit does not wait until Sunday. That is the entire
|
||||
reason `security:` exists in the catalog.
|
||||
|
||||
`now` must be aware here too, bypass or not: the naive-datetime rule does not get a
|
||||
hole punched in it by the branch that skips the window.
|
||||
"""
|
||||
if now.tzinfo is None or now.utcoffset() is None:
|
||||
raise ValueError(f"now must be an aware datetime, got naive {now!r}")
|
||||
if security:
|
||||
return now.astimezone(UTC)
|
||||
return next_window_open(window, now)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Migration runner. Twenty lines of psycopg, not Alembic.
|
||||
|
||||
Three rules this file exists to enforce:
|
||||
|
||||
1. Never migrate on app startup. N replicas would race. This runs as a Helm
|
||||
pre-upgrade/pre-install hook Job, once, before any new pod serves traffic.
|
||||
2. Forward-only. There are no down scripts. A mistake is fixed by a new migration.
|
||||
3. Expand/contract. Add nullable, backfill, switch reads, drop the old column in a
|
||||
LATER release. A rename is three deploys, never one.
|
||||
|
||||
Run: python -m svcforge_core.migrate
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg
|
||||
|
||||
from svcforge_core.settings import load_settings
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).resolve().parents[3] / "migrations"
|
||||
|
||||
# Advisory lock: two Jobs racing (a retried hook, a hand-run) must not both apply DDL.
|
||||
# Session-scoped, so this needs the session pooler (5432), not pgbouncer (6543).
|
||||
_LOCK_KEY = 0x5643_464F # "SVCFO"
|
||||
|
||||
_BOOTSTRAP = """
|
||||
create table if not exists schema_migrations (
|
||||
filename text primary key,
|
||||
applied_at timestamptz not null default now()
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def _pending(conn: psycopg.Connection[tuple[str, ...]], files: list[Path]) -> list[Path]:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("select filename from schema_migrations")
|
||||
done = {row[0] for row in cur.fetchall()}
|
||||
return [f for f in files if f.name not in done]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
settings = load_settings()
|
||||
files = sorted(MIGRATIONS_DIR.glob("*.sql"))
|
||||
if not files:
|
||||
print(f"no migrations found in {MIGRATIONS_DIR}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with psycopg.connect(settings.migration_dsn, autocommit=True) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("select pg_advisory_lock(%s)", (_LOCK_KEY,))
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(_BOOTSTRAP)
|
||||
|
||||
pending = _pending(conn, files)
|
||||
if not pending:
|
||||
print("up to date, nothing to apply")
|
||||
return 0
|
||||
|
||||
for path in pending:
|
||||
sql = path.read_text(encoding="utf-8")
|
||||
# One transaction per file: a file applies completely or not at all.
|
||||
with conn.transaction():
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(sql)
|
||||
cur.execute(
|
||||
"insert into schema_migrations (filename) values (%s)",
|
||||
(path.name,),
|
||||
)
|
||||
print(f"applied: {path.name}")
|
||||
finally:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("select pg_advisory_unlock(%s)", (_LOCK_KEY,))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Logs, traces, metrics. One setup() call, made once, before anything else.
|
||||
|
||||
Three libraries, one module, because the three are one decision. A log line without the
|
||||
trace id it belongs to is a log line you cannot join to anything; a span without the
|
||||
`instance_id` the request is about is a span you cannot search for. They are wired here
|
||||
together so that no service can configure two of the three and ship.
|
||||
|
||||
The three things that make this module worth reading:
|
||||
|
||||
1. **Context does not cross a queue.** A trace is a chain of parent/child span contexts
|
||||
passed in-process or over a wire header. `POST /v1/instances` inserts a row and
|
||||
returns; the worker picks that row up ninety seconds later in a different pod. Nothing
|
||||
carries the context across — unless we carry it ourselves. So: `inject_traceparent()`
|
||||
at enqueue, a `traceparent` column, `context_from_traceparent()` at claim. Two
|
||||
disconnected traces in Tempo is the symptom of skipping this.
|
||||
|
||||
2. **Histogram buckets are a domain decision.** prometheus_client's defaults top out at
|
||||
10 seconds because they were chosen for HTTP handlers. A provision is `helm --wait` on
|
||||
a StatefulSet: minutes. With the defaults every observation lands in `+Inf`,
|
||||
`histogram_quantile` interpolates inside a bucket that spans 10s→infinity, and the p95
|
||||
it prints is a number with no relationship to reality. The buckets below are sized for
|
||||
what is being measured.
|
||||
|
||||
3. **One process per pod.** prometheus_client keeps its registry in process memory. Run
|
||||
`uvicorn --workers 4` and Prometheus scrapes whichever of the four children the socket
|
||||
happens to hand it, so counters appear to jump backwards. There are two fixes:
|
||||
`PROMETHEUS_MULTIPROC_DIR` + `MultiProcessCollector` (a shared mmap directory, a
|
||||
gauge-mode decision at every call site, and dead files to garbage-collect after every
|
||||
crash), or one process per pod and scale with replicas. This repo takes the second.
|
||||
`PROMETHEUS_MULTIPROC_DIR` is deliberately not set, and nothing here reads it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.context import Context
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
||||
from prometheus_client import Counter, Gauge, Histogram, start_http_server
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from svcforge_core.settings import Settings
|
||||
|
||||
# --- Metrics ----------------------------------------------------------------------------
|
||||
#
|
||||
# Module level, created exactly once at import. A second registration of the same name
|
||||
# against the default registry raises ValueError, which is a feature: it turns "two modules
|
||||
# each defined their own copy of this counter" into an ImportError at startup instead of a
|
||||
# metric that silently reports half the truth.
|
||||
|
||||
TASKS_CLAIMED = Counter(
|
||||
"svcforge_tasks_claimed_total",
|
||||
"Tasks claimed off the queue by a worker.",
|
||||
["kind"],
|
||||
)
|
||||
|
||||
TASKS_FAILED = Counter(
|
||||
"svcforge_tasks_failed_total",
|
||||
"Tasks that exhausted their attempts and went to 'failed'.",
|
||||
["kind"],
|
||||
)
|
||||
|
||||
PROVISION_TIME = Histogram(
|
||||
"svcforge_provision_duration_seconds",
|
||||
"Wall time of a provision task, claim to terminal report.",
|
||||
# NOT the defaults. See the module docstring: the defaults end at 10s and a provision
|
||||
# takes minutes. The top finite bucket is 1800 because helm's own --timeout is 600 and
|
||||
# a provision past thirty minutes is not slow, it is broken and belongs in +Inf.
|
||||
buckets=(10, 30, 60, 120, 300, 600, 1800, float("inf")),
|
||||
)
|
||||
|
||||
QUEUE_DEPTH = Gauge(
|
||||
"svcforge_queue_depth",
|
||||
"Runnable tasks waiting in the queue. Set by the reconciler each tick.",
|
||||
)
|
||||
|
||||
INSTANCES = Gauge(
|
||||
"svcforge_instances",
|
||||
"Instances by lifecycle state. Set by the reconciler each tick.",
|
||||
["state"],
|
||||
)
|
||||
|
||||
RECONCILER_LAST_TICK = Gauge(
|
||||
"svcforge_reconciler_last_tick_timestamp_seconds",
|
||||
"Unix time of the reconciler's last completed tick. The liveness signal that matters.",
|
||||
)
|
||||
|
||||
# --- Wiring -----------------------------------------------------------------------------
|
||||
|
||||
_TRACER_NAME = "svcforge"
|
||||
|
||||
# setup() is idempotent because it is called from three entrypoints and from tests, and
|
||||
# because configuring structlog twice silently discards the first configuration while
|
||||
# adding a second stdout handler to the root logger — every line then prints twice.
|
||||
_configured = False
|
||||
|
||||
_propagator = TraceContextTextMapPropagator()
|
||||
|
||||
|
||||
def _add_trace_ids(
|
||||
_logger: Any, # noqa: ANN401 - structlog's Processor signature; the logger is untyped
|
||||
_method: str,
|
||||
event_dict: structlog.typing.EventDict,
|
||||
) -> structlog.typing.EventDict:
|
||||
"""Stamp the active trace/span id onto the line, if there is one.
|
||||
|
||||
This is the join key. Without it, "find the logs for this trace" is a full-text search
|
||||
over a time window and a guess; with it, it is one query. Hex-formatted to the widths
|
||||
the W3C spec uses, so the value pasted from Tempo matches the value in Loki.
|
||||
"""
|
||||
span = trace.get_current_span()
|
||||
ctx = span.get_span_context()
|
||||
if ctx.is_valid:
|
||||
event_dict["trace_id"] = format(ctx.trace_id, "032x")
|
||||
event_dict["span_id"] = format(ctx.span_id, "016x")
|
||||
return event_dict
|
||||
|
||||
|
||||
def setup(service_name: str, settings: Settings) -> None:
|
||||
"""Configure structlog, the tracer provider, and the metric registry. Idempotent.
|
||||
|
||||
Called once from each service's entrypoint, before anything else — "before anything
|
||||
else" because any logger bound before this runs keeps the default configuration
|
||||
(`cache_logger_on_first_use`), and a module-level `log = structlog.get_logger()` in an
|
||||
import that lands first will print unstructured text forever.
|
||||
"""
|
||||
global _configured # process-wide config is process-wide state
|
||||
if _configured:
|
||||
return
|
||||
|
||||
_setup_logging(service_name, settings)
|
||||
_setup_tracing(service_name, settings)
|
||||
_configured = True
|
||||
|
||||
|
||||
def _setup_logging(service_name: str, settings: Settings) -> None:
|
||||
"""structlog + stdlib logging, both rendering JSON to stdout through one handler.
|
||||
|
||||
The stdlib half is not optional. `psycopg`, `httpx`, `uvicorn` and the OTEL SDK all log
|
||||
through `logging`; without the ProcessorFormatter bridge below, their lines arrive as
|
||||
bare text on the same stdout and every one of them is a parse failure in the collector.
|
||||
"""
|
||||
level = getattr(logging, settings.log_level.upper(), logging.INFO)
|
||||
|
||||
shared: list[structlog.typing.Processor] = [
|
||||
# FIRST. This is what puts instance_id/task_id/team on every line, including the
|
||||
# lines written by code that has never heard of them.
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.stdlib.add_logger_name,
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
||||
_add_trace_ids,
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
]
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
*shared,
|
||||
# Hands off to the stdlib formatter below, which appends the renderer. This is
|
||||
# what lets one handler render both structlog and foreign logs identically.
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
],
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
# The renderer goes LAST and nothing follows it: it turns the event dict into a string,
|
||||
# so any processor after it receives a str where it expects a dict and raises.
|
||||
renderer: structlog.typing.Processor = (
|
||||
structlog.processors.JSONRenderer() if settings.log_json else structlog.dev.ConsoleRenderer()
|
||||
)
|
||||
formatter = structlog.stdlib.ProcessorFormatter(
|
||||
foreign_pre_chain=shared, # applied to records from logging.getLogger(...) callers
|
||||
processors=[structlog.stdlib.ProcessorFormatter.remove_processors_meta, renderer],
|
||||
)
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
root = logging.getLogger()
|
||||
# Replace rather than append: basicConfig may already have run, and two handlers means
|
||||
# two copies of every line. stdout only — a container writes logs to stdout and the
|
||||
# collector tails them from there. A log file inside a pod is deleted with the pod.
|
||||
root.handlers = [handler]
|
||||
root.setLevel(level)
|
||||
|
||||
structlog.contextvars.bind_contextvars(service=service_name)
|
||||
|
||||
|
||||
def _setup_tracing(service_name: str, settings: Settings) -> None:
|
||||
"""Set the global tracer provider, exporting over OTLP when an endpoint is configured.
|
||||
|
||||
Skipped entirely when something already set a provider: the API runs under
|
||||
`opentelemetry-instrument`, whose auto-instrumentation installs one before our
|
||||
`main()` is reached. Overwriting it drops the FastAPI and psycopg instrumentation's
|
||||
spans on the floor, and the SDK only logs a warning about it.
|
||||
"""
|
||||
if isinstance(trace.get_tracer_provider(), TracerProvider):
|
||||
return
|
||||
|
||||
provider = TracerProvider(resource=Resource.create({"service.name": service_name}))
|
||||
|
||||
if settings.otel_endpoint:
|
||||
exporter = _otlp_exporter(settings.otel_endpoint)
|
||||
if exporter is not None:
|
||||
# Batch, not Simple: SimpleSpanProcessor exports inline on span end, so every
|
||||
# helm span would block on a network round trip to the collector.
|
||||
provider.add_span_processor(BatchSpanProcessor(exporter))
|
||||
|
||||
trace.set_tracer_provider(provider)
|
||||
|
||||
|
||||
def _otlp_exporter(endpoint: str) -> Any | None: # noqa: ANN401 - one of two exporter classes
|
||||
"""The OTLP exporter, if the optional exporter package is installed.
|
||||
|
||||
Optional on purpose. In the cluster the API runs under `opentelemetry-instrument`,
|
||||
which brings its own exporter and configures it from `OTEL_EXPORTER_OTLP_*`. Making it
|
||||
a hard dependency of the shared library would mean every unit test imports gRPC.
|
||||
"""
|
||||
try:
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||
except ImportError:
|
||||
logging.getLogger(__name__).warning(
|
||||
"otel_endpoint is set but no OTLP exporter is installed; traces stay in-process",
|
||||
extra={"endpoint": endpoint},
|
||||
)
|
||||
return None
|
||||
return OTLPSpanExporter(endpoint=endpoint)
|
||||
|
||||
|
||||
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||
"""A bound logger. Call it inside a function, not at import time — see setup()."""
|
||||
logger: structlog.stdlib.BoundLogger = structlog.get_logger(name)
|
||||
return logger
|
||||
|
||||
|
||||
def tracer() -> trace.Tracer:
|
||||
"""The svcforge tracer. Manual spans wrap helm calls, and nothing else.
|
||||
|
||||
Everything else is auto-instrumented (FastAPI, psycopg). A hand-rolled span around a
|
||||
function that the SDK already wraps is a duplicated span and a maintenance cost.
|
||||
"""
|
||||
return trace.get_tracer(_TRACER_NAME)
|
||||
|
||||
|
||||
def start_metrics_server(port: int) -> None:
|
||||
"""Expose /metrics on `port`, for the services with no HTTP server of their own.
|
||||
|
||||
The worker and reconciler have no Service; the chart's PodMonitor scrapes them by pod
|
||||
on the port named `metrics`. The API does not use this — it mounts the same registry on
|
||||
its own ASGI app.
|
||||
"""
|
||||
start_http_server(port)
|
||||
|
||||
|
||||
# --- Context that has to cross a process boundary ---------------------------------------
|
||||
|
||||
|
||||
def bind_task_context(instance_id: UUID, task_id: int, team: str) -> None:
|
||||
"""Bind the three keys every log line in a task must carry. Called at claim time.
|
||||
|
||||
`clear_contextvars()` first, and this is the whole reason the function exists rather
|
||||
than three `bind_contextvars` calls at the call site. A worker coroutine reuses its
|
||||
context across loop iterations; without the clear, task 41's `instance_id` is still
|
||||
bound when task 42 starts logging, and the log for the incident you are debugging
|
||||
names the wrong tenant. Contextvars are per-task in asyncio, which makes this safe
|
||||
under the concurrency semaphore: two handlers running at once do not see each other's.
|
||||
"""
|
||||
structlog.contextvars.clear_contextvars()
|
||||
structlog.contextvars.bind_contextvars(
|
||||
instance_id=str(instance_id),
|
||||
task_id=task_id,
|
||||
team=team,
|
||||
)
|
||||
|
||||
|
||||
def inject_traceparent() -> str | None:
|
||||
"""Serialise the active span context to a W3C traceparent, for the tasks row.
|
||||
|
||||
None when there is no recording span — a task enqueued by the reconciler's own tick has
|
||||
no inbound request to be part of. Nullable column, nullable return: an untraced task is
|
||||
normal, not an error.
|
||||
"""
|
||||
carrier: dict[str, str] = {}
|
||||
_propagator.inject(carrier)
|
||||
return carrier.get("traceparent")
|
||||
|
||||
|
||||
def context_from_traceparent(traceparent: str | None) -> Context:
|
||||
"""Inverse of inject_traceparent. Used at claim to parent the worker span to the API's.
|
||||
|
||||
An empty Context for None or for a malformed value — `extract` does not raise on a
|
||||
traceparent that fails to parse, it returns the carrier's context unchanged, and the
|
||||
resulting span starts a new trace. A bad header must never fail a provision.
|
||||
"""
|
||||
if not traceparent:
|
||||
return Context()
|
||||
return _propagator.extract({"traceparent": traceparent})
|
||||
@@ -0,0 +1 @@
|
||||
"""SQL. Rows in, domain objects out. Knows psycopg; knows nothing about HTTP."""
|
||||
@@ -0,0 +1,57 @@
|
||||
"""The connection pool.
|
||||
|
||||
Two settings here are load-bearing on Supabase's transaction pooler (port 6543) and
|
||||
cost hours if you get them wrong. Both are documented at the call site below.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg_pool import AsyncConnectionPool
|
||||
|
||||
# The pool hands out dict-row connections because of `row_factory=dict_row` below. Say so
|
||||
# in the type system too, or every `row["attempts"]` in this codebase is a mypy error
|
||||
# against a bare `AsyncConnectionPool`, which resolves to tuple rows. The runtime was
|
||||
# always right; without these aliases the annotations quietly disagree with it, and the
|
||||
# fix people reach for is `# type: ignore`, which throws away the checking entirely.
|
||||
type DictRow = dict[str, Any]
|
||||
type DictConnection = AsyncConnection[DictRow]
|
||||
type DictPool = AsyncConnectionPool[DictConnection]
|
||||
|
||||
|
||||
def make_pool(dsn: str, min_size: int = 1, max_size: int = 5) -> DictPool:
|
||||
"""Construct the pool. Does NOT open it — the caller owns open/close.
|
||||
|
||||
`open=False` is deliberate: the constructor does zero I/O, so building a pool at
|
||||
import time and never opening it fails later as a PoolTimeout at first use, far
|
||||
from the cause. The caller (a FastAPI lifespan, a worker main) opens and closes it.
|
||||
|
||||
kwargs are per-connection:
|
||||
|
||||
* `prepare_threshold=None` — REQUIRED through pgbouncer in transaction mode.
|
||||
psycopg3 auto-prepares a statement after it sees it 5 times. pgbouncer may hand
|
||||
the next execution to a different backend, which has never heard of that prepared
|
||||
statement. Symptom: everything works for exactly five calls, then
|
||||
`prepared statement "_pg3_0" does not exist` — intermittent, only under
|
||||
concurrency, never in a unit test.
|
||||
|
||||
* `row_factory=dict_row` — rows arrive as dicts, so `Instance.model_validate(row)`
|
||||
works directly instead of unpacking tuples by position.
|
||||
|
||||
Also gone on 6543: LISTEN/NOTIFY, session-level SET, cross-statement advisory locks.
|
||||
`SELECT ... FOR UPDATE SKIP LOCKED` inside one transaction is unaffected — which is
|
||||
exactly why the queue is built on it. Use the session pooler (5432) for migrations.
|
||||
|
||||
max_size is a database-capacity decision, not a throughput knob: the free tier has a
|
||||
small connection budget, and replicas multiply this number.
|
||||
"""
|
||||
return AsyncConnectionPool(
|
||||
conninfo=dsn,
|
||||
min_size=min_size,
|
||||
max_size=max_size,
|
||||
open=False,
|
||||
kwargs={"prepare_threshold": None, "row_factory": dict_row, "autocommit": False},
|
||||
)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Instance persistence.
|
||||
|
||||
AuthZ lives in the WHERE clause. `get(id, team)` filters by team in SQL rather than
|
||||
fetching the row and comparing in Python: a wrong-team id must be indistinguishable
|
||||
from a nonexistent one, and a check you can forget to write is a check you will forget
|
||||
to write.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from svcforge_core.domain.models import Instance
|
||||
from svcforge_core.domain.states import InstanceState
|
||||
from svcforge_core.repo.db import DictPool
|
||||
|
||||
_COLUMNS = """id, team, service_type, size, state, namespace, release_name, chart_version,
|
||||
endpoint, error, expires_at, created_at, updated_at"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpgradeCandidate:
|
||||
"""One row of the day-2 work list: an instance, plus the raw window spec to schedule in.
|
||||
|
||||
`maintenance_window` rides alongside rather than inside `Instance` because `create()`
|
||||
never writes it — folding it into the model would mean every freshly created Instance
|
||||
reports `maintenance_window=None` whether or not the row has one. A column only the
|
||||
work list reads is a column only the work list carries.
|
||||
"""
|
||||
|
||||
instance: Instance
|
||||
maintenance_window: str | None
|
||||
|
||||
|
||||
class InstanceRepo:
|
||||
"""Reads and writes `instances`."""
|
||||
|
||||
def __init__(self, pool: DictPool) -> None:
|
||||
self._pool = pool
|
||||
|
||||
async def create(self, conn: AsyncConnection[dict[str, Any]], inst: Instance) -> Instance:
|
||||
"""Insert one instance.
|
||||
|
||||
Takes a `conn` rather than using the pool so the caller can share a transaction
|
||||
with TaskRepo.enqueue — the instance row and its provision task must commit
|
||||
together or not at all. That single fact is why the queue is in Postgres.
|
||||
"""
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
f"""insert into instances (id, team, service_type, size, state, namespace,
|
||||
release_name, chart_version, endpoint, error, expires_at)
|
||||
values (%(id)s, %(team)s, %(service_type)s, %(size)s, %(state)s, %(namespace)s,
|
||||
%(release_name)s, %(chart_version)s, %(endpoint)s, %(error)s, %(expires_at)s)
|
||||
returning {_COLUMNS}""", # noqa: S608 - _COLUMNS is a module constant, not input
|
||||
{
|
||||
"id": inst.id,
|
||||
"team": inst.team,
|
||||
"service_type": inst.service_type,
|
||||
"size": inst.size,
|
||||
"state": inst.state.value,
|
||||
"namespace": inst.namespace,
|
||||
"release_name": inst.release_name,
|
||||
"chart_version": inst.chart_version,
|
||||
"endpoint": inst.endpoint,
|
||||
"error": inst.error,
|
||||
"expires_at": inst.expires_at,
|
||||
},
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
assert row is not None # noqa: S101 - `returning` always yields a row or raises
|
||||
return Instance.model_validate(row)
|
||||
|
||||
async def get(self, id: UUID, team: str) -> Instance | None:
|
||||
"""Fetch one instance owned by `team`. None if it does not exist OR is not theirs."""
|
||||
async with self._pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
f"select {_COLUMNS} from instances where id = %s and team = %s", # noqa: S608
|
||||
(id, team),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
return Instance.model_validate(row) if row else None
|
||||
|
||||
async def list(self, team: str, limit: int = 50) -> list[Instance]:
|
||||
"""The team's instances, newest first."""
|
||||
async with self._pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
f"""select {_COLUMNS} from instances
|
||||
where team = %s order by created_at desc limit %s""", # noqa: S608
|
||||
(team, limit),
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
return [Instance.model_validate(r) for r in rows]
|
||||
|
||||
async def update_state(
|
||||
self,
|
||||
id: UUID,
|
||||
expect: InstanceState,
|
||||
to: InstanceState,
|
||||
error: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
) -> bool:
|
||||
"""Compare-and-set. False if the row moved under you.
|
||||
|
||||
`where id=%s and state=%s` is the whole trick: two workers racing to move the
|
||||
same instance means exactly one UPDATE matches a row. The loser gets False and
|
||||
must not treat it as an error — it means someone else already did the work.
|
||||
"""
|
||||
async with self._pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""update instances
|
||||
set state = %(to)s,
|
||||
error = %(error)s,
|
||||
endpoint = coalesce(%(endpoint)s, endpoint),
|
||||
updated_at = now()
|
||||
where id = %(id)s and state = %(expect)s""",
|
||||
{
|
||||
"id": id,
|
||||
"expect": expect.value,
|
||||
"to": to.value,
|
||||
"error": error,
|
||||
"endpoint": endpoint,
|
||||
},
|
||||
)
|
||||
return cur.rowcount == 1
|
||||
|
||||
async def set_error(self, id: UUID, error: str) -> None:
|
||||
"""Record a terminal failure message. Used when a task exhausts its attempts."""
|
||||
async with self._pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"update instances set error = %s, state = %s, updated_at = now() where id = %s",
|
||||
(error[-2000:], InstanceState.FAILED.value, id),
|
||||
)
|
||||
|
||||
async def list_upgradable(
|
||||
self,
|
||||
service_type: str,
|
||||
catalog_version: str,
|
||||
own_team: str,
|
||||
max_in_flight: int = 1,
|
||||
) -> Sequence[UpgradeCandidate]:
|
||||
"""The day-2 work list: `ready` instances not yet on the catalog's pinned version.
|
||||
|
||||
The whole rollout is this query. Three clauses carry it:
|
||||
|
||||
`not exists (... rollout_state = 'halted')` — a failed `verify` writes one column
|
||||
and this query goes empty for that service type. That is the stop button: no
|
||||
rollouts table, no state machine, no pause/resume CLI. You clear it with SQL.
|
||||
|
||||
`order by team = %(own_team)s desc` — your own instances upgrade first, so you are
|
||||
the tenant who finds out the chart is broken. Eating your own dog food is a
|
||||
`order by`, not a policy document.
|
||||
|
||||
`limit %(max_in_flight)s` — a config value, not a scheduler. It stays at 1 until 1
|
||||
is too slow, and 1 is what makes the halt meaningful: the fleet stops after the
|
||||
first casualty rather than after all of them.
|
||||
"""
|
||||
# `row_factory=dict_row` is already the pool's per-connection default; naming it
|
||||
# here is for mypy, which cannot see a row factory passed through a kwargs dict
|
||||
# and would otherwise type these rows as tuples.
|
||||
async with self._pool.connection() as conn, conn.cursor(row_factory=dict_row) as cur:
|
||||
await cur.execute(
|
||||
f"""select {_COLUMNS}, maintenance_window from instances
|
||||
where state = 'ready'
|
||||
and service_type = %(service_type)s
|
||||
and chart_version <> %(catalog_version)s
|
||||
and not exists (
|
||||
select 1 from catalog_versions cv
|
||||
where cv.service_type = %(service_type)s
|
||||
and cv.rollout_state = 'halted')
|
||||
order by team = %(own_team)s desc, created_at
|
||||
limit %(max_in_flight)s""", # noqa: S608 - _COLUMNS is a module constant, not input
|
||||
{
|
||||
"service_type": service_type,
|
||||
"catalog_version": catalog_version,
|
||||
"own_team": own_team,
|
||||
"max_in_flight": max_in_flight,
|
||||
},
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
return [
|
||||
UpgradeCandidate(
|
||||
instance=Instance.model_validate(r),
|
||||
maintenance_window=r["maintenance_window"],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
@@ -0,0 +1,276 @@
|
||||
"""The reconciler's SQL.
|
||||
|
||||
Why this file exists rather than the queries living in `services/reconciler/main.py`: the
|
||||
layer rule says transport knows nothing about SQL, and the reconciler is transport — a CLI
|
||||
entrypoint. It gets its own repo module rather than growing `InstanceRepo` and `TaskRepo`
|
||||
because everything here is a *sweep*: it reads rows nobody asked about and it writes an
|
||||
instance state and a task row in the same transaction. `InstanceRepo.update_state` owns its
|
||||
own connection by design, so the reconciler cannot get atomicity from it without reaching
|
||||
around the repo — which is the thing the layer rule exists to prevent.
|
||||
|
||||
The recurring shape below is: lock the row, re-check the condition under the lock, act.
|
||||
The re-check is not paranoia about concurrency — the reconciler is a singleton. It is what
|
||||
makes the sweep idempotent against *itself*: a tick that crashes after the insert and
|
||||
before the commit must leave nothing behind, and the next tick must not double-enqueue.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from psycopg import AsyncCursor
|
||||
|
||||
from svcforge_core.domain.models import Instance, TaskKind, TaskState
|
||||
from svcforge_core.domain.states import InstanceState, transition
|
||||
from svcforge_core.obs import inject_traceparent
|
||||
from svcforge_core.repo.db import DictPool
|
||||
|
||||
_COLUMNS = """id, team, service_type, size, state, namespace, release_name, chart_version,
|
||||
endpoint, error, expires_at, created_at, updated_at"""
|
||||
|
||||
# A task nobody will ever run again. The idempotency guard on every enqueue below asks
|
||||
# "is one already outstanding?", and 'done'/'failed' are not outstanding: a failed
|
||||
# deprovision that exhausted its attempts must be re-enqueueable by the next sweep, or a
|
||||
# transient cluster outage would permanently strand the instance.
|
||||
_UNFINISHED = (TaskState.QUEUED.value, TaskState.RUNNING.value)
|
||||
|
||||
|
||||
class ReconcileRepo:
|
||||
"""Fleet-wide sweeps. Read-mostly, and every write is one transaction."""
|
||||
|
||||
def __init__(self, pool: DictPool) -> None:
|
||||
self._pool = pool
|
||||
|
||||
# --- Gauges -------------------------------------------------------------------------
|
||||
|
||||
async def queue_depth(self) -> int:
|
||||
"""Tasks waiting to be claimed.
|
||||
|
||||
Counts every `queued` row, not just the runnable ones (`run_after <= now()`). The
|
||||
alert on this gauge is `deriv(...) > 0` — "the backlog is growing" — and a backlog
|
||||
of tasks parked on backoff is exactly the backlog you want to see growing.
|
||||
"""
|
||||
async with self._pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute("select count(*) as n from tasks where state = %s", (TaskState.QUEUED.value,))
|
||||
row = await cur.fetchone()
|
||||
return int(row["n"]) if row else 0
|
||||
|
||||
async def instance_counts(self) -> dict[str, int]:
|
||||
"""Instances per lifecycle state. States with no rows are absent, not zero."""
|
||||
async with self._pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute("select state, count(*) as n from instances group by state")
|
||||
rows = await cur.fetchall()
|
||||
return {str(r["state"]): int(r["n"]) for r in rows}
|
||||
|
||||
# --- Drift --------------------------------------------------------------------------
|
||||
|
||||
async def ready_instances(self) -> list[Instance]:
|
||||
"""Every instance the DB believes is running. The drift check's expectation."""
|
||||
async with self._pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
f"select {_COLUMNS} from instances where state = %s", # noqa: S608 - module constant
|
||||
(InstanceState.READY.value,),
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
return [Instance.model_validate(r) for r in rows]
|
||||
|
||||
async def known_releases(self) -> set[tuple[str, str]]:
|
||||
"""(release_name, namespace) for every instance row, in any state.
|
||||
|
||||
Any state, deliberately. An instance that is still `requested` has no release yet,
|
||||
but a worker may be installing it *right now* — treating it as unknown would
|
||||
report a healthy in-flight provision as an orphan on every tick.
|
||||
"""
|
||||
async with self._pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute("select release_name, namespace from instances")
|
||||
rows = await cur.fetchall()
|
||||
return {(str(r["release_name"]), str(r["namespace"])) for r in rows}
|
||||
|
||||
async def enqueue_reprovision(self, instance_id: UUID, reason: str) -> int | None:
|
||||
"""`ready` instance whose release vanished -> back to `provisioning`, with a task.
|
||||
|
||||
None if the row moved, or if a provision is already outstanding.
|
||||
|
||||
The two-hop state change is the interesting part, and it is not busywork:
|
||||
|
||||
* `LEGAL` has no `ready -> provisioning` edge. The tenant-visible lifecycle only
|
||||
leaves `ready` through `deleting` or `failed`, and drift is a failure — the
|
||||
service the tenant is paying for is gone. So: `ready -> failed -> provisioning`,
|
||||
both edges legal, asserted below by the domain function rather than assumed.
|
||||
* The row must land in `provisioning`, not `failed`, before the worker sees the
|
||||
task. `handle_provision` CASes `requested -> provisioning` best-effort and then
|
||||
CASes `provisioning -> ready` for real; hand it a `failed` row and helm runs, the
|
||||
final CAS matches nothing, and the instance sits in `failed` forever with a
|
||||
healthy release behind it.
|
||||
|
||||
Both hops and the insert are one transaction, so the row is never observable in the
|
||||
intermediate `failed` state and a crash mid-sweep leaves nothing half-done.
|
||||
"""
|
||||
async with self._pool.connection() as conn:
|
||||
async with conn.transaction(), conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"select state from instances where id = %s and state = %s for update",
|
||||
(instance_id, InstanceState.READY.value),
|
||||
)
|
||||
if await cur.fetchone() is None:
|
||||
return None
|
||||
if await _has_unfinished(cur, instance_id, TaskKind.PROVISION):
|
||||
return None
|
||||
|
||||
# Assert the path through the state machine instead of trusting the SQL.
|
||||
# If someone edits LEGAL, this raises here rather than corrupting rows.
|
||||
failed = transition(InstanceState.READY, InstanceState.FAILED)
|
||||
provisioning = transition(failed, InstanceState.PROVISIONING)
|
||||
|
||||
await cur.execute(
|
||||
"update instances set state = %s, error = %s, updated_at = now() where id = %s",
|
||||
(provisioning.value, reason[-2000:], instance_id),
|
||||
)
|
||||
return await _insert_task(cur, instance_id, TaskKind.PROVISION)
|
||||
|
||||
# --- TTL ----------------------------------------------------------------------------
|
||||
|
||||
async def due_for_deprovision(self) -> list[Instance]:
|
||||
"""Instances that should be torn down and have no deprovision task outstanding.
|
||||
|
||||
Two populations, one query:
|
||||
|
||||
* `ready` and past `expires_at` — the TTL sweep proper. The whole reason a
|
||||
throwaway Elasticsearch does not become a permanent line on the cloud bill.
|
||||
* `deleting` with nothing to do the deleting — the API CASes to `deleting` and then
|
||||
enqueues in a second statement, and a crash between the two leaves exactly this.
|
||||
That ordering is chosen *because* this sweep exists; the other order would leave
|
||||
a deprovision task pointing at a `ready` instance, and a worker would tear down a
|
||||
live service nobody asked to delete.
|
||||
|
||||
Note the parentheses around the OR. Without them, `and not exists (...)` binds to
|
||||
the second branch alone and the query re-enqueues a deprovision for every deleting
|
||||
instance on every tick, forever.
|
||||
"""
|
||||
async with self._pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
f"""select {_COLUMNS} from instances i
|
||||
where ((i.state = %(ready)s and i.expires_at < now()) or i.state = %(deleting)s)
|
||||
and not exists (
|
||||
select 1 from tasks t
|
||||
where t.instance_id = i.id
|
||||
and t.kind = %(kind)s
|
||||
and t.state = any(%(unfinished)s))
|
||||
order by i.created_at""", # noqa: S608 - module constant
|
||||
{
|
||||
"ready": InstanceState.READY.value,
|
||||
"deleting": InstanceState.DELETING.value,
|
||||
"kind": TaskKind.DEPROVISION.value,
|
||||
"unfinished": list(_UNFINISHED),
|
||||
},
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
return [Instance.model_validate(r) for r in rows]
|
||||
|
||||
async def enqueue_deprovision(self, instance_id: UUID) -> int | None:
|
||||
"""CAS to `deleting` if needed, and enqueue the task. One transaction. None if moot.
|
||||
|
||||
The instance must be in `deleting` before the worker claims the task, for the same
|
||||
reason as `enqueue_reprovision`: `handle_deprovision` finishes with a
|
||||
`deleting -> deleted` CAS, and a `ready` row would make helm uninstall the release
|
||||
and the DB keep advertising an endpoint that no longer resolves.
|
||||
"""
|
||||
async with self._pool.connection() as conn:
|
||||
async with conn.transaction(), conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""select state from instances
|
||||
where id = %(id)s
|
||||
and ((state = %(ready)s and expires_at < now()) or state = %(deleting)s)
|
||||
for update""",
|
||||
{
|
||||
"id": instance_id,
|
||||
"ready": InstanceState.READY.value,
|
||||
"deleting": InstanceState.DELETING.value,
|
||||
},
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
if await _has_unfinished(cur, instance_id, TaskKind.DEPROVISION):
|
||||
return None
|
||||
|
||||
if row["state"] == InstanceState.READY.value:
|
||||
deleting = transition(InstanceState.READY, InstanceState.DELETING)
|
||||
await cur.execute(
|
||||
"update instances set state = %s, updated_at = now() where id = %s",
|
||||
(deleting.value, instance_id),
|
||||
)
|
||||
return await _insert_task(cur, instance_id, TaskKind.DEPROVISION)
|
||||
|
||||
# --- Version drift ------------------------------------------------------------------
|
||||
|
||||
async def enqueue_upgrade(self, instance_id: UUID, run_after: datetime) -> int | None:
|
||||
"""Enqueue an upgrade unless one is already outstanding. None if it is.
|
||||
|
||||
The guard is what keeps the fleet at `max_in_flight`. The work list is a query over
|
||||
`chart_version`, and that column is only written *after* helm reports success — so
|
||||
an instance stays on the work list for the entire duration of its own upgrade, and
|
||||
for the hours it spends parked waiting for its 03:00 window. Without this check the
|
||||
sweep enqueues one more upgrade for the same instance every 60 seconds, and
|
||||
`max_in_flight=1` becomes sixty tasks an hour against one release.
|
||||
|
||||
`verify` counts as outstanding too: an upgrade whose verify has not reported is an
|
||||
upgrade still in progress, and re-enqueueing it would race the probe that decides
|
||||
whether the whole rollout halts.
|
||||
"""
|
||||
async with self._pool.connection() as conn:
|
||||
async with conn.transaction(), conn.cursor() as cur:
|
||||
if await _has_unfinished(cur, instance_id, TaskKind.UPGRADE, TaskKind.VERIFY):
|
||||
return None
|
||||
return await _insert_task(cur, instance_id, TaskKind.UPGRADE, run_after)
|
||||
|
||||
|
||||
async def _has_unfinished(
|
||||
cur: AsyncCursor[dict[str, Any]],
|
||||
instance_id: UUID,
|
||||
*kinds: TaskKind,
|
||||
) -> bool:
|
||||
"""Is a task of any of these kinds queued or running for this instance?
|
||||
|
||||
Takes the caller's cursor on purpose: the answer is only true for as long as the
|
||||
transaction that asked, and checking on a separate connection would be a check against
|
||||
a different snapshot than the insert that follows it.
|
||||
"""
|
||||
await cur.execute(
|
||||
"""select 1 from tasks
|
||||
where instance_id = %(id)s and kind = any(%(kinds)s) and state = any(%(unfinished)s)
|
||||
limit 1""",
|
||||
{
|
||||
"id": instance_id,
|
||||
"kinds": [k.value for k in kinds],
|
||||
"unfinished": list(_UNFINISHED),
|
||||
},
|
||||
)
|
||||
return await cur.fetchone() is not None
|
||||
|
||||
|
||||
async def _insert_task(
|
||||
cur: AsyncCursor[dict[str, Any]],
|
||||
instance_id: UUID,
|
||||
kind: TaskKind,
|
||||
run_after: datetime | None = None,
|
||||
) -> int:
|
||||
"""Insert one task in the caller's transaction, carrying the current trace context.
|
||||
|
||||
`traceparent` is written here rather than left to `TaskRepo.enqueue` because these rows
|
||||
are inserted inside a transaction the reconciler owns. Nothing propagates a trace
|
||||
through a table on its own — see `obs.inject_traceparent`. It is null when the sweep is
|
||||
not itself inside a span, which is fine and expected: a nullable column for an untraced
|
||||
task.
|
||||
"""
|
||||
await cur.execute(
|
||||
"""insert into tasks (instance_id, kind, run_after, traceparent)
|
||||
values (%s, %s, coalesce(%s, now()), %s)
|
||||
returning id""",
|
||||
(instance_id, kind.value, run_after, inject_traceparent()),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
assert row is not None # noqa: S101 - `returning` always yields a row or raises
|
||||
return int(row["id"])
|
||||
@@ -0,0 +1,219 @@
|
||||
"""The queue.
|
||||
|
||||
The queue is a Postgres table, not Redis. The reason is one sentence: a task and the
|
||||
instance state it describes must commit atomically. Split them across two stores and you
|
||||
own a distributed commit problem that has no winning move — the process can die between
|
||||
the two writes, and whichever you write first is the one that lies.
|
||||
|
||||
Everything else here follows from that.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Final
|
||||
from uuid import UUID
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
|
||||
from svcforge_core.domain.backoff import next_attempt_at
|
||||
from svcforge_core.domain.models import Task, TaskKind
|
||||
from svcforge_core.domain.states import LEGAL, InstanceState
|
||||
from svcforge_core.obs import inject_traceparent
|
||||
from svcforge_core.repo.db import DictPool
|
||||
|
||||
# Which states may legally become `failed`, derived from the domain's own table rather
|
||||
# than restated here. Without this guard the UPDATE below would happily move a `deleted`
|
||||
# instance to `failed` — a transition domain.transition() explicitly forbids, performed
|
||||
# by raw SQL that never asks it. The state machine has to be the same one everywhere, or
|
||||
# it is decoration.
|
||||
_CAN_FAIL: Final[tuple[str, ...]] = tuple(
|
||||
state.value for state, allowed in LEGAL.items() if InstanceState.FAILED in allowed
|
||||
)
|
||||
|
||||
# The claim query. Do not "simplify" this into two statements.
|
||||
#
|
||||
# Postgres has no `UPDATE ... LIMIT`, so the row is chosen by a subquery. That subquery
|
||||
# takes a row lock (`for update`) and steps over rows other workers already hold
|
||||
# (`skip locked`) instead of blocking behind them — which is what makes N workers scale
|
||||
# instead of queueing single-file behind the oldest task.
|
||||
#
|
||||
# The whole thing is ONE statement on purpose. Select-then-update as two statements
|
||||
# leaves a gap in which a second worker reads the same id, and both provision. The gap
|
||||
# is small, which means you will not hit it in testing and will hit it in production.
|
||||
#
|
||||
# The `with claimed as (...)` wrapper is the ONLY addition to the canonical form, and it
|
||||
# changes nothing about the locking: the UPDATE and its `for update skip locked` subquery
|
||||
# are still one statement, executed once. The outer SELECT only joins `instances.team`
|
||||
# onto the row that was already claimed, so the worker can bind `team` to its log context
|
||||
# before it has loaded anything. A data-modifying CTE runs exactly once and cannot claim
|
||||
# twice. The alternative — a second SELECT for the team — would be a second round trip per
|
||||
# task to fetch a column the database already had in hand.
|
||||
_CLAIM_SQL = """
|
||||
with claimed as (
|
||||
update tasks set state='running', attempts=attempts+1, locked_by=%(worker)s, locked_at=now()
|
||||
where id = (
|
||||
select id from tasks
|
||||
where state='queued' and run_after <= now()
|
||||
order by run_after
|
||||
for update skip locked
|
||||
limit 1
|
||||
)
|
||||
returning *
|
||||
)
|
||||
select claimed.*, instances.team
|
||||
from claimed join instances on instances.id = claimed.instance_id;
|
||||
"""
|
||||
|
||||
|
||||
class TaskRepo:
|
||||
"""Reads and writes `tasks`. Claiming is the only interesting part."""
|
||||
|
||||
def __init__(self, pool: DictPool) -> None:
|
||||
self._pool = pool
|
||||
|
||||
async def enqueue(
|
||||
self,
|
||||
conn: AsyncConnection[dict[str, Any]],
|
||||
instance_id: UUID,
|
||||
kind: TaskKind,
|
||||
run_after: datetime | None = None,
|
||||
) -> Task:
|
||||
"""Insert a task inside the CALLER's transaction.
|
||||
|
||||
Takes `conn` so the API can insert the instance and enqueue its provision task in
|
||||
one transaction. Rolling back must lose both, or you get an orphan task pointing
|
||||
at an instance that was never committed.
|
||||
|
||||
The `traceparent` is captured here, at enqueue time, because this is the last
|
||||
moment the caller's span context still exists. Trace context does NOT survive a
|
||||
queue on its own: the worker picks the row up in a different process, minutes
|
||||
later, with no ambient context. Writing the W3C traceparent onto the row is the
|
||||
thread that lets the worker re-parent its span to the POST that caused it — the
|
||||
difference between one trace spanning API → queue → helm and two unrelated ones.
|
||||
"""
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""insert into tasks (instance_id, kind, run_after, traceparent)
|
||||
values (%s, %s, coalesce(%s, now()), %s)
|
||||
returning id, instance_id, kind, state, attempts, run_after, locked_by, last_error""",
|
||||
(instance_id, kind.value, run_after, inject_traceparent()),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
assert row is not None # noqa: S101 - `returning` always yields a row or raises
|
||||
return Task.model_validate(row)
|
||||
|
||||
async def enqueue_standalone(
|
||||
self,
|
||||
instance_id: UUID,
|
||||
kind: TaskKind,
|
||||
run_after: datetime | None = None,
|
||||
) -> int:
|
||||
"""Enqueue in its own transaction, returning the new task id.
|
||||
|
||||
For callers with nothing to commit alongside it — the reconciler, tests. The
|
||||
module specs disagree about enqueue's shape (Module 2 passes a conn, Module 4
|
||||
does not); rather than making `conn` optional and quietly hiding the transaction
|
||||
question, both callers get an honest method name.
|
||||
"""
|
||||
async with self._pool.connection() as conn:
|
||||
task = await self.enqueue(conn, instance_id, kind, run_after)
|
||||
return task.id
|
||||
|
||||
async def claim(self, worker_id: str) -> Task | None:
|
||||
"""Claim one runnable task, or None if nothing is runnable.
|
||||
|
||||
`attempts` increments HERE, at claim time, not on failure. A worker that dies
|
||||
mid-task without reporting anything has still burned an attempt, so a task that
|
||||
reliably kills its worker cannot retry forever.
|
||||
"""
|
||||
async with self._pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(_CLAIM_SQL, {"worker": worker_id})
|
||||
row = await cur.fetchone()
|
||||
return Task.model_validate(row) if row else None
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
task_id: int,
|
||||
conn: AsyncConnection[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
"""Mark done. Pass `conn` to commit alongside the caller's instance update.
|
||||
|
||||
Completing the task and recording what it accomplished belong in one transaction:
|
||||
commit them separately and a crash in between leaves a task marked done whose
|
||||
work never landed, or work that landed and will be redone.
|
||||
"""
|
||||
sql = "update tasks set state='done', locked_by=null where id = %s"
|
||||
if conn is not None:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(sql, (task_id,))
|
||||
return
|
||||
async with self._pool.connection() as own, own.cursor() as cur:
|
||||
await cur.execute(sql, (task_id,))
|
||||
|
||||
async def fail(self, task_id: int, err: str, max_attempts: int = 5) -> None:
|
||||
"""Retry with backoff, or give up.
|
||||
|
||||
Under max_attempts: back to 'queued' with run_after pushed out by exponential
|
||||
backoff with full jitter. Jitter matters — a cluster-wide outage fails every task
|
||||
at once, and without it every worker retries in the same instant, forever.
|
||||
|
||||
At max_attempts: 'failed', and the error is copied onto the instance so the tenant
|
||||
can see it. A dead-letter state, not an infinite retry: a task that cannot succeed
|
||||
must stop and become someone's problem.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
async with self._pool.connection() as conn:
|
||||
async with conn.transaction(), conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"select attempts, instance_id from tasks where id = %s for update",
|
||||
(task_id,),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
if row is None:
|
||||
return
|
||||
attempts = int(row["attempts"])
|
||||
instance_id = row["instance_id"]
|
||||
|
||||
if attempts < max_attempts:
|
||||
await cur.execute(
|
||||
"""update tasks
|
||||
set state='queued', locked_by=null, locked_at=null,
|
||||
last_error=%s, run_after=%s
|
||||
where id = %s""",
|
||||
(err[-2000:], next_attempt_at(attempts - 1, now=now), task_id),
|
||||
)
|
||||
return
|
||||
|
||||
await cur.execute(
|
||||
"""update tasks
|
||||
set state='failed', locked_by=null, locked_at=null, last_error=%s
|
||||
where id = %s""",
|
||||
(err[-2000:], task_id),
|
||||
)
|
||||
# `state = any(%s)` keeps this honest: a deprovision that exhausts its
|
||||
# retries against an already-deleted instance records nothing rather than
|
||||
# resurrecting it into `failed`.
|
||||
await cur.execute(
|
||||
"""update instances set error=%s, state=%s, updated_at=now()
|
||||
where id=%s and state = any(%s)""",
|
||||
(err[-2000:], InstanceState.FAILED.value, instance_id, list(_CAN_FAIL)),
|
||||
)
|
||||
|
||||
async def reset_expired_leases(self, lease_seconds: int) -> int:
|
||||
"""Return tasks whose worker died back to the queue. Called by the reconciler.
|
||||
|
||||
No distributed lock survives a power cut. A worker that is SIGKILLed leaves
|
||||
`state='running'` and `locked_by` set with nobody running it, and that row would
|
||||
sit there forever. The lease is the only thing that recovers it: not a lock, a
|
||||
timeout. This is why `locked_at` exists.
|
||||
"""
|
||||
async with self._pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""update tasks
|
||||
set state='queued', locked_by=null, locked_at=null
|
||||
where state='running'
|
||||
and locked_at < now() - make_interval(secs => %s)""",
|
||||
(lease_seconds,),
|
||||
)
|
||||
return cur.rowcount
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Typed configuration. Environment in, validated object out, fails fast at startup.
|
||||
|
||||
The whole point: a missing or malformed DSN kills the process on line one with a readable
|
||||
error, instead of surfacing as a PoolTimeout twenty minutes into a provision.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field, PostgresDsn, RedisDsn
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Every knob svcforge has. Read once, at startup, and passed down explicitly."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="SVCFORGE_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
frozen=True,
|
||||
)
|
||||
|
||||
# --- Postgres -----------------------------------------------------------------
|
||||
# Transaction pooler (6543 on Supabase). Everything the services do at runtime.
|
||||
pg_dsn: PostgresDsn
|
||||
# Session pooler (5432). Migrations and psql only: DDL and advisory locks need a
|
||||
# session that outlives a single transaction.
|
||||
pg_dsn_session: PostgresDsn | None = None
|
||||
|
||||
pool_min_size: int = Field(default=1, ge=0)
|
||||
pool_max_size: int = Field(default=5, ge=1)
|
||||
|
||||
# --- Redis (derived state only; never the source of truth) ---------------------
|
||||
redis_dsn: RedisDsn | None = None
|
||||
|
||||
# --- API ----------------------------------------------------------------------
|
||||
jwks_url: str | None = None
|
||||
jwt_audience: str = "svcforge"
|
||||
jwt_issuer: str | None = None
|
||||
# Dev escape hatch: skip JWT verification. Refused in prod by check_production().
|
||||
auth_disabled: bool = False
|
||||
|
||||
# --- Worker -------------------------------------------------------------------
|
||||
worker_id: str = Field(default="worker-local", min_length=1)
|
||||
worker_concurrency: int = Field(default=4, ge=1)
|
||||
poll_interval_s: float = Field(default=5.0, gt=0)
|
||||
max_attempts: int = Field(default=5, ge=1)
|
||||
lease_seconds: int = Field(default=300, ge=1)
|
||||
|
||||
# --- Reconciler ---------------------------------------------------------------
|
||||
reconcile_interval_s: float = Field(default=60.0, gt=0)
|
||||
|
||||
# --- Catalog / helm -----------------------------------------------------------
|
||||
catalog_path: Path = Path("catalog.yaml")
|
||||
helm_bin: str = "helm"
|
||||
kubectl_bin: str = "kubectl"
|
||||
helm_timeout_s: float = Field(default=300.0, gt=0)
|
||||
|
||||
# --- CLI ----------------------------------------------------------------------
|
||||
# The CLI is an API client and nothing more. It gets a URL and a token; it does not
|
||||
# get a DSN, because the moment a human can reach the database directly, someone will
|
||||
# "just fix one row" and the state machine stops being true.
|
||||
api_url: str = "http://localhost:8000"
|
||||
api_token: str | None = None
|
||||
|
||||
# --- Observability ------------------------------------------------------------
|
||||
log_level: str = "info"
|
||||
log_json: bool = True
|
||||
otel_endpoint: str | None = None
|
||||
service_name: str = "svcforge"
|
||||
# The worker and reconciler have no HTTP server of their own, so they start a tiny one
|
||||
# just for /metrics. 9000 matches the chart's PodMonitor; change both or neither.
|
||||
metrics_port: int = Field(default=9000, ge=1, le=65535)
|
||||
|
||||
@property
|
||||
def migration_dsn(self) -> str:
|
||||
"""Migrations need a session-mode connection; fall back to the runtime DSN locally."""
|
||||
return str(self.pg_dsn_session or self.pg_dsn)
|
||||
|
||||
def check_production(self) -> None:
|
||||
"""Refuse the dev escape hatches when they would matter."""
|
||||
if self.auth_disabled:
|
||||
raise ValueError("SVCFORGE_AUTH_DISABLED=true is refused outside local development")
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
"""Read the environment. Raises ValidationError — loudly — if anything is missing."""
|
||||
return Settings() # type: ignore[call-arg] # pydantic-settings fills these from env
|
||||
@@ -0,0 +1,36 @@
|
||||
-- 001_init.sql — instances and tasks.
|
||||
--
|
||||
-- Forward-only. There is no down script. If this is wrong, 002 fixes it.
|
||||
|
||||
create table instances (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
team text not null,
|
||||
service_type text not null, -- 'elasticsearch' | 'redis' | ...
|
||||
size text not null, -- 'small' | 'medium'
|
||||
state text not null,
|
||||
namespace text not null,
|
||||
release_name text not null unique, -- helm release; the idempotency anchor
|
||||
chart_version text not null, -- what's ACTUALLY deployed. day-2 hinges on this column.
|
||||
endpoint text,
|
||||
error text,
|
||||
expires_at timestamptz,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table tasks (
|
||||
id bigserial primary key,
|
||||
instance_id uuid not null references instances(id) on delete cascade,
|
||||
kind text not null, -- 'provision'|'deprovision'|'upgrade'|'verify'
|
||||
state text not null default 'queued', -- queued|running|done|failed
|
||||
attempts int not null default 0,
|
||||
run_after timestamptz not null default now(), -- backoff lands here
|
||||
locked_by text,
|
||||
locked_at timestamptz,
|
||||
last_error text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- Partial index: the claim query only ever looks at queued rows. Keeping the index
|
||||
-- to that subset means it stays small no matter how much history `tasks` accumulates.
|
||||
create index tasks_runnable on tasks (run_after) where state = 'queued';
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 003_day2.sql — the whole of day 2: two columns and one query.
|
||||
--
|
||||
-- Forward-only, expand/contract. Both additions are nullable / defaulted, so the old
|
||||
-- code keeps running against the new schema between the migrate and the deploy. That
|
||||
-- ordering is not optional: migrate first, deploy second, and a column the running
|
||||
-- code has never heard of must not break it.
|
||||
|
||||
alter table instances add column maintenance_window text; -- '0 3 * * 0|Asia/Ho_Chi_Minh', null = any time
|
||||
|
||||
-- One row per service type, one column that matters. `halted` is what stops a bad
|
||||
-- chart after the first tenant instead of after all of them, and it is cleared by
|
||||
-- hand with SQL — an automatic un-halt would just resume breaking things.
|
||||
create table catalog_versions (
|
||||
service_type text primary key,
|
||||
rollout_state text not null default 'ok' -- 'ok' | 'halted'
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 004_traceparent.sql — carry the trace across the queue.
|
||||
--
|
||||
-- A trace is a chain of span contexts. `POST /v1/instances` commits a row and returns;
|
||||
-- a worker in another pod claims that row minutes later. Nothing propagates the context
|
||||
-- across that gap, because the gap is a table. So the context rides in the table: the API
|
||||
-- writes the W3C traceparent it is currently inside, and the worker extracts it at claim
|
||||
-- and makes its span a child of the API's. Skip this and Tempo shows two unrelated traces
|
||||
-- for one provision, which is worse than no tracing — it looks like it works.
|
||||
--
|
||||
-- Nullable, no default, no backfill: expand/contract done right. Old rows have no
|
||||
-- traceparent and never will; a worker running the previous image ignores a column it has
|
||||
-- never heard of. Migrate first, deploy second, and neither step needs the other to have
|
||||
-- happened.
|
||||
|
||||
alter table tasks add column traceparent text; -- '00-<32 hex>-<16 hex>-01', null = untraced
|
||||
@@ -0,0 +1,98 @@
|
||||
[project]
|
||||
name = "svcforge"
|
||||
version = "0.1.0"
|
||||
description = "X-as-a-Service control plane — reference implementation"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"svcforge-core",
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.32",
|
||||
"psycopg[binary,pool]>=3.2",
|
||||
"pyjwt[crypto]>=2.9",
|
||||
"httpx>=0.27",
|
||||
"croniter>=3.0",
|
||||
"tzdata>=2024.2",
|
||||
"structlog>=24.4",
|
||||
"prometheus-client>=0.21",
|
||||
"redis>=5.2",
|
||||
"typer>=0.15",
|
||||
"opentelemetry-api>=1.28",
|
||||
"opentelemetry-sdk>=1.28",
|
||||
"opentelemetry-instrumentation-fastapi>=0.49b0",
|
||||
"opentelemetry-instrumentation-psycopg>=0.49b0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.3",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pytest-cov>=6.0",
|
||||
"mypy>=1.13",
|
||||
"ruff>=0.8",
|
||||
"hypothesis>=6.122",
|
||||
"pre-commit>=4.0",
|
||||
"testcontainers[postgres]>=4.9",
|
||||
"types-pyyaml>=6.0.12.20260518",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
svcforge = "services.cli.main:app"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["services"]
|
||||
|
||||
[tool.uv.sources]
|
||||
# editable here is a DEVELOPMENT convenience: source edits are visible without a
|
||||
# reinstall. The Dockerfiles deliberately override it with `uv sync --no-editable`,
|
||||
# because an editable install in an image resolves imports to /app/libs and ships a
|
||||
# path, not a package.
|
||||
svcforge-core = { path = "libs/svcforge_core", editable = true }
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 110
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "ANN", "S", "C4", "RUF"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
# svcforge_core lives under libs/, so isort cannot infer it is ours.
|
||||
known-first-party = ["svcforge_core", "services"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# Tests may assert, and fixtures shadow names by design.
|
||||
"tests/**" = ["S101"]
|
||||
# The e2e tests drive the real `kubectl`/`helm`/`pgrep` off PATH — that is the whole point
|
||||
# of them, and pinning absolute paths would make them pass on one machine only. Scoped to
|
||||
# this directory so S603/S607 keep guarding the application code, where `team` is tenant
|
||||
# input that reaches a helm release name.
|
||||
"tests/e2e/**" = ["S101", "S603", "S607"]
|
||||
|
||||
[tool.mypy]
|
||||
strict = true
|
||||
python_version = "3.12"
|
||||
warn_unreachable = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["testcontainers.*", "croniter.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
# The OTLP exporter is an optional runtime dependency: in the cluster the API runs under
|
||||
# `opentelemetry-instrument`, which brings its own. obs.py imports it inside a try/except
|
||||
# and degrades to in-process traces without it, so mypy must not require it to be installed.
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["opentelemetry.exporter.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q --strict-markers"
|
||||
asyncio_mode = "auto"
|
||||
markers = [
|
||||
"slow: >1s",
|
||||
"e2e: needs a cluster and real helm",
|
||||
]
|
||||
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# CI's last act.
|
||||
#
|
||||
# Resolves the digest each service's commit-SHA tag points at, writes those digests into
|
||||
# deploy/chart/values.yaml, and commits. That commit is the deploy: ArgoCD is watching
|
||||
# master and picks it up. This script does not, and must not, talk to the cluster.
|
||||
#
|
||||
# Called by .gitea/workflows/ci.yaml on master only. Runnable by hand for a re-bump:
|
||||
# REGISTRY=gitea.oci-oci.duckdns.org IMAGE_NS=gitea_admin IMAGE_TAG=<sha> ./scripts/bump-digests.sh
|
||||
#
|
||||
# -e a failed inspect must not lead to committing a stale digest
|
||||
# -u an unset REGISTRY would silently resolve the wrong image
|
||||
# -o pipefail the digest comes out of a pipe; without this, a failing inspect that pipes
|
||||
# into a successful grep exits 0 and writes garbage
|
||||
set -euo pipefail
|
||||
|
||||
: "${REGISTRY:?REGISTRY must be set}"
|
||||
: "${IMAGE_NS:?IMAGE_NS must be set}"
|
||||
: "${IMAGE_TAG:?IMAGE_TAG must be set (the commit sha the images were built from)}"
|
||||
|
||||
SERVICES=(api worker reconciler)
|
||||
CHART_VALUES="deploy/chart/values.yaml"
|
||||
|
||||
# yq, pinned by digest. Not python+pyyaml: a yaml round-trip strips every comment in
|
||||
# values.yaml, and those comments are the only thing explaining why the digests are there.
|
||||
# yq edits in place and leaves the rest of the file alone.
|
||||
YQ_IMAGE="mikefarah/yq:4.44.6@sha256:b1d117c609ba990436ad1649299e2f6c378f62cb562caf30b6f2fb6144713422"
|
||||
|
||||
WORKDIR="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "${WORKDIR}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
yq() {
|
||||
docker run --rm -v "${PWD}:/work" -w /work -u "$(id -u):$(id -g)" "${YQ_IMAGE}" "$@"
|
||||
}
|
||||
|
||||
echo "==> resolving digests for tag ${IMAGE_TAG}"
|
||||
for svc in "${SERVICES[@]}"; do
|
||||
image="${REGISTRY}/${IMAGE_NS}/svcforge-${svc}"
|
||||
digest="$(docker buildx imagetools inspect "${image}:${IMAGE_TAG}" --format '{{.Manifest.Digest}}')"
|
||||
|
||||
# Defence against a silently empty inspect. Without this, `yq` would happily write an
|
||||
# empty digest and the chart's own guard would fail the release later, further from
|
||||
# the cause.
|
||||
if [[ ! "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then
|
||||
echo "!! ${svc}: refusing to write a non-digest: '${digest}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " ${svc} -> ${digest}"
|
||||
echo "${digest}" > "${WORKDIR}/${svc}.digest"
|
||||
done
|
||||
|
||||
echo "==> writing ${CHART_VALUES}"
|
||||
for svc in "${SERVICES[@]}"; do
|
||||
digest="$(cat "${WORKDIR}/${svc}.digest")"
|
||||
# env(...) rather than string interpolation: a digest is attacker-controlled only in
|
||||
# theory, but yq expression injection is not a thing worth leaving open.
|
||||
DIGEST="${digest}" yq -i ".image.${svc}.digest = strenv(DIGEST)" "${CHART_VALUES}"
|
||||
done
|
||||
|
||||
if git diff --quiet -- "${CHART_VALUES}"; then
|
||||
echo "==> no digest changed; nothing to commit"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "==> committing"
|
||||
git config user.name "svcforge-ci"
|
||||
git config user.email "ci@svcforge.invalid"
|
||||
git add "${CHART_VALUES}"
|
||||
git commit -m "ci: bump image digests to ${IMAGE_TAG}
|
||||
|
||||
Built and scanned by ${IMAGE_TAG}. ArgoCD syncs from this commit.
|
||||
|
||||
[skip ci]"
|
||||
git push origin HEAD:master
|
||||
|
||||
echo "==> done. ArgoCD owns it from here."
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
"""Throwaway load generator. Enqueue N instances, watch the queue drain, print three numbers.
|
||||
|
||||
The point is not a benchmark. It is to find the ceiling on purpose, in a place where finding
|
||||
it is free, so that the number in RUNBOOK.md comes from an observation instead of a guess.
|
||||
|
||||
The ceiling you are looking for is arithmetic, not mysterious:
|
||||
|
||||
total connections = (api_replicas + worker_replicas) x pool_max_size
|
||||
|
||||
Supabase's free-tier pooler has a small connection budget. Cross it and the failure does not
|
||||
look like "too many connections" — it looks like slow claims, then PoolTimeout, then a queue
|
||||
that grows while every worker looks idle. Once you have watched it once, you recognise it in
|
||||
two seconds instead of an hour.
|
||||
|
||||
Usage:
|
||||
python -m scripts.load --count 200 --watch
|
||||
python -m scripts.load --count 200 --direct # skip the API, enqueue straight to the DB
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from svcforge_core.settings import load_settings
|
||||
|
||||
|
||||
async def _seed_direct(dsn: str, count: int) -> float:
|
||||
"""Insert `count` instances + provision tasks. Returns seconds taken.
|
||||
|
||||
--direct exists to separate two questions that a single POST run conflates: "how fast can
|
||||
the API accept work" and "how fast can workers drain it". Measure them apart or you will
|
||||
tune the wrong one.
|
||||
"""
|
||||
started = time.monotonic()
|
||||
async with await psycopg.AsyncConnection.connect(dsn, row_factory=dict_row) as conn:
|
||||
async with conn.transaction(), conn.cursor() as cur:
|
||||
for _ in range(count):
|
||||
iid = uuid4()
|
||||
await cur.execute(
|
||||
"""insert into instances (id, team, service_type, size, state, namespace,
|
||||
release_name, chart_version)
|
||||
values (%s, 'loadtest', 'elasticsearch', 'small', 'requested',
|
||||
'tenant-loadtest', %s, '21.3.19')""",
|
||||
(iid, f"loadtest-elasticsearch-{str(iid)[:8]}"),
|
||||
)
|
||||
await cur.execute(
|
||||
"insert into tasks (instance_id, kind) values (%s, 'provision')",
|
||||
(iid,),
|
||||
)
|
||||
return time.monotonic() - started
|
||||
|
||||
|
||||
async def _depth(dsn: str) -> dict[str, int]:
|
||||
async with await psycopg.AsyncConnection.connect(dsn, row_factory=dict_row) as conn:
|
||||
cur = await conn.execute("select state, count(*) as n from tasks group by 1")
|
||||
return {str(r["state"]): int(r["n"]) for r in await cur.fetchall()}
|
||||
|
||||
|
||||
async def _watch(dsn: str, timeout_s: float) -> None:
|
||||
"""Print queue depth once a second until it drains. The slope is the number you want."""
|
||||
started = time.monotonic()
|
||||
peak = 0
|
||||
print(f"{'t(s)':>6} {'queued':>7} {'running':>8} {'done':>6} {'failed':>7} slope/s")
|
||||
prev_done, prev_t = 0, started
|
||||
|
||||
while time.monotonic() - started < timeout_s:
|
||||
d = await _depth(dsn)
|
||||
queued, running = d.get("queued", 0), d.get("running", 0)
|
||||
done, failed = d.get("done", 0), d.get("failed", 0)
|
||||
peak = max(peak, queued + running)
|
||||
|
||||
now = time.monotonic()
|
||||
slope = (done - prev_done) / max(now - prev_t, 1e-9)
|
||||
prev_done, prev_t = done, now
|
||||
|
||||
print(f"{now - started:6.1f} {queued:7d} {running:8d} {done:6d} {failed:7d} {slope:7.1f}")
|
||||
|
||||
if queued == 0 and running == 0:
|
||||
elapsed = now - started
|
||||
print(f"\ndrained in {elapsed:.1f}s peak depth {peak} throughput {done / elapsed:.1f} task/s")
|
||||
if failed:
|
||||
print(f"WARNING: {failed} tasks failed — the number above is not a clean drain")
|
||||
return
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
print(f"\nstill draining after {timeout_s}s — that IS the result. Record it.")
|
||||
|
||||
|
||||
async def _amain() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--count", type=int, default=200)
|
||||
ap.add_argument("--direct", action="store_true", help="enqueue via SQL instead of the API")
|
||||
ap.add_argument("--watch", action="store_true", help="poll queue depth until drained")
|
||||
ap.add_argument("--timeout", type=float, default=600.0)
|
||||
ap.add_argument("--cleanup", action="store_true", help="delete loadtest rows and exit")
|
||||
args = ap.parse_args()
|
||||
|
||||
settings = load_settings()
|
||||
dsn = settings.pg_dsn.unicode_string()
|
||||
|
||||
if args.cleanup:
|
||||
async with await psycopg.AsyncConnection.connect(dsn, autocommit=True) as conn:
|
||||
await conn.execute("delete from instances where team = 'loadtest'") # tasks cascade
|
||||
print("loadtest rows deleted")
|
||||
return
|
||||
|
||||
if not args.direct:
|
||||
raise SystemExit(
|
||||
"POST mode needs a token; use --direct for the drain measurement, or drive the API "
|
||||
"with k6 (one dependency, not two — do not add locust for this)."
|
||||
)
|
||||
|
||||
print(f"seeding {args.count} instances at {datetime.now(UTC).isoformat()} ...")
|
||||
took = await _seed_direct(dsn, args.count)
|
||||
print(f"enqueued {args.count} in {took:.2f}s ({args.count / took:.0f}/s)\n")
|
||||
|
||||
if args.watch:
|
||||
await _watch(dsn, args.timeout)
|
||||
print("\nremember: `python -m scripts.load --cleanup` when you are done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(_amain())
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Project month-end Redis command burn from the live counter. Exit 1 if it blows the budget.
|
||||
|
||||
$ python3 scripts/redis_budget.py
|
||||
$ python3 scripts/redis_budget.py --url http://localhost:8000/metrics --budget 500000
|
||||
|
||||
Upstash's free tier is 500,000 commands/month, which sounds enormous and is not:
|
||||
|
||||
500,000 / month = 16,129 / day = 11 / minute = 0.19 / second, sustained
|
||||
|
||||
0.19 commands per second is the entire engineering constraint. One worker polling Redis
|
||||
every five seconds spends 518,400/month — the whole budget, to learn nothing. That is why
|
||||
Redis is only ever on the request path here, and why this script exists: the rule is easy
|
||||
to state and invisible to violate. A `cache.get()` added inside the reconciler's
|
||||
per-instance loop is one line in review and 2,160,000 commands/month in production.
|
||||
|
||||
**Why a projection and not an alarm on the counter.** Exhausting the budget is a slow,
|
||||
silent failure with a cliff at the end: nothing degrades, nothing pages, every call
|
||||
succeeds, and then the month rolls over and every Redis call starts erroring at once. By
|
||||
then the fix is a bill or an outage. A burn rate extrapolated from the counter is visible
|
||||
on day two, which is the only time it is cheap to fix.
|
||||
|
||||
Stdlib only, on purpose — this is meant to run from CI, from a laptop, or from inside a
|
||||
pod that has nothing but python3, without an environment to activate first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# A 30-day month. Upstash bills on a calendar month; 30 days is the honest rounding and
|
||||
# errs slightly pessimistic on the long ones, which is the correct direction for a budget.
|
||||
_MONTH_S = 30 * 24 * 60 * 60
|
||||
_FREE_TIER_BUDGET = 500_000
|
||||
|
||||
_COMMANDS_METRIC = "svcforge_redis_commands_total"
|
||||
_START_TIME_METRIC = "process_start_time_seconds"
|
||||
|
||||
|
||||
class BudgetError(RuntimeError):
|
||||
"""The metrics endpoint did not give us enough to project from."""
|
||||
|
||||
|
||||
def scrape(url: str, timeout_s: float = 5.0) -> str:
|
||||
"""GET the Prometheus text exposition. http/https only."""
|
||||
if urlparse(url).scheme not in ("http", "https"):
|
||||
raise BudgetError(f"refusing to fetch a non-http(s) url: {url}")
|
||||
# S310 is satisfied by the scheme check above: this cannot open file:// or ftp://.
|
||||
with urllib.request.urlopen(url, timeout=timeout_s) as resp: # noqa: S310
|
||||
body: str = resp.read().decode("utf-8")
|
||||
return body
|
||||
|
||||
|
||||
def _parse_sample(line: str) -> tuple[str, dict[str, str], float] | None:
|
||||
"""One exposition line -> (name, labels, value). None for comments and blanks.
|
||||
|
||||
A deliberately small parser rather than prometheus_client's: importing the library
|
||||
would mean this script only runs where the app's venv is already active, which is
|
||||
exactly where you least need to check the budget.
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
return None
|
||||
head, _, raw_value = line.rpartition(" ")
|
||||
if not head:
|
||||
return None
|
||||
try:
|
||||
value = float(raw_value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
name, brace, rest = head.partition("{")
|
||||
labels: dict[str, str] = {}
|
||||
if brace:
|
||||
for pair in rest.rstrip("}").split(","):
|
||||
key, eq, val = pair.partition("=")
|
||||
if eq:
|
||||
labels[key.strip()] = val.strip().strip('"')
|
||||
return name.strip(), labels, value
|
||||
|
||||
|
||||
def collect(text: str) -> tuple[dict[str, float], float]:
|
||||
"""Extract per-op command totals and the process start time from a scrape."""
|
||||
per_op: dict[str, float] = {}
|
||||
started_at: float | None = None
|
||||
|
||||
for line in text.splitlines():
|
||||
parsed = _parse_sample(line)
|
||||
if parsed is None:
|
||||
continue
|
||||
name, labels, value = parsed
|
||||
# prometheus_client exposes counters with a `_total` suffix already; tolerate both
|
||||
# spellings so this keeps working if the client library changes its mind.
|
||||
if name in (_COMMANDS_METRIC, _COMMANDS_METRIC.removesuffix("_total")):
|
||||
per_op[labels.get("op", "unknown")] = value
|
||||
elif name == _START_TIME_METRIC:
|
||||
started_at = value
|
||||
|
||||
if not per_op:
|
||||
raise BudgetError(
|
||||
f"{_COMMANDS_METRIC} is not exposed. Either the API never imported "
|
||||
f"svcforge_core.adapters.redis, or you are scraping the wrong process."
|
||||
)
|
||||
if started_at is None:
|
||||
raise BudgetError(
|
||||
f"{_START_TIME_METRIC} is missing, so there is no window to project over. "
|
||||
f"It comes from prometheus_client's default collector."
|
||||
)
|
||||
return per_op, started_at
|
||||
|
||||
|
||||
def report(per_op: dict[str, float], started_at: float, budget: int, now: float) -> int:
|
||||
"""Print the projection. Returns the process exit code."""
|
||||
elapsed_s = max(1.0, now - started_at)
|
||||
total = sum(per_op.values())
|
||||
rate = total / elapsed_s
|
||||
projected = rate * _MONTH_S
|
||||
|
||||
print(f"window {elapsed_s / 3600:.2f} h since process start")
|
||||
print(f"commands {total:,.0f}")
|
||||
for op, value in sorted(per_op.items(), key=lambda kv: -kv[1]):
|
||||
share = (value / total * 100) if total else 0.0
|
||||
print(f" {op:<14}{value:>12,.0f} ({share:.1f}%)")
|
||||
print(f"rate {rate:.4f} /s (budget allows {budget / _MONTH_S:.4f} /s sustained)")
|
||||
print(f"projected {projected:,.0f} / month")
|
||||
print(f"budget {budget:,} / month")
|
||||
|
||||
if total < 100:
|
||||
# Extrapolating a month from a handful of commands is astrology. Say so rather than
|
||||
# printing a confident number derived from six samples.
|
||||
print("verdict INCONCLUSIVE — fewer than 100 commands; let it run longer")
|
||||
return 0
|
||||
if projected >= budget:
|
||||
headroom = projected / budget
|
||||
print(f"verdict OVER BUDGET — {headroom:.1f}x. Find the Redis call in a loop.")
|
||||
return 1
|
||||
share = projected / budget * 100
|
||||
print(f"verdict OK — {share:.1f}% of budget, {budget / projected:.1f}x headroom")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
parser.add_argument("--url", default="http://localhost:8000/metrics", help="Prometheus endpoint")
|
||||
parser.add_argument("--budget", type=int, default=_FREE_TIER_BUDGET, help="commands per month")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
per_op, started_at = collect(scrape(args.url))
|
||||
except BudgetError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
except OSError as exc:
|
||||
print(f"error: cannot scrape {args.url}: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
return report(per_op, started_at, args.budget, time.time())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
"""svcforge services: api, worker, reconciler."""
|
||||
@@ -0,0 +1,55 @@
|
||||
# syntax=docker/dockerfile:1.10
|
||||
#
|
||||
# svcforge api. Build from the REPO ROOT:
|
||||
# docker buildx build -f services/api/Dockerfile -t svcforge/api:dev .
|
||||
# `COPY ../..` is illegal, so the context must be the root. There is no other option.
|
||||
#
|
||||
# Two syncs, not one: deps change rarely and our own code changes every commit, so the
|
||||
# expensive layer (third-party wheels) must land before the cheap one (our source).
|
||||
|
||||
FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS builder
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.5.11@sha256:0ac957607303916420297a4c9c213bb33fbd3c888f9cd7f4f7273596ebf42b85 /uv /usr/local/bin/uv
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never
|
||||
WORKDIR /app
|
||||
|
||||
# --- layer 1: third-party dependencies only -------------------------------------------
|
||||
# --no-install-project skips the root; --no-install-package skips our path dependency.
|
||||
# Without the latter, uv would try to build svcforge-core here, where its source is not
|
||||
# yet in the context, and the build would fail.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY libs/svcforge_core/pyproject.toml libs/svcforge_core/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev --no-editable \
|
||||
--no-install-project --no-install-package svcforge-core
|
||||
|
||||
# --- layer 2: our code ----------------------------------------------------------------
|
||||
COPY libs/ libs/
|
||||
COPY services/api/ services/api/
|
||||
COPY catalog.yaml ./
|
||||
# --no-editable is what turns svcforge-core into a real wheel in site-packages.
|
||||
# pyproject.toml declares it `editable = true` for local dev; an editable install in an
|
||||
# image points at /app/libs, which is a source tree that need not survive the final stage.
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev --no-editable && \
|
||||
/app/.venv/bin/python -c 'import svcforge_core, sys; \
|
||||
p = svcforge_core.__file__; \
|
||||
sys.exit(0) if "site-packages" in p else sys.exit("not a wheel install: " + p)'
|
||||
|
||||
# --- runtime --------------------------------------------------------------------------
|
||||
FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de
|
||||
|
||||
ARG BUILD_SHA=unknown
|
||||
LABEL org.opencontainers.image.title="svcforge-api" \
|
||||
org.opencontainers.image.source="https://gitea.oci-oci.duckdns.org/gitea_admin/svcforge" \
|
||||
org.opencontainers.image.revision="${BUILD_SHA}"
|
||||
|
||||
RUN useradd -u 10001 -m -s /usr/sbin/nologin svcforge
|
||||
WORKDIR /app
|
||||
COPY --from=builder --chown=10001:10001 /app /app
|
||||
ENV PATH="/app/.venv/bin:$PATH" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
USER 10001
|
||||
ENTRYPOINT ["python", "-m", "services.api"]
|
||||
@@ -0,0 +1 @@
|
||||
"""The api service: HTTP transport over the domain."""
|
||||
@@ -0,0 +1,29 @@
|
||||
"""`python -m services.api` — what the container's ENTRYPOINT runs.
|
||||
|
||||
One uvicorn worker per container, on purpose. Replicas are Kubernetes' job: `--workers N`
|
||||
forks processes the orchestrator cannot see, size, or drain, and it breaks the in-process
|
||||
Prometheus registry that /metrics depends on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uvicorn
|
||||
|
||||
from svcforge_core.settings import load_settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Load settings (fail fast if the env is wrong), then serve."""
|
||||
settings = load_settings()
|
||||
uvicorn.run(
|
||||
"services.api.main:app",
|
||||
factory=True,
|
||||
host="0.0.0.0", # noqa: S104 - a container binds all interfaces; the pod is the boundary
|
||||
port=8000,
|
||||
log_level=settings.log_level,
|
||||
access_log=False, # structlog owns request logging (Module 7); two sources would double it
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Dependency injection: how a handler gets a pool, a repo, a catalog, and a team.
|
||||
|
||||
Everything expensive — the pool, the JWKS client, the parsed catalog — is built once in
|
||||
`lifespan` and parked on `app.state`. These functions only hand it out. A `Depends` that
|
||||
does I/O per request is a `Depends` that does that I/O on every request forever.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated, Any
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jwt import PyJWKClient
|
||||
|
||||
from svcforge_core.domain.models import CatalogEntry
|
||||
from svcforge_core.repo.db import DictPool
|
||||
from svcforge_core.repo.instances import InstanceRepo
|
||||
from svcforge_core.repo.tasks import TaskRepo
|
||||
from svcforge_core.settings import Settings
|
||||
|
||||
# The algorithm allow-list is the whole point of naming algorithms explicitly.
|
||||
# `jwt.decode(..., algorithms=...)` without it accepts whatever the *token* claims in its
|
||||
# own header — including `none`, and including HS256 verified with the RSA public key as
|
||||
# an HMAC secret. Both are forgery. The list is not configuration.
|
||||
ALLOWED_ALGORITHMS = ["RS256"]
|
||||
|
||||
# What `auth_disabled` returns. Settings.check_production() refuses that flag in prod.
|
||||
DEV_TEAM = "platform"
|
||||
|
||||
TEAM_CLAIM = "team"
|
||||
|
||||
# auto_error=False is load-bearing. HTTPBearer(auto_error=True) answers a *missing*
|
||||
# Authorization header with 403, not 401 — an old FastAPI wart. The spec (and every
|
||||
# client that knows what to do about it) wants 401, so the error is raised here.
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def _unauthorized() -> HTTPException:
|
||||
"""One shape for every auth failure.
|
||||
|
||||
Expired, wrong issuer, wrong audience, bad signature, malformed, no header: all the
|
||||
same 401 with the same body. Telling a caller *which* one turns the endpoint into an
|
||||
oracle they can tune a forgery against.
|
||||
"""
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"code": "unauthorized", "message": "invalid or missing credentials"},
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def get_settings(request: Request) -> Settings:
|
||||
"""The Settings that create_app() was handed."""
|
||||
settings: Settings = request.app.state.settings
|
||||
return settings
|
||||
|
||||
|
||||
async def get_pool(request: Request) -> DictPool:
|
||||
"""Return the pool that lifespan put on app.state."""
|
||||
pool: DictPool = request.app.state.pool
|
||||
return pool
|
||||
|
||||
|
||||
def get_catalog(request: Request) -> dict[str, CatalogEntry]:
|
||||
"""The catalog, parsed once at startup.
|
||||
|
||||
Read from disk per request and a mid-flight edit to catalog.yaml changes the answer
|
||||
between two requests of the same deploy. Load it at startup; a change is a restart.
|
||||
"""
|
||||
catalog: dict[str, CatalogEntry] = request.app.state.catalog
|
||||
return catalog
|
||||
|
||||
|
||||
def get_instance_repo(pool: Annotated[DictPool, Depends(get_pool)]) -> InstanceRepo:
|
||||
"""An InstanceRepo bound to the app's pool. Cheap: it is a handle, not a connection."""
|
||||
return InstanceRepo(pool)
|
||||
|
||||
|
||||
def get_task_repo(pool: Annotated[DictPool, Depends(get_pool)]) -> TaskRepo:
|
||||
"""A TaskRepo bound to the app's pool."""
|
||||
return TaskRepo(pool)
|
||||
|
||||
|
||||
async def get_current_team(
|
||||
request: Request,
|
||||
creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> str:
|
||||
"""Verify the JWT against the cached JWKS. Check aud/iss/exp and the alg allow-list.
|
||||
|
||||
Returns the team claim. Raises HTTPException(401) on any failure — never leaks why.
|
||||
"""
|
||||
if settings.auth_disabled:
|
||||
return DEV_TEAM
|
||||
|
||||
if creds is None or not creds.credentials:
|
||||
raise _unauthorized()
|
||||
|
||||
jwks_client: PyJWKClient | None = getattr(request.app.state, "jwks_client", None)
|
||||
if jwks_client is None:
|
||||
# Auth is on but there is no key source. Fail closed. Answering 500 here would be
|
||||
# honest about the cause and would also let a misconfigured deploy be told apart
|
||||
# from a bad token; 401 is the same answer a forger gets.
|
||||
raise _unauthorized()
|
||||
|
||||
try:
|
||||
# PyJWKClient keeps its own TTL cache, so this is a dict lookup on the hot path.
|
||||
# It is only blocking on a cache MISS (key rotation) — hence to_thread, which
|
||||
# costs a thread hop we take a handful of times a day rather than an event loop
|
||||
# stalled on someone else's HTTP call once per rotation.
|
||||
signing_key = await _signing_key(jwks_client, creds.credentials)
|
||||
claims: dict[str, Any] = jwt.decode(
|
||||
creds.credentials,
|
||||
signing_key.key,
|
||||
algorithms=ALLOWED_ALGORITHMS,
|
||||
audience=settings.jwt_audience,
|
||||
issuer=settings.jwt_issuer,
|
||||
options={
|
||||
"require": ["exp", "aud", "iss"],
|
||||
"verify_exp": True,
|
||||
"verify_aud": True,
|
||||
"verify_iss": settings.jwt_issuer is not None,
|
||||
"verify_signature": True,
|
||||
},
|
||||
)
|
||||
except Exception as exc: # deliberate catch-all: every failure becomes one opaque 401
|
||||
raise _unauthorized() from exc
|
||||
|
||||
team = claims.get(TEAM_CLAIM)
|
||||
if not isinstance(team, str) or not team:
|
||||
raise _unauthorized()
|
||||
return team
|
||||
|
||||
|
||||
async def _signing_key(client: PyJWKClient, token: str) -> jwt.PyJWK:
|
||||
"""Fetch the signing key off the event loop.
|
||||
|
||||
PyJWKClient.get_signing_key_from_jwt() does a synchronous urlopen on a cache miss.
|
||||
Called directly from `async def`, that blocks the loop — every other in-flight request
|
||||
on this worker stops until the identity provider answers, and if it hangs, so does the
|
||||
pod, and /readyz keeps saying it is fine.
|
||||
"""
|
||||
return await asyncio.to_thread(client.get_signing_key_from_jwt, token)
|
||||
|
||||
|
||||
async def rate_limit(team: Annotated[str, Depends(get_current_team)]) -> None:
|
||||
"""Per-team rate limiting. Seam only — Module 10 fills this in (Redis, Lua, token bucket).
|
||||
|
||||
It exists now, wired into the routes, so that turning it on is an edit to one function
|
||||
body rather than a change to every handler signature.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
async def idempotency_key(request: Request) -> str | None:
|
||||
"""`Idempotency-Key` handling. Seam only — Module 10 fills this in (Redis store).
|
||||
|
||||
Until then the real idempotency anchor is `instances.release_name`, which is unique in
|
||||
the schema and deterministic from (team, service_type, id).
|
||||
"""
|
||||
return request.headers.get("Idempotency-Key")
|
||||
|
||||
|
||||
PoolDep = Annotated[DictPool, Depends(get_pool)]
|
||||
TeamDep = Annotated[str, Depends(get_current_team)]
|
||||
InstanceRepoDep = Annotated[InstanceRepo, Depends(get_instance_repo)]
|
||||
TaskRepoDep = Annotated[TaskRepo, Depends(get_task_repo)]
|
||||
CatalogDep = Annotated[dict[str, CatalogEntry], Depends(get_catalog)]
|
||||
@@ -0,0 +1,121 @@
|
||||
"""The app factory and its lifespan.
|
||||
|
||||
`create_app(settings)` is a factory, not a module-level `app = FastAPI()`, for one reason:
|
||||
a test needs an app pointed at a throwaway Postgres, and an import-time app reads the real
|
||||
environment at import time — before any fixture can say otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from jwt import PyJWKClient
|
||||
|
||||
from services.api.models import ErrorBody
|
||||
from services.api.routes import health, instances
|
||||
from svcforge_core.domain.catalog import load_catalog
|
||||
from svcforge_core.repo.db import make_pool
|
||||
from svcforge_core.settings import Settings, load_settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
"""Open the pool, yield, close the pool.
|
||||
|
||||
A lifespan context, not the deprecated startup/shutdown event decorators: those cannot
|
||||
express "this resource lives for exactly as long as the app", and give you no place to
|
||||
put the teardown next to the setup. Closing the pool matters — an unclosed pool means
|
||||
connections linger server-side after SIGTERM, and on a pooled Postgres with a small
|
||||
connection budget a few rolling deploys exhaust it.
|
||||
|
||||
(The old decorator's name is spelled nowhere in this package on purpose: CI greps for
|
||||
the literal string, and a comment quoting it fails the gate just as loudly as a call.)
|
||||
"""
|
||||
settings: Settings = app.state.settings
|
||||
|
||||
app.state.catalog = load_catalog(settings.catalog_path)
|
||||
|
||||
pool = make_pool(str(settings.pg_dsn), settings.pool_min_size, settings.pool_max_size)
|
||||
# wait=True fails NOW, loudly, if the DSN is wrong — instead of at the first request,
|
||||
# as a PoolTimeout, in front of a user.
|
||||
await pool.open(wait=True)
|
||||
app.state.pool = pool
|
||||
|
||||
# The pool is open from here on, so everything below is inside the try: an exception
|
||||
# in JWKS setup must still close it, or a crash-looping pod leaks a connection per
|
||||
# restart until the database refuses new ones.
|
||||
try:
|
||||
if settings.jwks_url and not settings.auth_disabled:
|
||||
client = PyJWKClient(settings.jwks_url, cache_keys=True, lifespan=300)
|
||||
app.state.jwks_client = client
|
||||
# Warm the cache off the loop so the first authenticated request does not pay
|
||||
# a blocking urlopen. Best-effort: a slow identity provider must not stop the
|
||||
# pod from starting — a cache miss later just costs one to_thread hop.
|
||||
try:
|
||||
await asyncio.to_thread(client.get_signing_keys)
|
||||
except Exception: # deliberate catch-all: startup must not hinge on the IdP being up
|
||||
log.warning("JWKS warm-up failed; keys will be fetched on first use", exc_info=True)
|
||||
else:
|
||||
app.state.jwks_client = None
|
||||
|
||||
yield
|
||||
finally:
|
||||
await pool.close()
|
||||
|
||||
|
||||
async def _http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Render HTTPException bodies as ErrorBody, so every error has one shape.
|
||||
|
||||
Handlers raise `detail={"code": ..., "message": ...}`; FastAPI's default would nest
|
||||
that under `{"detail": {...}}`. Plain-string details (raised by FastAPI itself, e.g.
|
||||
a 405) are wrapped so clients never have to branch on the body's type.
|
||||
"""
|
||||
assert isinstance(exc, HTTPException) # noqa: S101 - registered only for HTTPException
|
||||
# Widened to object deliberately. Starlette types `detail` as str, but FastAPI passes
|
||||
# through whatever a handler raised — our handlers raise dicts. Narrowing off the
|
||||
# declared type would make mypy call the dict branch unreachable and delete it.
|
||||
detail: object = exc.detail
|
||||
if isinstance(detail, dict) and "code" in detail and "message" in detail:
|
||||
body = ErrorBody(code=str(detail["code"]), message=str(detail["message"]))
|
||||
else:
|
||||
body = ErrorBody(code=f"http_{exc.status_code}", message=str(detail))
|
||||
return JSONResponse(status_code=exc.status_code, content=body.model_dump(), headers=exc.headers)
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
"""App factory: lifespan, routers, exception handler, /metrics."""
|
||||
settings = settings or load_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="svcforge",
|
||||
version="0.1.0",
|
||||
summary="X-as-a-Service control plane",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.state.settings = settings
|
||||
|
||||
# /metrics is a normal route on health.router, not an app.mount — see health.metrics
|
||||
# for why the mount does not actually serve a bare /metrics.
|
||||
app.include_router(health.router)
|
||||
app.include_router(instances.router)
|
||||
|
||||
app.add_exception_handler(HTTPException, _http_exception_handler)
|
||||
return app
|
||||
|
||||
|
||||
def app() -> FastAPI:
|
||||
"""Entry point for `uvicorn services.api.main:app --factory`."""
|
||||
return create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("services.api.main:app", factory=True, host="0.0.0.0", port=8000) # noqa: S104
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Wire types.
|
||||
|
||||
These are deliberately NOT the domain models. `Instance` carries `team`, `namespace` and
|
||||
`release_name` — placement details a tenant has no business seeing and no business
|
||||
setting. The response model is the allow-list that keeps them off the wire, which is why
|
||||
it is written out by hand instead of derived from `Instance`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from svcforge_core.domain.states import InstanceState
|
||||
|
||||
|
||||
class CreateInstanceRequest(BaseModel):
|
||||
"""What a tenant may ask for.
|
||||
|
||||
`service_type` and `size` are plain strings, not enums: the catalog is data loaded at
|
||||
runtime, so baking its keys into a type would mean a redeploy to add a service type,
|
||||
and a 422 (schema) where the spec wants a 404 (unknown resource). They are validated
|
||||
against the catalog in the handler.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
service_type: str = Field(min_length=1)
|
||||
size: str
|
||||
ttl_days: int | None = Field(default=None, ge=1, le=30)
|
||||
|
||||
|
||||
class InstanceResponse(BaseModel):
|
||||
"""What a tenant gets back. A subset of Instance, on purpose."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
state: InstanceState
|
||||
service_type: str
|
||||
size: str
|
||||
endpoint: str | None
|
||||
chart_version: str
|
||||
error: str | None
|
||||
|
||||
|
||||
class ErrorBody(BaseModel):
|
||||
"""Every non-2xx body. `code` is for machines, `message` is for humans."""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP routers."""
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Liveness, readiness, metrics.
|
||||
|
||||
The distinction between the first two is not pedantry, it is the difference between a
|
||||
30-second blip and a fleet-wide outage:
|
||||
|
||||
* `/healthz` (liveness) answers "is this process wedged?" A failure here gets the
|
||||
container KILLED. It must therefore touch NOTHING external. Wire it to the DB and a
|
||||
20-second Postgres failover restarts every pod at once; they come back, find the DB
|
||||
still down, and CrashLoopBackOff with exponential restart delays — so the fleet is now
|
||||
down for minutes after the database recovered.
|
||||
* `/readyz` (readiness) answers "should this pod get traffic?" A failure here only removes
|
||||
it from the Service endpoints. It is allowed to check dependencies, and it recovers by
|
||||
itself the moment the check passes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
from prometheus_client import REGISTRY
|
||||
from prometheus_client.exposition import choose_encoder
|
||||
|
||||
from services.api.deps import PoolDep
|
||||
from services.api.models import ErrorBody
|
||||
|
||||
router = APIRouter(tags=["ops"])
|
||||
|
||||
# No PROMETHEUS_MULTIPROC_DIR here, deliberately: it exists for prefork servers where each
|
||||
# worker process holds a slice of the counters. One uvicorn process per container means
|
||||
# the default in-process registry is already correct, and multiproc mode would add a
|
||||
# shared temp dir, a cleanup obligation, and a class of stale-file bugs for nothing.
|
||||
|
||||
|
||||
@router.get("/healthz", status_code=status.HTTP_200_OK)
|
||||
async def healthz() -> dict[str, str]:
|
||||
"""Liveness. No I/O. If the event loop can run this, the process is alive."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/readyz",
|
||||
responses={503: {"model": ErrorBody, "description": "A dependency is unavailable"}},
|
||||
)
|
||||
async def readyz(pool: PoolDep) -> dict[str, str]:
|
||||
"""Readiness. Postgres only.
|
||||
|
||||
Postgres-only is the rule, and Redis is the temptation. Redis holds derived state —
|
||||
rate-limit buckets, caches — and everything degrades gracefully without it. Put it in
|
||||
this check and an Upstash hiccup marks every pod unready, Kubernetes empties the
|
||||
Service, and a cache outage becomes a total API outage.
|
||||
"""
|
||||
try:
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute("select 1")
|
||||
row: Any = await cur.fetchone()
|
||||
if row is None:
|
||||
raise RuntimeError("select 1 returned no row")
|
||||
except Exception as exc: # closed pool, timeout, dead DB — all mean the same 'not ready'
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"code": "not_ready", "message": "database unavailable"},
|
||||
) from exc
|
||||
return {"status": "ready"}
|
||||
|
||||
|
||||
@router.get("/metrics", response_class=Response)
|
||||
async def metrics(request: Request) -> Response:
|
||||
"""The Prometheus scrape endpoint.
|
||||
|
||||
A route rather than `app.mount("/metrics", make_asgi_app())`, for two reasons. A
|
||||
Starlette `Mount` compiles to `^/metrics(?P<path>/.*)$`, which does not match a bare
|
||||
`/metrics` — the exact URL every scrape config uses — and a `Mount` is invisible to
|
||||
OpenAPI, while the deliverable asks for `/metrics` in `openapi.json`.
|
||||
|
||||
The encoding is still prometheus_client's: `choose_encoder` reads the Accept header and
|
||||
picks the exposition format (Prometheus text vs OpenMetrics) with its matching content
|
||||
type. Hand-rolling either is how you end up serving text/plain that a scraper rejects.
|
||||
"""
|
||||
encoder, content_type = choose_encoder(request.headers.get("Accept", ""))
|
||||
return Response(content=encoder(REGISTRY), media_type=content_type)
|
||||
@@ -0,0 +1,218 @@
|
||||
"""The tenant-facing API.
|
||||
|
||||
Two rules run through every handler here:
|
||||
|
||||
* **AuthZ is the WHERE clause.** No handler ever compares `inst.team` to the caller's
|
||||
team, because the repo never returns another team's row to compare. A wrong-team id is
|
||||
a 404. 403 would confirm the id exists, which is the leak.
|
||||
* **The instance and its task commit together.** A committed instance with no task is an
|
||||
instance that never provisions and that nothing will ever retry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
|
||||
from services.api.deps import (
|
||||
CatalogDep,
|
||||
InstanceRepoDep,
|
||||
PoolDep,
|
||||
TaskRepoDep,
|
||||
TeamDep,
|
||||
idempotency_key,
|
||||
rate_limit,
|
||||
)
|
||||
from services.api.models import CreateInstanceRequest, ErrorBody, InstanceResponse
|
||||
from svcforge_core.domain.models import CatalogEntry, Instance, TaskKind
|
||||
from svcforge_core.domain.states import IllegalTransition, InstanceState, transition
|
||||
|
||||
# Declared on the router so every error shape lands in openapi.json under ErrorBody.
|
||||
# The exception handler already renders this at runtime; without declaring it, generated
|
||||
# clients see the contract for 2xx only and invent their own guess for the rest.
|
||||
ERROR_RESPONSES: dict[int | str, dict[str, Any]] = {
|
||||
401: {"model": ErrorBody, "description": "Missing or invalid credentials"},
|
||||
404: {"model": ErrorBody, "description": "No such instance, or not this team's"},
|
||||
409: {"model": ErrorBody, "description": "Instance is not in a state that allows this"},
|
||||
422: {"model": ErrorBody, "description": "The body is well-formed but cannot be processed"},
|
||||
}
|
||||
|
||||
router = APIRouter(prefix="/v1/instances", tags=["instances"], responses=ERROR_RESPONSES)
|
||||
|
||||
|
||||
def release_name_for(team: str, service_type: str, instance_id: UUID) -> str:
|
||||
"""The helm release name. Deterministic, and `unique` in the schema.
|
||||
|
||||
This is the idempotency anchor. A worker that dies after `helm install` but before it
|
||||
marks the task done will retry, compute the same name, and `helm upgrade --install`
|
||||
onto the same release instead of creating a second one. Derive it from anything that
|
||||
is not already durable — a timestamp, a random suffix, the retry count — and a retry
|
||||
provisions a duplicate.
|
||||
|
||||
Truncated to the uuid's first 8 chars to stay inside the 53-char limit helm imposes
|
||||
on release names (Kubernetes label values, minus room for chart-generated suffixes).
|
||||
"""
|
||||
return f"{team}-{service_type}-{str(instance_id)[:8]}"
|
||||
|
||||
|
||||
def namespace_for(team: str) -> str:
|
||||
"""One namespace per tenant. The blast radius of a bad chart is one team."""
|
||||
return f"tenant-{team}"
|
||||
|
||||
|
||||
def _resolve(catalog: dict[str, CatalogEntry], service_type: str, size: str) -> CatalogEntry:
|
||||
"""Look up service_type + size, or raise the right 4xx.
|
||||
|
||||
The two failures are different HTTP problems and the spec asks for different codes:
|
||||
an unknown service_type is a resource that does not exist (404); an unknown size for a
|
||||
real service_type is a body the server understood and cannot process (422).
|
||||
"""
|
||||
entry = catalog.get(service_type)
|
||||
if entry is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={
|
||||
"code": "unknown_service_type",
|
||||
"message": f"no such service_type: {service_type}",
|
||||
},
|
||||
)
|
||||
if size not in entry.sizes:
|
||||
raise HTTPException(
|
||||
status_code=422, # starlette renamed the 422 constant; the number never moved
|
||||
detail={
|
||||
"code": "unknown_size",
|
||||
"message": f"{service_type} has no size {size!r}; available: {sorted(entry.sizes)}",
|
||||
},
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
response_model=InstanceResponse,
|
||||
dependencies=[Depends(rate_limit), Depends(idempotency_key)],
|
||||
)
|
||||
async def create_instance(
|
||||
body: CreateInstanceRequest,
|
||||
response: Response,
|
||||
team: TeamDep,
|
||||
pool: PoolDep,
|
||||
instances: InstanceRepoDep,
|
||||
tasks: TaskRepoDep,
|
||||
catalog: CatalogDep,
|
||||
) -> Instance:
|
||||
"""Accept a provisioning request. 202, never 201.
|
||||
|
||||
Nothing is provisioned when this returns. The row exists and a task is queued; a
|
||||
worker will do the work seconds or minutes from now. 201 Created would be a lie about
|
||||
a resource that does not exist yet, and clients would stop polling.
|
||||
"""
|
||||
entry = _resolve(catalog, body.service_type, body.size)
|
||||
|
||||
instance_id = uuid4()
|
||||
now = datetime.now(UTC)
|
||||
inst = Instance(
|
||||
id=instance_id,
|
||||
team=team,
|
||||
service_type=body.service_type,
|
||||
size=body.size,
|
||||
state=InstanceState.REQUESTED,
|
||||
namespace=namespace_for(team),
|
||||
release_name=release_name_for(team, body.service_type, instance_id),
|
||||
# Pinned from the catalog AT CREATION TIME, not read from the catalog later.
|
||||
# This column records what is actually deployed; bumping catalog.yaml must show up
|
||||
# as drift the reconciler can see, not silently rewrite history.
|
||||
chart_version=entry.chart_version,
|
||||
expires_at=now + timedelta(days=body.ttl_days) if body.ttl_days is not None else None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
# The transaction. Both writes go through THIS conn, or the atomicity is theatre.
|
||||
async with pool.connection() as conn, conn.transaction():
|
||||
created = await instances.create(conn, inst)
|
||||
await tasks.enqueue(conn, created.id, TaskKind.PROVISION)
|
||||
|
||||
response.headers["Location"] = f"/v1/instances/{created.id}"
|
||||
return created
|
||||
|
||||
|
||||
@router.get("/{instance_id}", response_model=InstanceResponse)
|
||||
async def get_instance(
|
||||
instance_id: UUID,
|
||||
team: TeamDep,
|
||||
instances: InstanceRepoDep,
|
||||
) -> Instance:
|
||||
"""404 if the repo returns None. A wrong-team id is a 404, not a 403."""
|
||||
inst = await instances.get(instance_id, team)
|
||||
if inst is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"code": "not_found", "message": f"no such instance: {instance_id}"},
|
||||
)
|
||||
return inst
|
||||
|
||||
|
||||
@router.get("", response_model=list[InstanceResponse])
|
||||
async def list_instances(
|
||||
team: TeamDep,
|
||||
instances: InstanceRepoDep,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[Instance]:
|
||||
"""The caller's instances, newest first. Bounded: no endpoint returns 'all rows'."""
|
||||
return await instances.list(team, limit=limit)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{instance_id}",
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
response_model=InstanceResponse,
|
||||
dependencies=[Depends(rate_limit)],
|
||||
)
|
||||
async def delete_instance(
|
||||
instance_id: UUID,
|
||||
team: TeamDep,
|
||||
instances: InstanceRepoDep,
|
||||
tasks: TaskRepoDep,
|
||||
) -> Instance:
|
||||
"""state -> deleting, enqueue deprovision. 202: the helm uninstall has not happened yet.
|
||||
|
||||
Ordering note. `InstanceRepo.update_state` owns its own connection, so the CAS and the
|
||||
enqueue cannot share one transaction without reaching around the repo. Given two
|
||||
statements, the order is chosen for its failure mode: CAS first, enqueue second. A
|
||||
crash in between leaves an instance in `deleting` with no task, which the reconciler's
|
||||
sweep re-enqueues. The other order leaves a deprovision task pointing at a `ready`
|
||||
instance, and a worker would tear down a live service nobody asked to delete.
|
||||
"""
|
||||
inst = await instances.get(instance_id, team)
|
||||
if inst is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"code": "not_found", "message": f"no such instance: {instance_id}"},
|
||||
)
|
||||
|
||||
try:
|
||||
target = transition(inst.state, InstanceState.DELETING)
|
||||
except IllegalTransition as exc:
|
||||
# Already deleting or already deleted. Not an error the tenant can fix.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"code": "illegal_transition",
|
||||
"message": f"instance is {inst.state}; delete is not a legal transition",
|
||||
},
|
||||
) from exc
|
||||
|
||||
if not await instances.update_state(inst.id, expect=inst.state, to=target):
|
||||
# Someone moved the row between the read and the CAS.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"code": "conflict", "message": "instance changed concurrently; retry"},
|
||||
)
|
||||
await tasks.enqueue_standalone(inst.id, TaskKind.DEPROVISION)
|
||||
|
||||
return inst.model_copy(update={"state": target})
|
||||
@@ -0,0 +1,170 @@
|
||||
"""svcforge — the control plane client.
|
||||
|
||||
This talks to the API over HTTP and never touches the database. That restraint is the
|
||||
whole design: if the CLI could write to Postgres, every invariant the API enforces
|
||||
(the state machine, the one-transaction create, AuthZ in the WHERE clause) would have a
|
||||
back door, and the first 3am incident would go through it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Any
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
|
||||
from svcforge_core.domain.states import InstanceState
|
||||
from svcforge_core.settings import load_settings
|
||||
|
||||
app = typer.Typer(help="svcforge control plane client", no_args_is_help=True)
|
||||
|
||||
_TERMINAL = {InstanceState.READY, InstanceState.FAILED, InstanceState.DELETED}
|
||||
|
||||
|
||||
class ServiceType(StrEnum):
|
||||
"""What the catalog offers. Kept as an enum so typer can complete and validate it."""
|
||||
|
||||
ELASTICSEARCH = "elasticsearch"
|
||||
REDIS = "redis"
|
||||
POSTGRES = "postgres"
|
||||
|
||||
|
||||
class Size(StrEnum):
|
||||
SMALL = "small"
|
||||
MEDIUM = "medium"
|
||||
|
||||
|
||||
def _client() -> httpx.Client:
|
||||
settings = load_settings()
|
||||
headers = {"authorization": f"Bearer {settings.api_token}"} if settings.api_token else {}
|
||||
return httpx.Client(base_url=settings.api_url, headers=headers, timeout=10.0)
|
||||
|
||||
|
||||
def _die(msg: str) -> None:
|
||||
typer.secho(msg, fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
def _check(resp: httpx.Response) -> Any: # noqa: ANN401 - a decoded JSON body genuinely is Any
|
||||
if resp.status_code == 401:
|
||||
_die("401 unauthorized: check SVCFORGE_API_TOKEN")
|
||||
if resp.status_code == 404:
|
||||
_die("404 not found")
|
||||
if resp.status_code == 429:
|
||||
_die("429 rate limited: slow down")
|
||||
if resp.status_code >= 400:
|
||||
_die(f"{resp.status_code}: {resp.text[:400]}")
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _parse_ttl(ttl: str | None) -> int | None:
|
||||
"""'7d' -> 7. Only days, because the API only takes days."""
|
||||
if ttl is None:
|
||||
return None
|
||||
if not ttl.endswith("d") or not ttl[:-1].isdigit():
|
||||
_die(f"bad --ttl {ttl!r}: expected something like '7d'")
|
||||
return int(ttl[:-1])
|
||||
|
||||
|
||||
def _print_table(rows: list[dict[str, Any]]) -> None:
|
||||
if not rows:
|
||||
typer.echo("(none)")
|
||||
return
|
||||
cols = ["id", "service_type", "size", "state", "chart_version", "endpoint"]
|
||||
widths = {c: max(len(c), *(len(str(r.get(c) or "-")) for r in rows)) for c in cols}
|
||||
typer.echo(" ".join(c.ljust(widths[c]) for c in cols))
|
||||
typer.echo(" ".join("-" * widths[c] for c in cols))
|
||||
for r in rows:
|
||||
typer.echo(" ".join(str(r.get(c) or "-").ljust(widths[c]) for c in cols))
|
||||
|
||||
|
||||
@app.command()
|
||||
def create(
|
||||
service_type: Annotated[ServiceType, typer.Argument(help="what to provision")],
|
||||
size: Annotated[Size, typer.Option()] = Size.SMALL,
|
||||
ttl: Annotated[str | None, typer.Option(help="e.g. 7d")] = None,
|
||||
wait: Annotated[bool, typer.Option(help="poll until ready or failed")] = False,
|
||||
) -> None:
|
||||
"""Request an instance. Returns as soon as the API accepts it (202)."""
|
||||
body: dict[str, Any] = {"service_type": service_type.value, "size": size.value}
|
||||
ttl_days = _parse_ttl(ttl)
|
||||
if ttl_days is not None:
|
||||
body["ttl_days"] = ttl_days
|
||||
|
||||
with _client() as c:
|
||||
data = _check(c.post("/v1/instances", json=body))
|
||||
typer.echo(f"{data['id']} {data['state']}")
|
||||
if not wait:
|
||||
return
|
||||
|
||||
# 202 means "accepted", not "done". Polling is the client's job precisely because
|
||||
# the API refused to block on a helm install that takes four minutes.
|
||||
instance_id = data["id"]
|
||||
deadline = time.monotonic() + 600
|
||||
state = data["state"]
|
||||
while time.monotonic() < deadline:
|
||||
time.sleep(2)
|
||||
data = _check(c.get(f"/v1/instances/{instance_id}"))
|
||||
if data["state"] != state:
|
||||
state = data["state"]
|
||||
typer.echo(f" -> {state}")
|
||||
if state in _TERMINAL:
|
||||
break
|
||||
else:
|
||||
_die("timed out waiting; the task may still be running — check `svcforge status`")
|
||||
|
||||
if state == InstanceState.FAILED:
|
||||
_die(f"failed: {data.get('error') or 'no error recorded'}")
|
||||
typer.echo(f"endpoint: {data.get('endpoint') or '-'}")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def list_instances(
|
||||
state: Annotated[InstanceState | None, typer.Option(help="filter by state")] = None,
|
||||
) -> None:
|
||||
"""List your team's instances."""
|
||||
with _client() as c:
|
||||
rows = _check(c.get("/v1/instances"))
|
||||
if state is not None:
|
||||
rows = [r for r in rows if r["state"] == state.value]
|
||||
_print_table(rows)
|
||||
|
||||
|
||||
@app.command()
|
||||
def status(instance_id: Annotated[uuid.UUID, typer.Argument()]) -> None:
|
||||
"""Show one instance. Exits non-zero if it is failed, so scripts can branch on it."""
|
||||
with _client() as c:
|
||||
data = _check(c.get(f"/v1/instances/{instance_id}"))
|
||||
_print_table([data])
|
||||
if data["state"] == InstanceState.FAILED:
|
||||
typer.secho(f"error: {data.get('error')}", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
@app.command()
|
||||
def delete(
|
||||
instance_id: Annotated[uuid.UUID, typer.Argument()],
|
||||
yes: Annotated[bool, typer.Option("--yes", "-y", help="skip the confirmation")] = False,
|
||||
) -> None:
|
||||
"""Deprovision an instance."""
|
||||
if not yes and not typer.confirm(f"delete {instance_id}?"):
|
||||
raise typer.Abort
|
||||
with _client() as c:
|
||||
data = _check(c.delete(f"/v1/instances/{instance_id}"))
|
||||
typer.echo(f"{data['id']} {data['state']}")
|
||||
|
||||
|
||||
def main() -> None: # pragma: no cover - console-script entrypoint
|
||||
try:
|
||||
app()
|
||||
except httpx.ConnectError:
|
||||
typer.secho("cannot reach the API: check SVCFORGE_API_URL", fg=typer.colors.RED, err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,53 @@
|
||||
# syntax=docker/dockerfile:1.10
|
||||
#
|
||||
# svcforge reconciler. Build from the REPO ROOT:
|
||||
# docker buildx build -f services/reconciler/Dockerfile -t svcforge/reconciler:dev .
|
||||
#
|
||||
# Reads helm state to detect drift, so it carries helm — but never kubectl, and it never
|
||||
# writes: the four checks enqueue tasks, they do not provision. Orphans are logged, never
|
||||
# deleted.
|
||||
|
||||
FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS builder
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.5.11@sha256:0ac957607303916420297a4c9c213bb33fbd3c888f9cd7f4f7273596ebf42b85 /uv /usr/local/bin/uv
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY libs/svcforge_core/pyproject.toml libs/svcforge_core/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev --no-editable \
|
||||
--no-install-project --no-install-package svcforge-core
|
||||
|
||||
COPY libs/ libs/
|
||||
COPY services/reconciler/ services/reconciler/
|
||||
COPY catalog.yaml ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev --no-editable && \
|
||||
/app/.venv/bin/python -c 'import svcforge_core, sys; \
|
||||
p = svcforge_core.__file__; \
|
||||
sys.exit(0) if "site-packages" in p else sys.exit("not a wheel install: " + p)'
|
||||
|
||||
# --- runtime --------------------------------------------------------------------------
|
||||
FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de
|
||||
|
||||
ARG BUILD_SHA=unknown
|
||||
LABEL org.opencontainers.image.title="svcforge-reconciler" \
|
||||
org.opencontainers.image.source="https://gitea.oci-oci.duckdns.org/gitea_admin/svcforge" \
|
||||
org.opencontainers.image.revision="${BUILD_SHA}"
|
||||
|
||||
RUN useradd -u 10001 -m -s /usr/sbin/nologin svcforge
|
||||
WORKDIR /app
|
||||
COPY --from=builder --chown=10001:10001 /app /app
|
||||
|
||||
COPY --from=alpine/helm:3.16.2@sha256:a19a2968fd672336d39771f6c899781424d725229148656dbc2a1e305003cdec /usr/bin/helm /usr/local/bin/helm
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
HELM_CACHE_HOME=/tmp/helm/cache \
|
||||
HELM_CONFIG_HOME=/tmp/helm/config \
|
||||
HELM_DATA_HOME=/tmp/helm/data
|
||||
USER 10001
|
||||
ENTRYPOINT ["python", "-m", "services.reconciler.main"]
|
||||
@@ -0,0 +1 @@
|
||||
"""The reconciler service: one singleton loop that makes the world match the database."""
|
||||
@@ -0,0 +1,410 @@
|
||||
"""The control loop.
|
||||
|
||||
Every other service in svcforge is edge-triggered: a tenant POSTs, a row appears, a worker
|
||||
claims it. Edge-triggered systems are correct exactly as long as nothing is ever missed —
|
||||
and things are missed. A worker is SIGKILLed holding a lease. An operator runs
|
||||
`helm uninstall` by hand. A pod dies between the CAS and the enqueue. Nobody sends an event
|
||||
for any of that, because the thing that would have sent it is the thing that died.
|
||||
|
||||
So: level-triggered. Every 60 seconds, compare the world to the database and enqueue what
|
||||
is missing. The four checks below do not know or care what went wrong, or whether anything
|
||||
did; they are the same code on the happy path and after an outage. That property is the
|
||||
entire reason this service exists, and it is why each check is written as a *query for
|
||||
work*, never as a reaction to an event.
|
||||
|
||||
Three rules hold the design together:
|
||||
|
||||
* **Singleton.** `replicas: 1`, `strategy: Recreate` in the chart. Two reconcilers
|
||||
double-enqueue drift and race on TTL. There is no leader election here on purpose: the
|
||||
correct lease for that lives in Postgres next to the data, not in a Redis lock, and
|
||||
until there is a second replica to elect between, an election is a subsystem that can
|
||||
only fail. One pod, and the `SvcforgeReconcilerStale` alert is what notices it is gone.
|
||||
* **Each check is independent.** One failing check must not skip the other three. A helm
|
||||
binary that cannot reach the API server must not stop TTLs from expiring.
|
||||
* **Enqueue, never act.** The reconciler diagnoses; workers treat. It writes task rows and
|
||||
instance states, and never calls `helm install`. The one exception is reading — the drift
|
||||
check runs `helm list`, because seeing reality is the job.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import signal
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
import typer
|
||||
|
||||
from svcforge_core.adapters.clock import Clock, SystemClock
|
||||
from svcforge_core.adapters.helm import HelmProvisioner, Provisioner
|
||||
from svcforge_core.adapters.notify import LogNotifier, Notifier
|
||||
from svcforge_core.domain.catalog import load_catalog
|
||||
from svcforge_core.domain.models import CatalogEntry
|
||||
from svcforge_core.domain.windows import BadWindow, parse_window, schedule_upgrade_at
|
||||
from svcforge_core.obs import (
|
||||
INSTANCES,
|
||||
QUEUE_DEPTH,
|
||||
RECONCILER_LAST_TICK,
|
||||
get_logger,
|
||||
setup,
|
||||
start_metrics_server,
|
||||
tracer,
|
||||
)
|
||||
from svcforge_core.repo.db import DictPool, make_pool
|
||||
from svcforge_core.repo.instances import InstanceRepo
|
||||
from svcforge_core.repo.reconcile import ReconcileRepo
|
||||
from svcforge_core.repo.tasks import TaskRepo
|
||||
from svcforge_core.settings import Settings, load_settings
|
||||
|
||||
log = get_logger("svcforge.reconciler")
|
||||
|
||||
# The chart's PodMonitor scrapes the port named `metrics` on 9000. Keep them in step.
|
||||
DEFAULT_METRICS_PORT = 9000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReconcilerDeps:
|
||||
"""Everything a check is allowed to touch. Built once in main(), passed down.
|
||||
|
||||
Same shape as `WorkerDeps` for the same reason: the checks take `deps` instead of
|
||||
reaching for globals, so the integration tests below run every check against a real
|
||||
Postgres and a `FakeProvisioner` without a cluster anywhere in sight.
|
||||
"""
|
||||
|
||||
pool: DictPool
|
||||
instances: InstanceRepo
|
||||
tasks: TaskRepo
|
||||
reconcile: ReconcileRepo
|
||||
provisioner: Provisioner
|
||||
notifier: Notifier
|
||||
clock: Clock
|
||||
catalog: dict[str, CatalogEntry]
|
||||
settings: Settings
|
||||
# Whose instances go first in the day-2 work list. Eating your own dog food is an
|
||||
# `order by`, not a policy document — see InstanceRepo.list_upgradable.
|
||||
own_team: str
|
||||
# A config value, not a scheduler. Leave it at 1 until 1 is too slow.
|
||||
max_in_flight: int
|
||||
|
||||
|
||||
# --- The four checks ---------------------------------------------------------------------
|
||||
|
||||
|
||||
async def check_drift(deps: ReconcilerDeps) -> None:
|
||||
"""`helm list -A -o json` versus what the database believes.
|
||||
|
||||
This is the only check that looks outside Postgres, and the only one that can catch the
|
||||
failure nothing else can: someone ran `helm uninstall` by hand, or a node was drained
|
||||
and the release never came back. The DB still says `ready` and still hands the tenant an
|
||||
endpoint that resolves to nothing.
|
||||
|
||||
Two directions, two very different answers:
|
||||
|
||||
* **Release gone, DB says `ready`** -> re-enqueue provision. Safe, because provisioning
|
||||
is `helm upgrade --install` against a deterministic release name: converging on
|
||||
desired state, not a blind re-install.
|
||||
* **Release exists, DB knows nothing** -> log at error with release and namespace, and
|
||||
stop. **Never delete in v1.** The reconciler's view of "the DB knows nothing" is one
|
||||
query against one database; the release might belong to another team, another tool,
|
||||
or a migration half-finished. Deleting on that evidence is how an automated system
|
||||
takes down production faster than any human could. A human reads the log and decides.
|
||||
"""
|
||||
with tracer().start_as_current_span("helm.list"):
|
||||
releases = await deps.provisioner.list_releases()
|
||||
live = {(r.name, r.namespace) for r in releases}
|
||||
|
||||
for inst in await deps.reconcile.ready_instances():
|
||||
if (inst.release_name, inst.namespace) in live:
|
||||
continue
|
||||
reason = f"drift: helm release {inst.release_name} missing from namespace {inst.namespace}"
|
||||
task_id = await deps.reconcile.enqueue_reprovision(inst.id, reason)
|
||||
if task_id is None:
|
||||
continue # already being dealt with, or the row moved under us
|
||||
log.warning(
|
||||
"drift.release_missing",
|
||||
instance_id=str(inst.id),
|
||||
team=inst.team,
|
||||
release=inst.release_name,
|
||||
namespace=inst.namespace,
|
||||
task_id=task_id,
|
||||
)
|
||||
try:
|
||||
await deps.notifier.send(
|
||||
"drift.release_missing",
|
||||
f"re-provisioning {inst.id}: release {inst.release_name} vanished",
|
||||
{"instance_id": str(inst.id), "team": inst.team},
|
||||
)
|
||||
except Exception:
|
||||
# The task is already committed; the notification is a courtesy. A webhook
|
||||
# timing out must not abandon the rest of the sweep — the instances after this
|
||||
# one in the loop have the same problem and nobody else is coming to find them.
|
||||
log.exception("notify.failed", instance_id=str(inst.id))
|
||||
|
||||
known = await deps.reconcile.known_releases()
|
||||
for name, namespace in sorted(live - known):
|
||||
# error, not warning: this is a resource nobody is billing for and nobody owns.
|
||||
# It will sit here every 60s until a human deletes it or adopts it. That is the
|
||||
# intended pressure.
|
||||
log.error("drift.orphan_release", release=name, namespace=namespace, action="none (v1 never deletes)")
|
||||
|
||||
|
||||
async def check_lease_expiry(deps: ReconcilerDeps) -> None:
|
||||
"""Tasks whose worker died -> back to `queued`.
|
||||
|
||||
A lease, not a lock. No lock survives a power cut: a worker SIGKILLed mid-provision
|
||||
leaves `state='running'` with `locked_by` set and nobody running it, and no amount of
|
||||
cleanup code in the worker helps, because the worker is the part that died. `locked_at`
|
||||
plus a timeout is the only thing that recovers the row, which is why `locked_at` exists.
|
||||
|
||||
The 5-minute default is not arbitrary: it must exceed the longest a healthy task can
|
||||
hold a lease, or the reconciler hands a still-running provision to a second worker.
|
||||
Handlers are idempotent, so that is survivable rather than fatal — but survivable is
|
||||
not free, and `lease_seconds` sits above helm's `--timeout` for that reason.
|
||||
"""
|
||||
freed = await deps.tasks.reset_expired_leases(deps.settings.lease_seconds)
|
||||
if freed:
|
||||
log.warning("lease.expired", tasks_freed=freed, lease_seconds=deps.settings.lease_seconds)
|
||||
|
||||
|
||||
async def check_ttl(deps: ReconcilerDeps) -> None:
|
||||
"""Expired instances -> `deleting`, plus a deprovision task.
|
||||
|
||||
The line item that stops a demo cluster from becoming a permanent cloud bill. Also the
|
||||
sweep the API's DELETE route depends on: it CASes to `deleting` and enqueues in two
|
||||
statements, and a crash in between lands here on the next tick.
|
||||
|
||||
Idempotent by construction — the work list excludes anything that already has a queued
|
||||
or running deprovision, and the CAS and the insert share one transaction. Without that
|
||||
guard, a deprovision that takes longer than 60 seconds gets a second task on the next
|
||||
tick, and a third on the tick after.
|
||||
"""
|
||||
for inst in await deps.reconcile.due_for_deprovision():
|
||||
task_id = await deps.reconcile.enqueue_deprovision(inst.id)
|
||||
if task_id is None:
|
||||
continue
|
||||
log.info(
|
||||
"ttl.expired",
|
||||
instance_id=str(inst.id),
|
||||
team=inst.team,
|
||||
expires_at=inst.expires_at.isoformat() if inst.expires_at else None,
|
||||
previous_state=inst.state.value,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
|
||||
async def check_version_drift(deps: ReconcilerDeps) -> None:
|
||||
"""The day-2 rollout: the work-list query, one service type at a time.
|
||||
|
||||
Everything that makes this safe is somewhere else, which is the point:
|
||||
|
||||
* `list_upgradable` limits to `max_in_flight` and returns nothing while
|
||||
`rollout_state='halted'`, so a bad chart stops after one tenant.
|
||||
* `schedule_upgrade_at` turns the tenant's maintenance window into a `run_after`; the
|
||||
queue does the waiting, in `where run_after <= now()`. There is no scheduler here and
|
||||
there must not be one — a task parked in Postgres until 03:00 Sunday survives a
|
||||
reconciler restart, and an in-memory timer does not.
|
||||
* `security: true` in the catalog bypasses the window. A CVE with a public exploit does
|
||||
not wait until Sunday.
|
||||
|
||||
A bad window spec is this instance's problem, not the fleet's: log it and move to the
|
||||
next one. Failing the whole check would let one tenant's typo freeze everyone's
|
||||
security rollout.
|
||||
"""
|
||||
now = deps.clock.now()
|
||||
|
||||
for service_type, entry in deps.catalog.items():
|
||||
candidates = await deps.instances.list_upgradable(
|
||||
service_type=service_type,
|
||||
catalog_version=entry.chart_version,
|
||||
own_team=deps.own_team,
|
||||
max_in_flight=deps.max_in_flight,
|
||||
)
|
||||
for candidate in candidates:
|
||||
inst = candidate.instance
|
||||
try:
|
||||
window = parse_window(candidate.maintenance_window)
|
||||
except BadWindow:
|
||||
log.exception(
|
||||
"upgrade.bad_window",
|
||||
instance_id=str(inst.id),
|
||||
team=inst.team,
|
||||
maintenance_window=candidate.maintenance_window,
|
||||
)
|
||||
continue
|
||||
|
||||
run_after = schedule_upgrade_at(window, security=entry.security, now=now)
|
||||
task_id = await deps.reconcile.enqueue_upgrade(inst.id, run_after)
|
||||
if task_id is None:
|
||||
continue # already queued or running; this is the max_in_flight guard
|
||||
log.info(
|
||||
"upgrade.scheduled",
|
||||
instance_id=str(inst.id),
|
||||
team=inst.team,
|
||||
service_type=service_type,
|
||||
from_version=inst.chart_version,
|
||||
to_version=entry.chart_version,
|
||||
run_after=run_after.isoformat(),
|
||||
security=entry.security,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
|
||||
CHECKS: dict[str, Callable[[ReconcilerDeps], Awaitable[None]]] = {
|
||||
"drift": check_drift,
|
||||
"lease_expiry": check_lease_expiry,
|
||||
"ttl": check_ttl,
|
||||
"version_drift": check_version_drift,
|
||||
}
|
||||
|
||||
|
||||
# --- The tick ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def tick(deps: ReconcilerDeps) -> None:
|
||||
"""One pass: all four checks, then the gauges, then the heartbeat.
|
||||
|
||||
Checks first, gauges second: `svcforge_queue_depth` is read straight after the checks
|
||||
that add to the queue, so the value scraped is the value the tick left behind rather
|
||||
than one from before its own work.
|
||||
|
||||
The heartbeat is set unconditionally, and that is deliberate. It answers "is the loop
|
||||
running", not "is everything fine" — the checks have their own alerts. Gating it on
|
||||
success would make `SvcforgeReconcilerStale` fire for a helm blip and mean two things
|
||||
at once, and an alert that means two things gets muted.
|
||||
|
||||
The whole tick runs inside one span, which is a considered exception to "manual spans go
|
||||
around helm calls only". That rule exists so the API does not hand-roll spans that
|
||||
`opentelemetry-instrument` already creates for it. Nothing auto-instruments the
|
||||
reconciler: without a span here it emits no traces at all, and — because
|
||||
`inject_traceparent` serialises the *active* context — every task it enqueues would be
|
||||
written with a null `traceparent` and be unjoinable to the tick that decided to create
|
||||
it. One span per tick is what makes "why was this instance re-provisioned at 03:00?" a
|
||||
question the traces can answer.
|
||||
"""
|
||||
with tracer().start_as_current_span("reconciler.tick"):
|
||||
await _run_checks(deps)
|
||||
|
||||
RECONCILER_LAST_TICK.set(deps.clock.now().timestamp())
|
||||
log.info("tick.done")
|
||||
|
||||
|
||||
async def _run_checks(deps: ReconcilerDeps) -> None:
|
||||
"""The four checks and the gauges. Split out so `tick` reads as span + heartbeat."""
|
||||
for name, check in CHECKS.items():
|
||||
try:
|
||||
await check(deps)
|
||||
except Exception: # the tick is the error boundary
|
||||
# The swallow is the design. These four checks share nothing but a database
|
||||
# handle, and the value of a level-triggered loop is that it keeps running: an
|
||||
# unreachable cluster must not stop TTLs from expiring, and one tenant's broken
|
||||
# window spec must not stop drift detection. This means "this check achieved
|
||||
# nothing for 60 seconds", which the log says out loud. It never means "the
|
||||
# reconciler stops".
|
||||
log.exception("check.failed", check=name)
|
||||
|
||||
try:
|
||||
QUEUE_DEPTH.set(await deps.reconcile.queue_depth())
|
||||
counts = await deps.reconcile.instance_counts()
|
||||
for state, n in counts.items():
|
||||
INSTANCES.labels(state=state).set(n)
|
||||
except Exception: # gauges are diagnostics; a failed read is not a failed tick
|
||||
log.exception("gauges.failed")
|
||||
|
||||
|
||||
async def _sleep_or_stop(stop: asyncio.Event, seconds: float) -> None:
|
||||
"""Sleep, but wake immediately on SIGTERM. A 60s nap must not cost 60s of shutdown."""
|
||||
with contextlib.suppress(TimeoutError):
|
||||
await asyncio.wait_for(stop.wait(), timeout=seconds)
|
||||
|
||||
|
||||
async def run_reconciler(deps: ReconcilerDeps, stop: asyncio.Event) -> None:
|
||||
"""Tick, sleep, repeat, until told to stop.
|
||||
|
||||
Tick first, then sleep: a pod that has just been restarted should reconcile now, not in
|
||||
sixty seconds. Fixed interval rather than a fixed period — a tick that overruns simply
|
||||
delays the next one, instead of stacking a second tick on top of the first, which for a
|
||||
singleton would be exactly the concurrent reconciler `replicas: 1` exists to prevent.
|
||||
"""
|
||||
while not stop.is_set():
|
||||
await tick(deps)
|
||||
await _sleep_or_stop(stop, deps.settings.reconcile_interval_s)
|
||||
|
||||
|
||||
def build_deps(
|
||||
pool: DictPool,
|
||||
settings: Settings,
|
||||
own_team: str,
|
||||
max_in_flight: int,
|
||||
) -> ReconcilerDeps:
|
||||
"""Wire the real collaborators. The only place that names concrete classes."""
|
||||
return ReconcilerDeps(
|
||||
pool=pool,
|
||||
instances=InstanceRepo(pool),
|
||||
tasks=TaskRepo(pool),
|
||||
reconcile=ReconcileRepo(pool),
|
||||
provisioner=HelmProvisioner(helm_bin=settings.helm_bin, timeout_s=int(settings.helm_timeout_s)),
|
||||
notifier=LogNotifier(),
|
||||
clock=SystemClock(),
|
||||
catalog=load_catalog(settings.catalog_path),
|
||||
settings=settings,
|
||||
own_team=own_team,
|
||||
max_in_flight=max_in_flight,
|
||||
)
|
||||
|
||||
|
||||
async def _amain(once: bool, metrics_port: int, own_team: str, max_in_flight: int) -> None:
|
||||
settings = load_settings()
|
||||
setup("svcforge-reconciler", settings)
|
||||
|
||||
pool = make_pool(settings.pg_dsn.unicode_string(), settings.pool_min_size, settings.pool_max_size)
|
||||
await pool.open(wait=True)
|
||||
deps = build_deps(pool, settings, own_team, max_in_flight)
|
||||
|
||||
try:
|
||||
if once:
|
||||
# One pass and exit: the acceptance path, and how you drive a reconcile by hand
|
||||
# from a shell. No metrics server — nothing would ever scrape it.
|
||||
await tick(deps)
|
||||
return
|
||||
|
||||
start_metrics_server(metrics_port)
|
||||
|
||||
stop = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
# add_signal_handler, NOT signal.signal. signal.signal fires the handler at an
|
||||
# arbitrary bytecode boundary on the main thread and the loop does not notice
|
||||
# until its next timer — which here is up to a full 60s tick away. This one is
|
||||
# scheduled as an ordinary loop callback, so the `stop.wait()` above returns
|
||||
# immediately.
|
||||
loop.add_signal_handler(sig, stop.set)
|
||||
|
||||
await run_reconciler(deps, stop)
|
||||
finally:
|
||||
await pool.close()
|
||||
|
||||
|
||||
app = typer.Typer(add_completion=False, help="svcforge reconciler: the control loop.")
|
||||
|
||||
|
||||
@app.command()
|
||||
def main(
|
||||
once: bool = typer.Option(False, "--once", help="Run one tick and exit."),
|
||||
metrics_port: int = typer.Option(
|
||||
DEFAULT_METRICS_PORT, envvar="SVCFORGE_METRICS_PORT", help="Port for /metrics."
|
||||
),
|
||||
own_team: str = typer.Option(
|
||||
"platform", envvar="SVCFORGE_OWN_TEAM", help="Team whose instances upgrade first."
|
||||
),
|
||||
max_in_flight: int = typer.Option(
|
||||
1, envvar="SVCFORGE_MAX_IN_FLIGHT", min=1, help="Concurrent upgrades across the fleet."
|
||||
),
|
||||
) -> None:
|
||||
"""Run the reconciler."""
|
||||
# One asyncio.run, at the top, never nested. Everything below it is already async.
|
||||
asyncio.run(_amain(once, metrics_port, own_team, max_in_flight))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -0,0 +1,54 @@
|
||||
# syntax=docker/dockerfile:1.10
|
||||
#
|
||||
# svcforge worker. Build from the REPO ROOT:
|
||||
# docker buildx build -f services/worker/Dockerfile -t svcforge/worker:dev .
|
||||
#
|
||||
# The only service that shells out to helm/kubectl, so the only one carrying those two
|
||||
# binaries. They are copied from pinned images rather than curl'd, so the version is a
|
||||
# reviewable line in a Dockerfile instead of a network call at build time.
|
||||
|
||||
FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS builder
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.5.11@sha256:0ac957607303916420297a4c9c213bb33fbd3c888f9cd7f4f7273596ebf42b85 /uv /usr/local/bin/uv
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY libs/svcforge_core/pyproject.toml libs/svcforge_core/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev --no-editable \
|
||||
--no-install-project --no-install-package svcforge-core
|
||||
|
||||
COPY libs/ libs/
|
||||
COPY services/worker/ services/worker/
|
||||
COPY catalog.yaml ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev --no-editable && \
|
||||
/app/.venv/bin/python -c 'import svcforge_core, sys; \
|
||||
p = svcforge_core.__file__; \
|
||||
sys.exit(0) if "site-packages" in p else sys.exit("not a wheel install: " + p)'
|
||||
|
||||
# --- runtime --------------------------------------------------------------------------
|
||||
FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de
|
||||
|
||||
ARG BUILD_SHA=unknown
|
||||
LABEL org.opencontainers.image.title="svcforge-worker" \
|
||||
org.opencontainers.image.source="https://gitea.oci-oci.duckdns.org/gitea_admin/svcforge" \
|
||||
org.opencontainers.image.revision="${BUILD_SHA}"
|
||||
|
||||
RUN useradd -u 10001 -m -s /usr/sbin/nologin svcforge
|
||||
WORKDIR /app
|
||||
COPY --from=builder --chown=10001:10001 /app /app
|
||||
|
||||
COPY --from=alpine/helm:3.16.2@sha256:a19a2968fd672336d39771f6c899781424d725229148656dbc2a1e305003cdec /usr/bin/helm /usr/local/bin/helm
|
||||
COPY --from=bitnamilegacy/kubectl:1.31.2@sha256:0eab9ec8f5e0f75271277467ebb7513b36dea0122bc68615d18c47fece4fd82c /opt/bitnami/kubectl/bin/kubectl /usr/local/bin/kubectl
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
HELM_CACHE_HOME=/tmp/helm/cache \
|
||||
HELM_CONFIG_HOME=/tmp/helm/config \
|
||||
HELM_DATA_HOME=/tmp/helm/data
|
||||
USER 10001
|
||||
ENTRYPOINT ["python", "-m", "services.worker.main"]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""What a worker needs to do its job.
|
||||
|
||||
One frozen bag of collaborators, constructed once in main() and passed down. Handlers
|
||||
take `deps` rather than reaching for globals, which is the entire reason the worker tests
|
||||
run in milliseconds against a FakeProvisioner instead of needing a cluster.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from svcforge_core.adapters.clock import Clock
|
||||
from svcforge_core.adapters.helm import Provisioner
|
||||
from svcforge_core.adapters.notify import Notifier
|
||||
from svcforge_core.domain.models import CatalogEntry
|
||||
from svcforge_core.repo.db import DictPool
|
||||
from svcforge_core.repo.instances import InstanceRepo
|
||||
from svcforge_core.repo.tasks import TaskRepo
|
||||
from svcforge_core.settings import Settings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerDeps:
|
||||
"""Everything a handler is allowed to touch."""
|
||||
|
||||
pool: DictPool
|
||||
instances: InstanceRepo
|
||||
tasks: TaskRepo
|
||||
provisioner: Provisioner
|
||||
notifier: Notifier
|
||||
clock: Clock
|
||||
catalog: dict[str, CatalogEntry]
|
||||
settings: Settings
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Task handlers.
|
||||
|
||||
Every handler here obeys one rule: running it twice must equal running it once.
|
||||
|
||||
That is not a nicety. A worker can be SIGKILLed after helm has installed the release but
|
||||
before the DB row says so; the lease expires; another worker claims the same task and runs
|
||||
this function again. If the handler is not idempotent, the tenant gets two Elasticsearches
|
||||
and you get a bill. Idempotency is what makes the crash safe, and it is bought in two
|
||||
places: a deterministic `release_name`, and adapters that state desired state
|
||||
(`helm upgrade --install`) instead of issuing imperative commands.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from services.worker.deps import WorkerDeps
|
||||
from svcforge_core.domain.models import CatalogEntry, Instance, Task, TaskKind
|
||||
from svcforge_core.domain.states import InstanceState
|
||||
|
||||
|
||||
class HandlerError(RuntimeError):
|
||||
"""A task failed in a way worth retrying. The message lands in tasks.last_error."""
|
||||
|
||||
|
||||
async def _load_instance(task: Task, deps: WorkerDeps) -> Instance:
|
||||
async with deps.pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""select id, team, service_type, size, state, namespace, release_name,
|
||||
chart_version, endpoint, error, expires_at, created_at, updated_at
|
||||
from instances where id = %s""",
|
||||
(task.instance_id,),
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
if row is None:
|
||||
raise HandlerError(f"instance {task.instance_id} vanished")
|
||||
return Instance.model_validate(row)
|
||||
|
||||
|
||||
def _values_for(inst: Instance, entry: CatalogEntry) -> dict[str, Any]:
|
||||
"""Turn a catalog size into helm values."""
|
||||
size = entry.sizes.get(inst.size)
|
||||
if size is None:
|
||||
raise HandlerError(f"size {inst.size!r} not in catalog for {inst.service_type!r}")
|
||||
return {"replicaCount": size.replicas, "resources": size.resources}
|
||||
|
||||
|
||||
async def handle_provision(task: Task, deps: WorkerDeps) -> None:
|
||||
"""Install the release and mark the instance ready. Idempotent."""
|
||||
inst = await _load_instance(task, deps)
|
||||
|
||||
if inst.state is InstanceState.READY:
|
||||
# A previous attempt already finished; the crash was after the work, before the
|
||||
# bookkeeping. Nothing to do — and re-installing would be the bug.
|
||||
return
|
||||
|
||||
entry = deps.catalog.get(inst.service_type)
|
||||
if entry is None:
|
||||
raise HandlerError(f"unknown service_type {inst.service_type!r}")
|
||||
|
||||
# Best-effort CAS. It returning False means someone else moved the row; the helm call
|
||||
# below is idempotent either way, so this is bookkeeping, not a lock.
|
||||
await deps.instances.update_state(inst.id, InstanceState.REQUESTED, InstanceState.PROVISIONING)
|
||||
|
||||
await deps.provisioner.install(
|
||||
release=inst.release_name,
|
||||
ns=inst.namespace,
|
||||
entry=entry,
|
||||
values=_values_for(inst, entry),
|
||||
)
|
||||
|
||||
endpoint = f"http://{inst.release_name}.{inst.namespace}.svc.cluster.local"
|
||||
ok = await deps.instances.update_state(
|
||||
inst.id, InstanceState.PROVISIONING, InstanceState.READY, endpoint=endpoint
|
||||
)
|
||||
if ok:
|
||||
await deps.notifier.send(
|
||||
"instance.ready",
|
||||
f"instance {inst.id} is ready at {endpoint}",
|
||||
{"instance_id": str(inst.id), "team": inst.team, "service_type": inst.service_type},
|
||||
)
|
||||
|
||||
|
||||
async def handle_deprovision(task: Task, deps: WorkerDeps) -> None:
|
||||
"""Remove the release and mark the instance deleted. Idempotent."""
|
||||
inst = await _load_instance(task, deps)
|
||||
|
||||
if inst.state is InstanceState.DELETED:
|
||||
return
|
||||
|
||||
# `helm uninstall` of an already-gone release is not an error to us: the adapter
|
||||
# swallows not-found, because the desired state — no release — is already true.
|
||||
await deps.provisioner.uninstall(release=inst.release_name, ns=inst.namespace)
|
||||
await deps.instances.update_state(inst.id, InstanceState.DELETING, InstanceState.DELETED)
|
||||
|
||||
|
||||
async def handle_upgrade(task: Task, deps: WorkerDeps) -> None:
|
||||
"""Upgrade the release to the catalog's pinned version, then record it.
|
||||
|
||||
`instances.chart_version` is written only AFTER helm reports success. That column is
|
||||
what the day-2 work-list query compares against, so writing it optimistically would
|
||||
make the fleet look upgraded while it isn't.
|
||||
"""
|
||||
inst = await _load_instance(task, deps)
|
||||
entry = deps.catalog.get(inst.service_type)
|
||||
if entry is None:
|
||||
raise HandlerError(f"unknown service_type {inst.service_type!r}")
|
||||
|
||||
if inst.chart_version == entry.chart_version:
|
||||
return # already there
|
||||
|
||||
await deps.provisioner.install(
|
||||
release=inst.release_name,
|
||||
ns=inst.namespace,
|
||||
entry=entry,
|
||||
values=_values_for(inst, entry),
|
||||
)
|
||||
|
||||
async with deps.pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"update instances set chart_version = %s, updated_at = now() where id = %s",
|
||||
(entry.chart_version, inst.id),
|
||||
)
|
||||
|
||||
|
||||
async def handle_verify(task: Task, deps: WorkerDeps) -> None:
|
||||
"""Post-upgrade health probe. On failure, halt the whole rollout for this service type.
|
||||
|
||||
One column decides whether the fleet keeps rolling. The work-list query returns nothing
|
||||
while `rollout_state='halted'`, so a bad chart stops after the first tenant instead of
|
||||
after all of them. You clear it with SQL, deliberately: an automatic un-halt would just
|
||||
resume breaking things.
|
||||
"""
|
||||
inst = await _load_instance(task, deps)
|
||||
releases = {r.name for r in await deps.provisioner.list_releases()}
|
||||
|
||||
if inst.release_name in releases:
|
||||
return
|
||||
|
||||
async with deps.pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""insert into catalog_versions (service_type, rollout_state)
|
||||
values (%s, 'halted')
|
||||
on conflict (service_type) do update set rollout_state = 'halted'""",
|
||||
(inst.service_type,),
|
||||
)
|
||||
await deps.notifier.send(
|
||||
"rollout.halted",
|
||||
f"rollout halted for {inst.service_type}: {inst.release_name} failed verify",
|
||||
{"instance_id": str(inst.id), "team": inst.team, "service_type": inst.service_type},
|
||||
)
|
||||
raise HandlerError(f"verify failed for {inst.release_name}; rollout halted")
|
||||
|
||||
|
||||
HANDLERS: dict[TaskKind, Callable[[Task, WorkerDeps], Awaitable[None]]] = {
|
||||
TaskKind.PROVISION: handle_provision,
|
||||
TaskKind.DEPROVISION: handle_deprovision,
|
||||
TaskKind.UPGRADE: handle_upgrade,
|
||||
TaskKind.VERIFY: handle_verify,
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"""The claim loop.
|
||||
|
||||
Poll every 5 seconds. Claim while a semaphore slot is free. Run the handler. Report.
|
||||
That is the whole design, and the restraint is the point: LISTEN/NOTIFY would shave the
|
||||
latency, is fire-and-forget so it can never replace the poll anyway, is strictly extra
|
||||
code, and does not exist on pgbouncer's transaction pooler. The poll is not a placeholder
|
||||
for something better.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import signal
|
||||
import time
|
||||
|
||||
from opentelemetry import trace
|
||||
|
||||
from services.worker.deps import WorkerDeps
|
||||
from services.worker.handlers import HANDLERS
|
||||
from svcforge_core import obs
|
||||
from svcforge_core.adapters.clock import SystemClock
|
||||
from svcforge_core.adapters.helm import HelmProvisioner
|
||||
from svcforge_core.adapters.notify import LogNotifier
|
||||
from svcforge_core.domain.catalog import load_catalog
|
||||
from svcforge_core.domain.models import Task
|
||||
from svcforge_core.repo.db import make_pool
|
||||
from svcforge_core.repo.instances import InstanceRepo
|
||||
from svcforge_core.repo.tasks import TaskRepo
|
||||
from svcforge_core.settings import Settings, load_settings
|
||||
|
||||
log = obs.get_logger("svcforge.worker")
|
||||
|
||||
|
||||
async def _sleep_or_stop(stop: asyncio.Event, seconds: float) -> None:
|
||||
"""Sleep, but wake immediately on shutdown.
|
||||
|
||||
`await asyncio.sleep(5)` would make every SIGTERM cost up to five seconds of
|
||||
Kubernetes waiting on terminationGracePeriod for no reason.
|
||||
"""
|
||||
with contextlib.suppress(TimeoutError):
|
||||
await asyncio.wait_for(stop.wait(), timeout=seconds)
|
||||
|
||||
|
||||
async def _run_one(deps: WorkerDeps, task: Task, sem: asyncio.Semaphore) -> None:
|
||||
"""Run one task to a terminal report. Never lets an exception escape the TaskGroup."""
|
||||
try:
|
||||
# Every log line from here carries instance_id/task_id/team. Bound once, at claim,
|
||||
# rather than passed down: the alternative is threading three arguments through
|
||||
# every function that might log, and the first one anyone forgets is the one you
|
||||
# need at 3am.
|
||||
obs.bind_task_context(task.instance_id, task.id, team=task.team or "unknown")
|
||||
log.info("task claimed", kind=task.kind.value, attempt=task.attempts)
|
||||
obs.TASKS_CLAIMED.labels(kind=task.kind.value).inc()
|
||||
|
||||
handler = HANDLERS.get(task.kind)
|
||||
if handler is None:
|
||||
await deps.tasks.fail(task.id, f"no handler for {task.kind}", max_attempts=1)
|
||||
return
|
||||
|
||||
# Re-parent to the span that enqueued this task. Without the stored traceparent
|
||||
# the worker's span starts a brand-new trace, and the POST that caused the work
|
||||
# is in a different trace to the helm call that did it.
|
||||
ctx = obs.context_from_traceparent(task.traceparent)
|
||||
started = time.monotonic()
|
||||
with obs.tracer().start_as_current_span(
|
||||
f"task.{task.kind.value}",
|
||||
context=ctx,
|
||||
kind=trace.SpanKind.CONSUMER,
|
||||
) as span:
|
||||
span.set_attribute("task.id", task.id)
|
||||
span.set_attribute("task.kind", task.kind.value)
|
||||
span.set_attribute("instance.id", str(task.instance_id))
|
||||
try:
|
||||
await handler(task, deps)
|
||||
except asyncio.CancelledError:
|
||||
# CancelledError inherits from BaseException, so `except Exception` below
|
||||
# would never see it. Catch it only to release the claim, then ALWAYS
|
||||
# re-raise: swallowing it breaks cancellation for everyone above us.
|
||||
await deps.tasks.fail(task.id, "cancelled", max_attempts=deps.settings.max_attempts)
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.exception("task failed", kind=task.kind.value, error=str(exc))
|
||||
span.record_exception(exc)
|
||||
span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))
|
||||
obs.TASKS_FAILED.labels(kind=task.kind.value).inc()
|
||||
await deps.tasks.fail(task.id, str(exc), max_attempts=deps.settings.max_attempts)
|
||||
else:
|
||||
obs.PROVISION_TIME.observe(time.monotonic() - started)
|
||||
await deps.tasks.complete(task.id)
|
||||
finally:
|
||||
sem.release()
|
||||
|
||||
|
||||
async def run_worker(deps: WorkerDeps, stop: asyncio.Event) -> None:
|
||||
"""Claim and run until told to stop, then drain what is in flight.
|
||||
|
||||
Draining is what makes a rolling deploy invisible. Exiting the `async with` block
|
||||
awaits every in-flight handler, so a pod that is being replaced finishes the provision
|
||||
it already started instead of abandoning it half-done for the lease to clean up
|
||||
five minutes later.
|
||||
"""
|
||||
sem = asyncio.Semaphore(deps.settings.worker_concurrency)
|
||||
worker_id = deps.settings.worker_id
|
||||
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
while not stop.is_set():
|
||||
await sem.acquire()
|
||||
if stop.is_set():
|
||||
sem.release()
|
||||
break
|
||||
|
||||
try:
|
||||
task = await deps.tasks.claim(worker_id)
|
||||
except Exception:
|
||||
# A DB blip must not kill the worker; back off and try again.
|
||||
log.exception("claim failed")
|
||||
sem.release()
|
||||
await _sleep_or_stop(stop, deps.settings.poll_interval_s)
|
||||
continue
|
||||
|
||||
if task is None:
|
||||
sem.release()
|
||||
await _sleep_or_stop(stop, deps.settings.poll_interval_s)
|
||||
continue
|
||||
|
||||
tg.create_task(_run_one(deps, task, sem))
|
||||
# TaskGroup.__aexit__ awaited the in-flight handlers. Now it is safe to exit 0.
|
||||
|
||||
|
||||
async def _amain() -> None:
|
||||
settings: Settings = load_settings()
|
||||
|
||||
# Before anything else: nothing logged above this line is structured, and the metrics
|
||||
# the SvcforgeTaskFailed / SvcforgeProvisionSlow alerts query do not exist until the
|
||||
# registry is up.
|
||||
obs.setup("svcforge-worker", settings)
|
||||
obs.start_metrics_server(settings.metrics_port)
|
||||
|
||||
pool = make_pool(settings.pg_dsn.unicode_string(), settings.pool_min_size, settings.pool_max_size)
|
||||
await pool.open(wait=True)
|
||||
|
||||
deps = WorkerDeps(
|
||||
pool=pool,
|
||||
instances=InstanceRepo(pool),
|
||||
tasks=TaskRepo(pool),
|
||||
provisioner=HelmProvisioner(helm_bin=settings.helm_bin, timeout_s=int(settings.helm_timeout_s)),
|
||||
notifier=LogNotifier(),
|
||||
clock=SystemClock(),
|
||||
catalog=load_catalog(settings.catalog_path),
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
stop = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
# add_signal_handler, NOT signal.signal. signal.signal runs the handler at an
|
||||
# arbitrary bytecode boundary on whatever thread the C-level handler lands on,
|
||||
# and the event loop will not notice until its next timer fires. This one is
|
||||
# loop-safe: the callback runs as a normal loop callback.
|
||||
loop.add_signal_handler(sig, stop.set)
|
||||
|
||||
try:
|
||||
await run_worker(deps, stop)
|
||||
finally:
|
||||
await pool.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""One asyncio.run, at the top, never nested."""
|
||||
asyncio.run(_amain())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,133 @@
|
||||
"""The real thing: a real helm, against a real cluster, installing a real chart.
|
||||
|
||||
Everything else in this suite runs against a FakeProvisioner, which is what makes the
|
||||
worker tests take milliseconds. That trade has one cost, and this file is the payment: the
|
||||
fake proves the *worker* is correct, and cannot prove the *adapter* is. Argv order, the
|
||||
`--wait` flag, chart resolution, RBAC, whether `upgrade --install` is genuinely idempotent
|
||||
against a live release — none of it is exercised by a fake that returns None.
|
||||
|
||||
So one test does it for real, exactly once. It is marked `e2e` and excluded from `make test`
|
||||
and from every pre-commit run; CI runs `-m e2e` in a job that has a cluster.
|
||||
|
||||
kind create cluster --name svcforge
|
||||
uv run pytest -m e2e -q
|
||||
|
||||
It skips (does not fail) with no cluster, because a laptop without kubectl is not a
|
||||
regression.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from svcforge_core.adapters.helm import HelmProvisioner
|
||||
from svcforge_core.domain.models import CatalogEntry, SizeSpec
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.slow]
|
||||
|
||||
NAMESPACE = "svcforge-e2e"
|
||||
|
||||
# A chart with no dependencies, no PVCs and no image pulls worth waiting on. The point is
|
||||
# to exercise the adapter, not to wait four minutes for Elasticsearch.
|
||||
ENTRY = CatalogEntry(
|
||||
service_type="podinfo",
|
||||
chart="oci://ghcr.io/stefanprodan/charts/podinfo",
|
||||
chart_version="6.7.1",
|
||||
sizes={"small": SizeSpec(replicas=1, resources={})},
|
||||
)
|
||||
|
||||
|
||||
def _cluster_reachable() -> bool:
|
||||
if not shutil.which("helm") or not shutil.which("kubectl"):
|
||||
return False
|
||||
out = subprocess.run(
|
||||
["kubectl", "cluster-info"],
|
||||
capture_output=True,
|
||||
timeout=15,
|
||||
)
|
||||
return out.returncode == 0
|
||||
|
||||
|
||||
requires_cluster = pytest.mark.skipif(
|
||||
not _cluster_reachable(),
|
||||
reason="no reachable cluster: `kind create cluster --name svcforge`",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def namespace() -> str:
|
||||
subprocess.run(
|
||||
["kubectl", "create", "namespace", NAMESPACE],
|
||||
capture_output=True,
|
||||
check=False, # already exists is fine — the whole system is idempotent or it is broken
|
||||
)
|
||||
return NAMESPACE
|
||||
|
||||
|
||||
@requires_cluster
|
||||
async def test_install_is_idempotent_against_a_real_cluster(namespace: str) -> None:
|
||||
"""Install twice. Get one release. This is the claim the fake cannot make for us."""
|
||||
release = f"e2e-podinfo-{uuid.uuid4().hex[:8]}"
|
||||
prov = HelmProvisioner(timeout_s=300)
|
||||
|
||||
try:
|
||||
await prov.install(release=release, ns=namespace, entry=ENTRY, values={"replicaCount": 1})
|
||||
|
||||
releases = [r for r in await prov.list_releases() if r.name == release]
|
||||
assert len(releases) == 1, f"expected exactly one release, got {releases}"
|
||||
|
||||
# The redelivery, for real: same task, same deterministic release name, run again.
|
||||
# `helm upgrade --install` must converge, not duplicate and not error.
|
||||
await prov.install(release=release, ns=namespace, entry=ENTRY, values={"replicaCount": 1})
|
||||
|
||||
releases = [r for r in await prov.list_releases() if r.name == release]
|
||||
assert len(releases) == 1, "second install created a second release: not idempotent"
|
||||
|
||||
# --wait means the DB writing `ready` is telling the truth.
|
||||
out = subprocess.run(
|
||||
[
|
||||
"kubectl",
|
||||
"get",
|
||||
"deploy",
|
||||
"-n",
|
||||
namespace,
|
||||
"-l",
|
||||
f"app.kubernetes.io/instance={release}",
|
||||
"-o",
|
||||
"jsonpath={.items[*].status.readyReplicas}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
assert out.stdout.strip() == "1", f"--wait returned before the pod was ready: {out.stdout!r}"
|
||||
finally:
|
||||
await prov.uninstall(release=release, ns=namespace)
|
||||
|
||||
|
||||
@requires_cluster
|
||||
async def test_uninstall_of_a_missing_release_is_not_an_error(namespace: str) -> None:
|
||||
"""The desired state — no release — is already true. That is success, not failure."""
|
||||
prov = HelmProvisioner(timeout_s=120)
|
||||
await prov.uninstall(release=f"never-existed-{uuid.uuid4().hex[:8]}", ns=namespace)
|
||||
|
||||
|
||||
@requires_cluster
|
||||
async def test_real_helm_timeout_leaves_no_helm_behind(namespace: str) -> None:
|
||||
"""A deadline that does not kill helm is a deadline that lets helm keep mutating."""
|
||||
prov = HelmProvisioner(timeout_s=1) # cannot possibly finish
|
||||
release = f"e2e-timeout-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
with pytest.raises((TimeoutError, Exception)):
|
||||
await prov.install(release=release, ns=namespace, entry=ENTRY, values={})
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
out = subprocess.run(["pgrep", "-f", f"helm.*{release}"], capture_output=True, text=True)
|
||||
assert out.stdout.strip() == "", "helm survived its own timeout and is still touching the cluster"
|
||||
|
||||
await prov.uninstall(release=release, ns=namespace)
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
"""Test doubles. The second implementation that makes each Protocol worth having.
|
||||
|
||||
These are fakes, not mocks: they have working behaviour (a dict of releases, a monotonic
|
||||
counter) and are asserted against by their *state*, not by "was this method called with
|
||||
these arguments". A mock proves your mock does what you told it to.
|
||||
|
||||
They live here, outside `svcforge_core`, so that no production import path can reach them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from svcforge_core.adapters.clock import Clock
|
||||
from svcforge_core.adapters.helm import HelmError, ReleaseInfo
|
||||
from svcforge_core.adapters.redis import RateLimitResult
|
||||
from svcforge_core.domain.models import CatalogEntry, Instance
|
||||
|
||||
|
||||
class FakeProvisioner:
|
||||
"""Dict of release -> ReleaseInfo. Same signatures. Optional fail_on / delay for tests.
|
||||
|
||||
This is what makes the worker's tests run in milliseconds with no cluster.
|
||||
"""
|
||||
|
||||
def __init__(self, *, delay: float = 0.0, fail_on: set[str] | None = None) -> None:
|
||||
"""`delay`: seconds `install` sleeps, to hold a task in flight (SIGTERM drain tests).
|
||||
|
||||
`fail_on`: substring match against the release name, not equality — release names
|
||||
carry a random suffix (`platform-elasticsearch-1a2b3c4d`), so a test that wants "every
|
||||
elasticsearch install fails" cannot name the release up front.
|
||||
"""
|
||||
self.delay = delay
|
||||
self.fail_on = fail_on or set()
|
||||
|
||||
self.releases: dict[str, ReleaseInfo] = {}
|
||||
self.installed: list[str] = []
|
||||
self.uninstalled: list[str] = []
|
||||
|
||||
# Concurrency accounting, so a test can assert the worker's semaphore actually caps.
|
||||
self.in_flight = 0
|
||||
self.max_concurrent = 0
|
||||
|
||||
def _should_fail(self, release: str) -> bool:
|
||||
return any(needle in release for needle in self.fail_on)
|
||||
|
||||
async def install(self, release: str, ns: str, entry: CatalogEntry, values: dict[str, Any]) -> None:
|
||||
self.in_flight += 1
|
||||
self.max_concurrent = max(self.max_concurrent, self.in_flight)
|
||||
try:
|
||||
if self.delay:
|
||||
await asyncio.sleep(self.delay)
|
||||
if self._should_fail(release):
|
||||
# Same type the real adapter raises, so handlers cannot pass here and fail in prod.
|
||||
raise HelmError(f"fake: install of {release} failed on purpose")
|
||||
self.releases[release] = ReleaseInfo(
|
||||
name=release,
|
||||
namespace=ns,
|
||||
chart=f"{entry.chart}-{entry.chart_version}",
|
||||
status="deployed",
|
||||
revision=self.releases[release].revision + 1 if release in self.releases else 1,
|
||||
app_version=entry.chart_version,
|
||||
)
|
||||
# Appended only on success: a failed install must not look installed.
|
||||
self.installed.append(release)
|
||||
finally:
|
||||
self.in_flight -= 1
|
||||
|
||||
async def uninstall(self, release: str, ns: str) -> None:
|
||||
"""Absent release is not an error: the desired state is already true."""
|
||||
self.releases.pop(release, None)
|
||||
self.uninstalled.append(release)
|
||||
|
||||
async def list_releases(self) -> list[ReleaseInfo]:
|
||||
return list(self.releases.values())
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""Time under test control. No sleeping, no monkeypatching the stdlib."""
|
||||
|
||||
def __init__(self, start: datetime) -> None:
|
||||
if start.tzinfo is None:
|
||||
raise ValueError("FakeClock needs an aware datetime; a naive start defeats the point")
|
||||
self._now = start
|
||||
|
||||
def now(self) -> datetime:
|
||||
return self._now
|
||||
|
||||
def advance(self, delta: timedelta) -> None:
|
||||
"""Move forward. Only forward — a clock that goes backwards is a different bug entirely."""
|
||||
if delta < timedelta(0):
|
||||
raise ValueError("FakeClock cannot go backwards")
|
||||
self._now += delta
|
||||
|
||||
|
||||
class FakeNotifier:
|
||||
"""Records what it was asked to send. Never leaves the process."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[tuple[str, str, dict[str, str]]] = []
|
||||
|
||||
async def send(self, event: str, message: str, fields: dict[str, str] | None = None) -> None:
|
||||
self.sent.append((event, message, fields or {}))
|
||||
|
||||
def events(self) -> list[str]:
|
||||
return [event for event, _, _ in self.sent]
|
||||
|
||||
|
||||
# --- Redis (Module 10) ------------------------------------------------------------------
|
||||
#
|
||||
# Three fakes, and each one is the second implementation that earns its Protocol. They also
|
||||
# earn their keep against the budget: Upstash's free tier is 500K commands/month, so a test
|
||||
# that wants a thousand rate-limit checks runs them here and spends nothing. Real Upstash
|
||||
# is reserved for the handful of `@pytest.mark.slow` tests that prove the wire protocol,
|
||||
# the Lua, and the command count.
|
||||
#
|
||||
# `down=True` is the interesting knob. Every real class degrades internally rather than
|
||||
# raising, so these degrade the same way — a fake that raises when the real one returns a
|
||||
# safe default would let a caller ship a `try/except` that production never exercises.
|
||||
|
||||
|
||||
class FakeRateLimiter:
|
||||
"""In-memory fixed window. Same semantics as the Lua, none of the network.
|
||||
|
||||
Counts `commands` so a test can assert the one-command-per-check budget without a
|
||||
server, and takes a Clock so a window rollover is an `advance()` rather than a sleep.
|
||||
"""
|
||||
|
||||
def __init__(self, limit: int, window_s: int, clock: Clock, *, down: bool = False) -> None:
|
||||
self.limit = limit
|
||||
self.window_s = window_s
|
||||
self.clock = clock
|
||||
self.down = down
|
||||
self.commands = 0
|
||||
self.counts: dict[str, int] = {}
|
||||
|
||||
async def check(self, team: str) -> RateLimitResult:
|
||||
window = int(self.clock.now().timestamp()) // self.window_s
|
||||
reset_at = datetime.fromtimestamp((window + 1) * self.window_s, tz=UTC)
|
||||
self.commands += 1
|
||||
if self.down:
|
||||
# Fails OPEN, exactly like the real one. A limiter that refused here would make
|
||||
# "Redis is down" indistinguishable from "you are over quota".
|
||||
return RateLimitResult(
|
||||
allowed=True, limit=self.limit, remaining=self.limit, reset_at=reset_at, degraded=True
|
||||
)
|
||||
key = f"rl:{team}:{window}"
|
||||
n = self.counts.get(key, 0) + 1
|
||||
self.counts[key] = n
|
||||
return RateLimitResult(
|
||||
allowed=n <= self.limit,
|
||||
limit=self.limit,
|
||||
remaining=max(0, self.limit - n),
|
||||
reset_at=reset_at,
|
||||
)
|
||||
|
||||
|
||||
class FakeIdempotencyStore:
|
||||
"""A dict with SET-NX semantics. No TTL: no test outlives one."""
|
||||
|
||||
def __init__(self, *, down: bool = False) -> None:
|
||||
self.down = down
|
||||
self.claims: dict[str, UUID] = {}
|
||||
|
||||
async def claim(self, key: str, instance_id: UUID) -> UUID | None:
|
||||
if self.down:
|
||||
# Falls through to the DB, where `instances.release_name` is UNIQUE. The real
|
||||
# guarantee was never here.
|
||||
return None
|
||||
existing = self.claims.get(key)
|
||||
if existing is not None:
|
||||
return existing
|
||||
self.claims[key] = instance_id
|
||||
return None
|
||||
|
||||
|
||||
class FakeInstanceCache:
|
||||
"""A dict, plus hit/miss accounting so a test can assert the second read never hits the DB."""
|
||||
|
||||
def __init__(self, *, down: bool = False) -> None:
|
||||
self.down = down
|
||||
self.entries: dict[UUID, Instance] = {}
|
||||
self.hits = 0
|
||||
self.misses = 0
|
||||
self.invalidations: list[UUID] = []
|
||||
|
||||
async def get(self, instance_id: UUID) -> Instance | None:
|
||||
if self.down:
|
||||
self.misses += 1
|
||||
return None
|
||||
inst = self.entries.get(instance_id)
|
||||
if inst is None:
|
||||
self.misses += 1
|
||||
return None
|
||||
self.hits += 1
|
||||
return inst
|
||||
|
||||
async def put(self, inst: Instance) -> None:
|
||||
if self.down:
|
||||
return
|
||||
self.entries[inst.id] = inst
|
||||
|
||||
async def invalidate(self, instance_id: UUID) -> None:
|
||||
self.invalidations.append(instance_id)
|
||||
if self.down:
|
||||
return
|
||||
self.entries.pop(instance_id, None)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Integration fixtures: a real Postgres, real SQL, no mocks.
|
||||
|
||||
The DB is never mocked. A mocked database proves your mock returns what you told it to.
|
||||
Every bug worth catching here — SKIP LOCKED semantics, CAS rowcounts, transaction
|
||||
rollback, `timestamptz` round-tripping — lives in the part a mock replaces.
|
||||
|
||||
Two ways to get a database, in priority order:
|
||||
|
||||
1. `SVCFORGE_TEST_DSN` in the environment — an already-running Postgres. This is the
|
||||
path on a host that has no Docker daemon (for example one whose containerd belongs
|
||||
to a Kubernetes kubelet, where installing Docker would evict the runtime).
|
||||
2. testcontainers, which starts `postgres:16-alpine` and throws it away after. This is
|
||||
the CI path.
|
||||
|
||||
Same tests either way.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from svcforge_core.repo.db import DictPool, make_pool
|
||||
|
||||
MIGRATIONS = Path(__file__).resolve().parents[2] / "migrations"
|
||||
|
||||
|
||||
def _apply_migrations(dsn: str) -> None:
|
||||
"""Run every migration in lexical order, one transaction each.
|
||||
|
||||
No `create table if not exists` and no reset: every caller hands this a database that
|
||||
was created moments ago (a per-process clone, or a fresh container), so the schema is
|
||||
always empty and the migrations always apply cleanly from zero.
|
||||
"""
|
||||
with psycopg.connect(dsn, autocommit=True) as conn:
|
||||
for path in sorted(MIGRATIONS.glob("*.sql")):
|
||||
with conn.transaction(), conn.cursor() as cur:
|
||||
cur.execute(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _private_database(admin_dsn: str) -> Iterator[str]:
|
||||
"""Clone a scratch database for THIS pytest process only, and drop it after.
|
||||
|
||||
The `pool` fixture truncates between tests. That is correct within one process and
|
||||
catastrophic across several: two pytest runs sharing a database truncate each other's
|
||||
rows mid-test, and the failures look like real bugs in the code under test rather than
|
||||
like the harness eating itself. Isolating per process makes concurrent runs
|
||||
(several agents, or pytest-xdist -n auto) simply work.
|
||||
"""
|
||||
name = f"svcforge_test_{os.getpid()}"
|
||||
parsed = urlsplit(admin_dsn)
|
||||
|
||||
with psycopg.connect(admin_dsn, autocommit=True) as conn:
|
||||
conn.execute(f'drop database if exists "{name}"')
|
||||
conn.execute(f'create database "{name}"')
|
||||
|
||||
dsn = urlunsplit(parsed._replace(path=f"/{name}"))
|
||||
try:
|
||||
_apply_migrations(dsn)
|
||||
yield dsn
|
||||
finally:
|
||||
with psycopg.connect(admin_dsn, autocommit=True) as conn:
|
||||
# Boot any lingering connections, or the drop blocks forever.
|
||||
conn.execute(
|
||||
"select pg_terminate_backend(pid) from pg_stat_activity where datname = %s",
|
||||
(name,),
|
||||
)
|
||||
conn.execute(f'drop database if exists "{name}"')
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def pg_dsn() -> Iterator[str]:
|
||||
"""A migrated Postgres, private to this process, from the environment or a container."""
|
||||
env_dsn = os.getenv("SVCFORGE_TEST_DSN")
|
||||
if env_dsn:
|
||||
yield from _private_database(env_dsn)
|
||||
return
|
||||
|
||||
try:
|
||||
from testcontainers.postgres import PostgresContainer
|
||||
except ImportError: # pragma: no cover - CI always has it
|
||||
pytest.skip("set SVCFORGE_TEST_DSN or install testcontainers")
|
||||
|
||||
# A container is already private to this process; no need to clone inside it.
|
||||
with PostgresContainer("postgres:16-alpine", driver=None) as pg:
|
||||
dsn = pg.get_connection_url()
|
||||
_apply_migrations(dsn)
|
||||
yield dsn
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def pool(pg_dsn: str) -> AsyncIterator[DictPool]:
|
||||
"""A clean database and an open pool, per test.
|
||||
|
||||
max_size=60 is not a performance choice. test_skip_locked_claims_each_task_exactly_once
|
||||
races 50 concurrent claims; a pool smaller than that serialises them at the pool
|
||||
instead of at the database, and the test passes while proving nothing.
|
||||
"""
|
||||
async with await psycopg.AsyncConnection.connect(pg_dsn, autocommit=True) as conn:
|
||||
# catalog_versions joins the list because a halted rollout is sticky by design:
|
||||
# leave it behind and every later test in the session sees an empty work list.
|
||||
await conn.execute("truncate tasks, instances, catalog_versions restart identity cascade")
|
||||
|
||||
p = make_pool(pg_dsn, min_size=1, max_size=60)
|
||||
await p.open(wait=True)
|
||||
try:
|
||||
yield p
|
||||
finally:
|
||||
await p.close()
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Builders for integration tests. Keeps the tests about the behaviour under test."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from svcforge_core.domain.models import Instance
|
||||
from svcforge_core.domain.states import InstanceState
|
||||
from svcforge_core.repo.db import DictPool
|
||||
from svcforge_core.repo.instances import InstanceRepo
|
||||
|
||||
|
||||
def build_instance(
|
||||
team: str = "platform",
|
||||
service_type: str = "elasticsearch",
|
||||
size: str = "small",
|
||||
state: InstanceState = InstanceState.REQUESTED,
|
||||
chart_version: str = "21.3.19",
|
||||
) -> Instance:
|
||||
"""An Instance with a deterministic release_name, as the domain requires."""
|
||||
iid = uuid4()
|
||||
now = datetime.now(UTC)
|
||||
return Instance(
|
||||
id=iid,
|
||||
team=team,
|
||||
service_type=service_type,
|
||||
size=size,
|
||||
state=state,
|
||||
namespace=f"tenant-{team}",
|
||||
release_name=f"{team}-{service_type}-{str(iid)[:8]}",
|
||||
chart_version=chart_version,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
async def make_instance(pool: DictPool, **kwargs: object) -> UUID:
|
||||
"""Insert an instance and return its id."""
|
||||
inst = build_instance(**kwargs) # type: ignore[arg-type]
|
||||
repo = InstanceRepo(pool)
|
||||
async with pool.connection() as conn:
|
||||
await repo.create(conn, inst)
|
||||
return inst.id
|
||||
@@ -0,0 +1,582 @@
|
||||
"""API integration tests: real app, real Postgres, real JWTs. No mocks.
|
||||
|
||||
`httpx.ASGITransport` calls the app in-process — no uvicorn, no socket, no port to race
|
||||
over. It exercises the same routing, dependency resolution and lifespan a real request
|
||||
would; only the TCP hop is gone.
|
||||
|
||||
The JWTs here are real RS256 tokens signed by a key generated in-fixture and served
|
||||
through a PyJWKClient whose cache is pre-seeded. That is deliberate: overriding
|
||||
`get_current_team` would leave the audience check, the issuer check and the algorithm
|
||||
allow-list — the parts worth having — completely untested.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from fastapi import FastAPI
|
||||
|
||||
from services.api.main import create_app
|
||||
from services.api.routes.instances import release_name_for
|
||||
from svcforge_core.repo.db import DictPool
|
||||
from svcforge_core.settings import Settings
|
||||
|
||||
CATALOG = Path(__file__).resolve().parents[2] / "catalog.yaml"
|
||||
ISSUER = "https://issuer.test/realms/svcforge"
|
||||
AUDIENCE = "svcforge"
|
||||
KID = "test-key-1"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- key material
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def rsa_key() -> rsa.RSAPrivateKey:
|
||||
"""One 2048-bit key for the whole session. Generating it per test costs ~100ms each."""
|
||||
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def jwks(rsa_key: rsa.RSAPrivateKey) -> dict[str, Any]:
|
||||
"""The public half, as a JWKS document — exactly what Keycloak/Zitadel would serve."""
|
||||
algo = jwt.algorithms.RSAAlgorithm
|
||||
key_dict: dict[str, Any] = json.loads(algo.to_jwk(rsa_key.public_key()))
|
||||
key_dict.update({"kid": KID, "alg": "RS256", "use": "sig"})
|
||||
return {"keys": [key_dict]}
|
||||
|
||||
|
||||
def make_token(
|
||||
rsa_key: rsa.RSAPrivateKey,
|
||||
team: str = "platform",
|
||||
*,
|
||||
audience: str = AUDIENCE,
|
||||
issuer: str = ISSUER,
|
||||
expires_in: timedelta = timedelta(minutes=5),
|
||||
algorithm: str = "RS256",
|
||||
) -> str:
|
||||
"""Sign a token. Defaults are valid; every argument exists so a test can invalidate one."""
|
||||
now = datetime.now(UTC)
|
||||
claims: dict[str, Any] = {
|
||||
"sub": f"user@{team}",
|
||||
"team": team,
|
||||
"aud": audience,
|
||||
"iss": issuer,
|
||||
"iat": now,
|
||||
"exp": now + expires_in,
|
||||
}
|
||||
key = rsa_key if algorithm == "RS256" else "x" * 32 # HS256 needs >=32 bytes to sign quietly
|
||||
return jwt.encode(claims, key, algorithm=algorithm, headers={"kid": KID})
|
||||
|
||||
|
||||
def auth(token: str) -> dict[str, str]:
|
||||
"""An Authorization header."""
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- the app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(pg_dsn: str) -> Settings:
|
||||
"""Settings pointed at the throwaway database. The whole reason create_app is a factory.
|
||||
|
||||
`jwks_url=None` so lifespan builds no real client and attempts no warm-up: the `app`
|
||||
fixture injects a pre-seeded one straight after. Set it to a fake URL and every test
|
||||
pays a DNS timeout resolving a host that does not exist — ~4s each, ~2min a run. The
|
||||
warm-up path itself is covered by test_lifespan_survives_an_unreachable_jwks.
|
||||
"""
|
||||
return Settings(
|
||||
pg_dsn=pg_dsn, # type: ignore[arg-type]
|
||||
jwks_url=None,
|
||||
jwt_audience=AUDIENCE,
|
||||
jwt_issuer=ISSUER,
|
||||
catalog_path=CATALOG,
|
||||
pool_min_size=1,
|
||||
pool_max_size=5,
|
||||
)
|
||||
|
||||
|
||||
class _FrozenJWKClient:
|
||||
"""A PyJWKClient with its cache pre-seeded and its network path removed.
|
||||
|
||||
Stands in for the real client at the seam `get_current_team` uses. It resolves a kid to
|
||||
a key exactly as PyJWKClient does; it just cannot reach out to an identity provider
|
||||
that does not exist in a test run.
|
||||
"""
|
||||
|
||||
def __init__(self, jwks: dict[str, Any]) -> None:
|
||||
self._keys = jwt.PyJWKSet.from_dict(jwks)
|
||||
|
||||
def get_signing_key_from_jwt(self, token: str) -> jwt.PyJWK:
|
||||
"""Resolve the token's `kid` against the key set. Raises if it is unknown."""
|
||||
kid = jwt.get_unverified_header(token)["kid"]
|
||||
for key in self._keys.keys:
|
||||
if key.key_id == kid:
|
||||
return key
|
||||
raise jwt.exceptions.PyJWKClientError(f"unable to find key {kid}")
|
||||
|
||||
def get_signing_keys(self) -> list[jwt.PyJWK]:
|
||||
"""Warm-up hook, called by lifespan."""
|
||||
return list(self._keys.keys)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def app(settings: Settings, pool: DictPool, jwks: dict[str, Any]) -> AsyncIterator[FastAPI]:
|
||||
"""A live app with its lifespan run.
|
||||
|
||||
Depends on `pool` only for its truncate-per-test side effect; the app opens its own.
|
||||
"""
|
||||
application = create_app(settings)
|
||||
# ASGITransport does NOT run lifespan — it only speaks the `http` scope. Drive the
|
||||
# lifespan by hand rather than reaching for asgi-lifespan: without this the pool is
|
||||
# never opened, app.state.pool does not exist, and every DB test dies on AttributeError.
|
||||
async with application.router.lifespan_context(application):
|
||||
# Swap the real (network-bound) JWKS client for one whose cache is pre-seeded.
|
||||
application.state.jwks_client = _FrozenJWKClient(jwks)
|
||||
yield application
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(app: FastAPI) -> AsyncIterator[httpx.AsyncClient]:
|
||||
"""An httpx client wired straight into the ASGI app."""
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def token(rsa_key: rsa.RSAPrivateKey) -> str:
|
||||
"""A valid token for team `platform`."""
|
||||
return make_token(rsa_key, "platform")
|
||||
|
||||
|
||||
async def _fetch_all(pool: DictPool, sql: str, args: tuple[Any, ...] = ()) -> list[Any]:
|
||||
"""Read rows back out of the database, bypassing the API entirely."""
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(sql, args)
|
||||
return list(await cur.fetchall())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- create
|
||||
|
||||
|
||||
async def test_post_returns_202_and_location(client: httpx.AsyncClient, token: str) -> None:
|
||||
"""202 Accepted, not 201: nothing is provisioned yet. Location points at the poll target."""
|
||||
resp = await client.post(
|
||||
"/v1/instances",
|
||||
headers=auth(token),
|
||||
json={"service_type": "elasticsearch", "size": "small"},
|
||||
)
|
||||
assert resp.status_code == 202, resp.text
|
||||
body = resp.json()
|
||||
assert resp.headers["location"] == f"/v1/instances/{body['id']}"
|
||||
assert body["state"] == "requested"
|
||||
assert body["service_type"] == "elasticsearch"
|
||||
# Pinned from catalog.yaml at creation time, not echoed from the request.
|
||||
assert body["chart_version"] == "21.3.15"
|
||||
assert body["endpoint"] is None
|
||||
# The response model is an allow-list: placement details stay off the wire.
|
||||
assert "team" not in body and "namespace" not in body and "release_name" not in body
|
||||
|
||||
|
||||
async def test_post_commits_instance_and_task_together(
|
||||
client: httpx.AsyncClient, pool: DictPool, token: str
|
||||
) -> None:
|
||||
"""The point of the whole module: both rows exist, or neither does."""
|
||||
resp = await client.post(
|
||||
"/v1/instances",
|
||||
headers=auth(token),
|
||||
json={"service_type": "redis", "size": "small"},
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
iid = UUID(resp.json()["id"])
|
||||
|
||||
rows = await _fetch_all(pool, "select * from instances where id = %s", (iid,))
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["state"] == "requested"
|
||||
assert rows[0]["team"] == "platform"
|
||||
assert rows[0]["namespace"] == "tenant-platform"
|
||||
# The idempotency anchor, and it is unique in the schema.
|
||||
assert rows[0]["release_name"] == release_name_for("platform", "redis", iid)
|
||||
|
||||
tasks = await _fetch_all(pool, "select * from tasks where instance_id = %s", (iid,))
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]["kind"] == "provision"
|
||||
assert tasks[0]["state"] == "queued"
|
||||
assert tasks[0]["attempts"] == 0
|
||||
|
||||
|
||||
async def test_release_name_is_deterministic_and_unique(
|
||||
client: httpx.AsyncClient, pool: DictPool, token: str
|
||||
) -> None:
|
||||
"""Two requests for the same service_type get distinct releases; the name is pure."""
|
||||
ids: list[UUID] = []
|
||||
for _ in range(2):
|
||||
resp = await client.post(
|
||||
"/v1/instances", headers=auth(token), json={"service_type": "redis", "size": "small"}
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
ids.append(UUID(resp.json()["id"]))
|
||||
|
||||
names = await _fetch_all(pool, "select release_name from instances order by created_at")
|
||||
assert len({r["release_name"] for r in names}) == 2
|
||||
# Pure function of (team, service_type, id): recomputing on a worker retry gives the
|
||||
# same answer, so `helm upgrade --install` lands on the same release.
|
||||
assert release_name_for("platform", "redis", ids[0]) == release_name_for("platform", "redis", ids[0])
|
||||
|
||||
|
||||
async def test_ttl_days_sets_expires_at(client: httpx.AsyncClient, pool: DictPool, token: str) -> None:
|
||||
"""ttl_days is the tenant-facing knob; expires_at is what the reconciler sweeps."""
|
||||
resp = await client.post(
|
||||
"/v1/instances",
|
||||
headers=auth(token),
|
||||
json={"service_type": "redis", "size": "small", "ttl_days": 7},
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
rows = await _fetch_all(
|
||||
pool, "select expires_at from instances where id = %s", (UUID(resp.json()["id"]),)
|
||||
)
|
||||
delta = rows[0]["expires_at"] - datetime.now(UTC)
|
||||
assert timedelta(days=6, hours=23) < delta <= timedelta(days=7)
|
||||
|
||||
|
||||
async def test_no_expires_at_without_ttl(client: httpx.AsyncClient, pool: DictPool, token: str) -> None:
|
||||
"""No TTL means no expiry. A default TTL would delete someone's database by surprise."""
|
||||
resp = await client.post(
|
||||
"/v1/instances", headers=auth(token), json={"service_type": "redis", "size": "small"}
|
||||
)
|
||||
rows = await _fetch_all(
|
||||
pool, "select expires_at from instances where id = %s", (UUID(resp.json()["id"]),)
|
||||
)
|
||||
assert rows[0]["expires_at"] is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- validation
|
||||
|
||||
|
||||
async def test_unknown_size_is_rejected(client: httpx.AsyncClient, pool: DictPool, token: str) -> None:
|
||||
"""size='enormous' is not in the catalog. Nothing is written."""
|
||||
resp = await client.post(
|
||||
"/v1/instances",
|
||||
headers=auth(token),
|
||||
json={"service_type": "elasticsearch", "size": "enormous"},
|
||||
)
|
||||
# The spec says 422 for an unknown size and 404 for an unknown service_type; its
|
||||
# summary line collapses both into "404 if unknown". Either is defensible; what is not
|
||||
# defensible is writing the row. Assert the contract that matters and allow both codes.
|
||||
assert resp.status_code in (404, 422), resp.text
|
||||
assert await _fetch_all(pool, "select 1 from instances") == []
|
||||
assert await _fetch_all(pool, "select 1 from tasks") == []
|
||||
|
||||
|
||||
async def test_unknown_service_type_is_404(client: httpx.AsyncClient, token: str) -> None:
|
||||
"""A service_type the catalog has never heard of is a resource that does not exist."""
|
||||
resp = await client.post(
|
||||
"/v1/instances", headers=auth(token), json={"service_type": "mongodb", "size": "small"}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["code"] == "unknown_service_type"
|
||||
|
||||
|
||||
async def test_ttl_out_of_range_is_422(client: httpx.AsyncClient, token: str) -> None:
|
||||
"""ttl_days has a schema bound (1..30); pydantic rejects it before the handler runs."""
|
||||
resp = await client.post(
|
||||
"/v1/instances",
|
||||
headers=auth(token),
|
||||
json={"service_type": "redis", "size": "small", "ttl_days": 999},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- authn
|
||||
|
||||
|
||||
async def test_missing_authorization_is_401(client: httpx.AsyncClient) -> None:
|
||||
"""No header -> 401, NOT 403.
|
||||
|
||||
This is the HTTPBearer(auto_error=True) trap: FastAPI's default answers a missing
|
||||
header with 403, which tells the client "you are known and forbidden" when the truth
|
||||
is "you never said who you are". deps.py passes auto_error=False for exactly this.
|
||||
"""
|
||||
resp = await client.post("/v1/instances", json={"service_type": "redis", "size": "small"})
|
||||
assert resp.status_code == 401
|
||||
assert resp.headers.get("www-authenticate") == "Bearer"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "case"),
|
||||
[
|
||||
({"audience": "some-other-api"}, "wrong audience: a token minted for another service"),
|
||||
({"issuer": "https://evil.test/"}, "wrong issuer"),
|
||||
({"expires_in": timedelta(minutes=-5)}, "expired"),
|
||||
({"algorithm": "HS256"}, "algorithm confusion: signed with HMAC, not RS256"),
|
||||
],
|
||||
)
|
||||
async def test_bad_tokens_are_401(
|
||||
client: httpx.AsyncClient, rsa_key: rsa.RSAPrivateKey, kwargs: dict[str, Any], case: str
|
||||
) -> None:
|
||||
"""Every way a token can be wrong produces the same opaque 401.
|
||||
|
||||
Identical bodies matter: a caller who can tell "expired" from "bad signature" from
|
||||
"wrong audience" has an oracle to tune a forgery against.
|
||||
"""
|
||||
resp = await client.get("/v1/instances", headers=auth(make_token(rsa_key, **kwargs)))
|
||||
assert resp.status_code == 401, f"{case} should be rejected"
|
||||
assert resp.json() == {"code": "unauthorized", "message": "invalid or missing credentials"}
|
||||
|
||||
|
||||
async def test_garbage_token_is_401(client: httpx.AsyncClient) -> None:
|
||||
"""Not even a JWT. Same 401, no stack trace, no 500."""
|
||||
resp = await client.get("/v1/instances", headers=auth("not-a-jwt"))
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- authz
|
||||
|
||||
|
||||
async def test_team_a_cannot_get_team_b_instance(
|
||||
client: httpx.AsyncClient, rsa_key: rsa.RSAPrivateKey
|
||||
) -> None:
|
||||
"""404, not 403. AuthZ is the WHERE clause.
|
||||
|
||||
403 would confirm the id exists — an enumeration oracle. 404 is the same answer a
|
||||
made-up uuid gets, so the two are indistinguishable, which is the point.
|
||||
"""
|
||||
a_token = make_token(rsa_key, "team-a")
|
||||
b_token = make_token(rsa_key, "team-b")
|
||||
|
||||
created = await client.post(
|
||||
"/v1/instances", headers=auth(a_token), json={"service_type": "redis", "size": "small"}
|
||||
)
|
||||
assert created.status_code == 202
|
||||
iid = created.json()["id"]
|
||||
|
||||
assert (await client.get(f"/v1/instances/{iid}", headers=auth(a_token))).status_code == 200
|
||||
|
||||
stolen = await client.get(f"/v1/instances/{iid}", headers=auth(b_token))
|
||||
assert stolen.status_code == 404
|
||||
# Byte-identical to a genuinely nonexistent id? The message embeds the id, so compare
|
||||
# the code: the shape a client can branch on must not distinguish the two cases.
|
||||
nonexistent = await client.get(f"/v1/instances/{uuid4()}", headers=auth(b_token))
|
||||
assert nonexistent.status_code == 404
|
||||
assert stolen.json()["code"] == nonexistent.json()["code"] == "not_found"
|
||||
|
||||
|
||||
async def test_list_is_scoped_to_the_callers_team(
|
||||
client: httpx.AsyncClient, rsa_key: rsa.RSAPrivateKey
|
||||
) -> None:
|
||||
"""A list endpoint is where cross-tenant leaks show up first."""
|
||||
a_token = make_token(rsa_key, "team-a")
|
||||
b_token = make_token(rsa_key, "team-b")
|
||||
for tok in (a_token, a_token, b_token):
|
||||
await client.post("/v1/instances", headers=auth(tok), json={"service_type": "redis", "size": "small"})
|
||||
|
||||
a_list = await client.get("/v1/instances", headers=auth(a_token))
|
||||
assert a_list.status_code == 200
|
||||
assert len(a_list.json()) == 2
|
||||
|
||||
b_list = await client.get("/v1/instances", headers=auth(b_token))
|
||||
assert len(b_list.json()) == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- get / delete
|
||||
|
||||
|
||||
async def test_get_unknown_id_is_404(client: httpx.AsyncClient, token: str) -> None:
|
||||
"""A well-formed uuid that is not a row."""
|
||||
resp = await client.get(f"/v1/instances/{uuid4()}", headers=auth(token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_get_malformed_id_is_422(client: httpx.AsyncClient, token: str) -> None:
|
||||
"""Not a uuid at all: a path-schema failure, caught before the handler."""
|
||||
resp = await client.get("/v1/instances/not-a-uuid", headers=auth(token))
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_delete_moves_to_deleting_and_enqueues_deprovision(
|
||||
client: httpx.AsyncClient, pool: DictPool, token: str
|
||||
) -> None:
|
||||
"""202: the helm uninstall has not happened yet. Only the intent is durable."""
|
||||
created = await client.post(
|
||||
"/v1/instances", headers=auth(token), json={"service_type": "redis", "size": "small"}
|
||||
)
|
||||
iid = UUID(created.json()["id"])
|
||||
|
||||
# requested -> deleting is not legal; the state machine only allows it from ready.
|
||||
async with pool.connection() as conn:
|
||||
await conn.execute("update instances set state='ready' where id = %s", (iid,))
|
||||
|
||||
resp = await client.delete(f"/v1/instances/{iid}", headers=auth(token))
|
||||
assert resp.status_code == 202, resp.text
|
||||
assert resp.json()["state"] == "deleting"
|
||||
|
||||
rows = await _fetch_all(pool, "select state from instances where id = %s", (iid,))
|
||||
assert rows[0]["state"] == "deleting"
|
||||
kinds = await _fetch_all(pool, "select kind from tasks where instance_id = %s order by id", (iid,))
|
||||
assert [r["kind"] for r in kinds] == ["provision", "deprovision"]
|
||||
|
||||
|
||||
async def test_delete_other_teams_instance_is_404(
|
||||
client: httpx.AsyncClient, rsa_key: rsa.RSAPrivateKey
|
||||
) -> None:
|
||||
"""The destructive endpoint gets the same WHERE-clause treatment as the read."""
|
||||
a_token = make_token(rsa_key, "team-a")
|
||||
created = await client.post(
|
||||
"/v1/instances", headers=auth(a_token), json={"service_type": "redis", "size": "small"}
|
||||
)
|
||||
iid = created.json()["id"]
|
||||
resp = await client.delete(f"/v1/instances/{iid}", headers=auth(make_token(rsa_key, "team-b")))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
async def test_delete_from_illegal_state_is_409(
|
||||
client: httpx.AsyncClient, pool: DictPool, token: str
|
||||
) -> None:
|
||||
"""`deleted` is terminal. The state machine says no, and the API does not overrule it."""
|
||||
created = await client.post(
|
||||
"/v1/instances", headers=auth(token), json={"service_type": "redis", "size": "small"}
|
||||
)
|
||||
iid = UUID(created.json()["id"])
|
||||
async with pool.connection() as conn:
|
||||
await conn.execute("update instances set state='deleted' where id = %s", (iid,))
|
||||
|
||||
resp = await client.delete(f"/v1/instances/{iid}", headers=auth(token))
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["code"] == "illegal_transition"
|
||||
|
||||
|
||||
async def test_delete_requires_auth(client: httpx.AsyncClient) -> None:
|
||||
"""No token, no teardown."""
|
||||
assert (await client.delete(f"/v1/instances/{uuid4()}")).status_code == 401
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- ops endpoints
|
||||
|
||||
|
||||
async def test_healthz_needs_no_database(settings: Settings) -> None:
|
||||
"""Liveness does no I/O, so it answers 200 with no database anywhere near it.
|
||||
|
||||
Built by hand against a nonsense DSN and with NO lifespan: if /healthz touched the
|
||||
pool, there is no pool to touch and this would fail. That is the assertion — a DB blip
|
||||
must never get the whole fleet killed and CrashLoopBackOff'd.
|
||||
"""
|
||||
broken = create_app(settings.model_copy(update={"pg_dsn": "postgresql://nobody@127.0.0.1:1/nothing"}))
|
||||
transport = httpx.ASGITransport(app=broken)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
resp = await c.get("/healthz") # no `async with transport` => lifespan never ran
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "ok"}
|
||||
|
||||
|
||||
async def test_healthz_needs_no_auth(client: httpx.AsyncClient) -> None:
|
||||
"""The kubelet has no bearer token."""
|
||||
assert (await client.get("/healthz")).status_code == 200
|
||||
|
||||
|
||||
async def test_readyz_is_200_when_the_pool_is_open(client: httpx.AsyncClient) -> None:
|
||||
"""`select 1` against a live database."""
|
||||
resp = await client.get("/readyz")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "ready"}
|
||||
|
||||
|
||||
async def test_readyz_is_503_when_the_pool_is_closed(client: httpx.AsyncClient, app: FastAPI) -> None:
|
||||
"""Readiness fails closed. 503 pulls the pod out of the Service; it does not kill it."""
|
||||
await app.state.pool.close()
|
||||
resp = await client.get("/readyz")
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["code"] == "not_ready"
|
||||
|
||||
|
||||
async def test_metrics_serves_the_prometheus_exposition_format(client: httpx.AsyncClient) -> None:
|
||||
"""A bare /metrics (what every scrape config requests) returns the exposition format."""
|
||||
resp = await client.get("/metrics")
|
||||
assert resp.status_code == 200
|
||||
assert "text/plain" in resp.headers["content-type"]
|
||||
assert "python_gc_objects_collected_total" in resp.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- contract
|
||||
|
||||
|
||||
async def test_openapi_lists_the_seven_endpoints(client: httpx.AsyncClient) -> None:
|
||||
"""The deliverable, asserted: /docs renders these paths."""
|
||||
paths = (await client.get("/openapi.json")).json()["paths"]
|
||||
assert sorted(paths) == [
|
||||
"/healthz",
|
||||
"/metrics",
|
||||
"/readyz",
|
||||
"/v1/instances",
|
||||
"/v1/instances/{instance_id}",
|
||||
]
|
||||
assert sorted(paths["/v1/instances"]) == ["get", "post"]
|
||||
assert sorted(paths["/v1/instances/{instance_id}"]) == ["delete", "get"]
|
||||
|
||||
|
||||
async def test_errors_are_documented_as_errorbody(client: httpx.AsyncClient) -> None:
|
||||
"""Every error the API returns has one declared shape, and clients can generate against it."""
|
||||
schema = (await client.get("/openapi.json")).json()
|
||||
assert sorted(schema["components"]["schemas"]["ErrorBody"]["properties"]) == ["code", "message"]
|
||||
post = schema["paths"]["/v1/instances"]["post"]["responses"]
|
||||
for code in ("401", "404", "409", "422"):
|
||||
ref = post[code]["content"]["application/json"]["schema"]["$ref"]
|
||||
assert ref.endswith("/ErrorBody"), f"{code} is not documented as ErrorBody"
|
||||
|
||||
|
||||
async def test_202_is_on_the_decorator_not_the_response_object(client: httpx.AsyncClient) -> None:
|
||||
"""The schema must say 202, not just the runtime.
|
||||
|
||||
Setting response.status_code in a handler body changes the response and leaves the
|
||||
OpenAPI document claiming 200 — so generated clients treat a 202 as an error.
|
||||
"""
|
||||
schema = (await client.get("/openapi.json")).json()
|
||||
assert "202" in schema["paths"]["/v1/instances"]["post"]["responses"]
|
||||
assert "202" in schema["paths"]["/v1/instances/{instance_id}"]["delete"]["responses"]
|
||||
|
||||
|
||||
def test_auth_disabled_short_circuits(settings: Settings) -> None:
|
||||
"""The dev escape hatch exists, and Settings.check_production() refuses it in prod."""
|
||||
dev = settings.model_copy(update={"auth_disabled": True})
|
||||
with pytest.raises(ValueError, match="refused outside local development"):
|
||||
dev.check_production()
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
async def test_lifespan_survives_an_unreachable_jwks(settings: Settings) -> None:
|
||||
"""A down identity provider must not stop the pod from starting.
|
||||
|
||||
The warm-up is best-effort on purpose: fail startup on it and an IdP blip means no pod
|
||||
in the fleet can start, so the outage outlives the blip. A cache miss later costs one
|
||||
to_thread hop. Requests still 401 until the keys arrive — fail closed, stay up.
|
||||
"""
|
||||
unreachable = settings.model_copy(
|
||||
update={"jwks_url": "https://nonexistent.invalid/protocol/openid-connect/certs"}
|
||||
)
|
||||
booted = create_app(unreachable)
|
||||
async with booted.router.lifespan_context(booted):
|
||||
transport = httpx.ASGITransport(app=booted)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
assert (await c.get("/healthz")).status_code == 200
|
||||
# Keys never loaded, so auth fails closed rather than falling open.
|
||||
assert (await c.get("/v1/instances", headers=auth("whatever"))).status_code == 401
|
||||
|
||||
|
||||
async def test_auth_disabled_accepts_an_unauthenticated_request(settings: Settings) -> None:
|
||||
"""With the hatch open, no header is needed and a fixed team is used."""
|
||||
dev_app = create_app(settings.model_copy(update={"auth_disabled": True}))
|
||||
async with dev_app.router.lifespan_context(dev_app):
|
||||
transport = httpx.ASGITransport(app=dev_app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
resp = await c.get("/v1/instances")
|
||||
assert resp.status_code == 200
|
||||
@@ -0,0 +1,43 @@
|
||||
"""The claim race. If only one test in this repo survives, it should be this one."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from svcforge_core.domain.models import TaskKind
|
||||
from svcforge_core.repo.db import DictPool
|
||||
from svcforge_core.repo.tasks import TaskRepo
|
||||
from tests.integration.helpers import make_instance
|
||||
|
||||
|
||||
async def test_skip_locked_claims_each_task_exactly_once(pool: DictPool) -> None:
|
||||
"""50 workers, 50 tasks, one claim each — no double-claims, no lost tasks.
|
||||
|
||||
This is the test that fails if you split the claim into select-then-update.
|
||||
"""
|
||||
repo = TaskRepo(pool)
|
||||
inst = await make_instance(pool)
|
||||
ids = {await repo.enqueue_standalone(inst, TaskKind.PROVISION) for _ in range(50)}
|
||||
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
claims = [tg.create_task(repo.claim(f"w{i}")) for i in range(50)]
|
||||
got = [c.result() for c in claims]
|
||||
|
||||
assert all(t is not None for t in got)
|
||||
assert sorted(t.id for t in got if t is not None) == sorted(ids) # each exactly once
|
||||
assert all(t.attempts == 1 for t in got if t is not None)
|
||||
|
||||
|
||||
async def test_more_workers_than_tasks_get_none_not_a_duplicate(pool: DictPool) -> None:
|
||||
"""Contention must produce None for the losers, never a second claim on one row."""
|
||||
repo = TaskRepo(pool)
|
||||
inst = await make_instance(pool)
|
||||
await repo.enqueue_standalone(inst, TaskKind.PROVISION)
|
||||
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
claims = [tg.create_task(repo.claim(f"w{i}")) for i in range(10)]
|
||||
got = [c.result() for c in claims]
|
||||
|
||||
won = [t for t in got if t is not None]
|
||||
assert len(won) == 1
|
||||
assert len([t for t in got if t is None]) == 9
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Day 2 against a real Postgres: the work list, the halt, and the window.
|
||||
|
||||
The work-list query is the entire rollout, so it is tested where it runs. `order by
|
||||
team = %s desc` and `not exists (... halted)` are SQL semantics — a fake repo asserting
|
||||
them would only prove the fake agrees with itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
from svcforge_core.domain.models import TaskKind
|
||||
from svcforge_core.domain.states import InstanceState
|
||||
from svcforge_core.domain.windows import parse_window, schedule_upgrade_at
|
||||
from svcforge_core.repo.db import DictPool
|
||||
from svcforge_core.repo.instances import InstanceRepo
|
||||
from svcforge_core.repo.tasks import TaskRepo
|
||||
from tests.integration.helpers import make_instance
|
||||
|
||||
OLD = "21.3.19" # what is deployed
|
||||
PINNED = "21.3.20" # what catalog.yaml now says
|
||||
OWN_TEAM = "platform"
|
||||
|
||||
|
||||
async def _set_window(pool: DictPool, instance_id: UUID, spec: str | None) -> None:
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"update instances set maintenance_window = %s where id = %s",
|
||||
(spec, instance_id),
|
||||
)
|
||||
|
||||
|
||||
async def _halt(pool: DictPool, service_type: str) -> None:
|
||||
"""What `handle_verify` does on a failed probe, and what you undo by hand with SQL."""
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""insert into catalog_versions (service_type, rollout_state) values (%s, 'halted')
|
||||
on conflict (service_type) do update set rollout_state = 'halted'""",
|
||||
(service_type,),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def fleet(pool: DictPool) -> list[UUID]:
|
||||
"""Three ready instances on the old version. The own-team one is created LAST.
|
||||
|
||||
Created last on purpose: `created_at` is the tiebreak, so if the `team = %s desc` sort
|
||||
were dropped this fixture makes the test fail instead of passing by luck.
|
||||
"""
|
||||
ids = [
|
||||
await make_instance(pool, team="tenant-a", state=InstanceState.READY, chart_version=OLD),
|
||||
await make_instance(pool, team="tenant-b", state=InstanceState.READY, chart_version=OLD),
|
||||
await make_instance(pool, team=OWN_TEAM, state=InstanceState.READY, chart_version=OLD),
|
||||
]
|
||||
return ids
|
||||
|
||||
|
||||
async def test_work_list_returns_one_row_and_it_is_the_own_team_row(
|
||||
pool: DictPool, fleet: list[UUID]
|
||||
) -> None:
|
||||
"""max_in_flight=1 means one instance moves at a time, and yours is the guinea pig."""
|
||||
repo = InstanceRepo(pool)
|
||||
|
||||
rows = await repo.list_upgradable(
|
||||
service_type="elasticsearch",
|
||||
catalog_version=PINNED,
|
||||
own_team=OWN_TEAM,
|
||||
max_in_flight=1,
|
||||
)
|
||||
|
||||
assert len(rows) == 1
|
||||
assert rows[0].instance.team == OWN_TEAM
|
||||
assert rows[0].instance.id == fleet[2]
|
||||
assert rows[0].instance.chart_version == OLD
|
||||
|
||||
|
||||
async def test_work_list_skips_instances_already_on_the_pinned_version(
|
||||
pool: DictPool, fleet: list[UUID]
|
||||
) -> None:
|
||||
"""The query is the progress bar: as instances land on PINNED, the list drains to empty."""
|
||||
repo = InstanceRepo(pool)
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute("update instances set chart_version = %s", (PINNED,))
|
||||
|
||||
rows = await repo.list_upgradable(
|
||||
service_type="elasticsearch", catalog_version=PINNED, own_team=OWN_TEAM, max_in_flight=10
|
||||
)
|
||||
|
||||
assert rows == []
|
||||
|
||||
|
||||
async def test_halted_rollout_returns_zero_rows(pool: DictPool, fleet: list[UUID]) -> None:
|
||||
"""One column stops the fleet. This is the whole stop button."""
|
||||
repo = InstanceRepo(pool)
|
||||
await _halt(pool, "elasticsearch")
|
||||
|
||||
rows = await repo.list_upgradable(
|
||||
service_type="elasticsearch",
|
||||
catalog_version=PINNED,
|
||||
own_team=OWN_TEAM,
|
||||
max_in_flight=10, # generous on purpose: it is the halt returning 0, not the limit
|
||||
)
|
||||
|
||||
assert rows == []
|
||||
|
||||
|
||||
async def test_halt_is_scoped_to_one_service_type(pool: DictPool) -> None:
|
||||
"""A broken redis chart must not freeze elasticsearch upgrades."""
|
||||
repo = InstanceRepo(pool)
|
||||
await make_instance(
|
||||
pool, team=OWN_TEAM, service_type="redis", state=InstanceState.READY, chart_version=OLD
|
||||
)
|
||||
await make_instance(
|
||||
pool, team=OWN_TEAM, service_type="elasticsearch", state=InstanceState.READY, chart_version=OLD
|
||||
)
|
||||
await _halt(pool, "redis")
|
||||
|
||||
assert await repo.list_upgradable("redis", PINNED, OWN_TEAM, 10) == []
|
||||
assert len(await repo.list_upgradable("elasticsearch", PINNED, OWN_TEAM, 10)) == 1
|
||||
|
||||
|
||||
async def test_windowed_upgrade_is_scheduled_in_the_future_and_security_bypasses_it(
|
||||
pool: DictPool,
|
||||
) -> None:
|
||||
"""The window lands in `tasks.run_after`, and `security: true` ignores it.
|
||||
|
||||
Both paths go through the real enqueue, so this also pins the `timestamptz` round-trip:
|
||||
an aware UTC datetime must come back out of Postgres still aware and still that instant.
|
||||
"""
|
||||
repo = InstanceRepo(pool)
|
||||
tasks = TaskRepo(pool)
|
||||
iid = await make_instance(pool, team=OWN_TEAM, state=InstanceState.READY, chart_version=OLD)
|
||||
await _set_window(pool, iid, "0 3 * * 0|Asia/Ho_Chi_Minh")
|
||||
|
||||
(candidate,) = await repo.list_upgradable("elasticsearch", PINNED, OWN_TEAM, 1)
|
||||
window = parse_window(candidate.maintenance_window)
|
||||
assert window is not None
|
||||
|
||||
now = datetime.now(UTC)
|
||||
|
||||
routine = await tasks.enqueue_standalone(
|
||||
iid, TaskKind.UPGRADE, schedule_upgrade_at(window, security=False, now=now)
|
||||
)
|
||||
urgent = await tasks.enqueue_standalone(
|
||||
iid, TaskKind.UPGRADE, schedule_upgrade_at(window, security=True, now=now)
|
||||
)
|
||||
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute("select id, run_after from tasks where id = any(%s)", ([routine, urgent],))
|
||||
run_after = {r["id"]: r["run_after"] for r in await cur.fetchall()}
|
||||
|
||||
assert run_after[routine] > now # waits for 03:00 Sunday, Vietnam time
|
||||
assert run_after[urgent] <= now # a public exploit does not wait
|
||||
assert run_after[routine].tzinfo is not None
|
||||
@@ -0,0 +1,29 @@
|
||||
"""The one test that proves the timeout is real.
|
||||
|
||||
`bash -c "sleep 300 & sleep 300"` is a miniature helm: a process that forks a child and
|
||||
waits on another. Kill the direct child only and the backgrounded `sleep` reparents to init
|
||||
and keeps running — which, when the process is helm, means a timed-out task retries while
|
||||
the original helm is still mutating the same release.
|
||||
|
||||
This test needs a real process tree, so it lives in integration/. It needs no database:
|
||||
the `pool` fixture in conftest is not autouse.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from svcforge_core.adapters.helm import _run
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_kills_the_whole_process_group() -> None:
|
||||
argv = ["bash", "-c", "sleep 300 & sleep 300"] # child forks a grandchild
|
||||
with pytest.raises(TimeoutError):
|
||||
await _run(argv, timeout_s=1)
|
||||
await asyncio.sleep(0.5)
|
||||
out = subprocess.run(["pgrep", "-f", "sleep 300"], capture_output=True, text=True) # noqa: S607
|
||||
assert out.stdout.strip() == "", "grandchild survived: you killed the child, not the group"
|
||||
@@ -0,0 +1,104 @@
|
||||
"""InstanceRepo against real SQL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from svcforge_core.domain.models import TaskKind
|
||||
from svcforge_core.domain.states import InstanceState
|
||||
from svcforge_core.repo.db import DictPool
|
||||
from svcforge_core.repo.instances import InstanceRepo
|
||||
from svcforge_core.repo.tasks import TaskRepo
|
||||
from tests.integration.helpers import build_instance
|
||||
|
||||
|
||||
async def test_create_then_get_round_trips(pool: DictPool) -> None:
|
||||
repo = InstanceRepo(pool)
|
||||
inst = build_instance()
|
||||
async with pool.connection() as conn:
|
||||
created = await repo.create(conn, inst)
|
||||
assert created.id == inst.id
|
||||
assert created.release_name == inst.release_name
|
||||
|
||||
got = await repo.get(inst.id, team="platform")
|
||||
assert got is not None
|
||||
assert got.service_type == "elasticsearch"
|
||||
assert got.state is InstanceState.REQUESTED
|
||||
# timestamptz round-trips as aware, or every later comparison raises TypeError.
|
||||
assert got.created_at.tzinfo is not None
|
||||
|
||||
|
||||
async def test_get_by_other_team_is_none_not_403(pool: DictPool) -> None:
|
||||
"""A wrong-team id is indistinguishable from a missing one."""
|
||||
repo = InstanceRepo(pool)
|
||||
inst = build_instance(team="platform")
|
||||
async with pool.connection() as conn:
|
||||
await repo.create(conn, inst)
|
||||
|
||||
assert await repo.get(inst.id, team="quant") is None
|
||||
|
||||
|
||||
async def test_list_is_filtered_by_team(pool: DictPool) -> None:
|
||||
repo = InstanceRepo(pool)
|
||||
async with pool.connection() as conn:
|
||||
await repo.create(conn, build_instance(team="platform"))
|
||||
await repo.create(conn, build_instance(team="quant"))
|
||||
|
||||
mine = await repo.list(team="platform")
|
||||
assert len(mine) == 1
|
||||
assert all(i.team == "platform" for i in mine)
|
||||
|
||||
|
||||
async def test_update_state_cas_rejects_stale_expectation(pool: DictPool) -> None:
|
||||
repo = InstanceRepo(pool)
|
||||
inst = build_instance()
|
||||
async with pool.connection() as conn:
|
||||
await repo.create(conn, inst)
|
||||
|
||||
ok = await repo.update_state(inst.id, InstanceState.REQUESTED, InstanceState.PROVISIONING)
|
||||
assert ok is True
|
||||
|
||||
# The row already moved: the second caller must lose, and must not raise.
|
||||
lost = await repo.update_state(inst.id, InstanceState.REQUESTED, InstanceState.PROVISIONING)
|
||||
assert lost is False
|
||||
|
||||
|
||||
async def test_update_state_sets_endpoint(pool: DictPool) -> None:
|
||||
repo = InstanceRepo(pool)
|
||||
inst = build_instance()
|
||||
async with pool.connection() as conn:
|
||||
await repo.create(conn, inst)
|
||||
await repo.update_state(inst.id, InstanceState.REQUESTED, InstanceState.PROVISIONING)
|
||||
ok = await repo.update_state(
|
||||
inst.id, InstanceState.PROVISIONING, InstanceState.READY, endpoint="http://es:9200"
|
||||
)
|
||||
assert ok is True
|
||||
got = await repo.get(inst.id, team="platform")
|
||||
assert got is not None
|
||||
assert got.endpoint == "http://es:9200"
|
||||
assert got.state is InstanceState.READY
|
||||
|
||||
|
||||
@pytest.mark.parametrize("explode", [True])
|
||||
async def test_instance_and_task_roll_back_together(pool: DictPool, explode: bool) -> None:
|
||||
"""The reason the queue is in Postgres, as an executable claim.
|
||||
|
||||
If the transaction aborts, BOTH the instance and its provision task must vanish.
|
||||
An instance with no task never gets built; a task with no instance is an orphan.
|
||||
"""
|
||||
instances, tasks = InstanceRepo(pool), TaskRepo(pool)
|
||||
inst = build_instance()
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
async with pool.connection() as conn, conn.transaction():
|
||||
await instances.create(conn, inst)
|
||||
await tasks.enqueue(conn, inst.id, TaskKind.PROVISION)
|
||||
if explode:
|
||||
raise RuntimeError("boom, mid-transaction")
|
||||
|
||||
assert await instances.get(inst.id, team="platform") is None
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute("select count(*) as n from tasks")
|
||||
row = await cur.fetchone()
|
||||
assert row is not None
|
||||
assert row["n"] == 0
|
||||
@@ -0,0 +1,556 @@
|
||||
"""The four checks, against a real Postgres and a fake cluster.
|
||||
|
||||
Integration, not unit, because there is nothing to unit test: each check is a query and a
|
||||
transaction. The behaviour worth asserting — that the CAS and the insert commit together,
|
||||
that the idempotency guard is a real `not exists`, that a second tick does not double-
|
||||
enqueue — lives entirely in the part a mock would replace.
|
||||
|
||||
The cluster is faked; the database is not. FakeProvisioner is a dict of releases, which is
|
||||
all the drift check needs: drift is "helm says X, the DB says Y", and a dict says X.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||
|
||||
from services.reconciler.main import (
|
||||
ReconcilerDeps,
|
||||
check_drift,
|
||||
check_lease_expiry,
|
||||
check_ttl,
|
||||
check_version_drift,
|
||||
tick,
|
||||
)
|
||||
from svcforge_core.domain.models import CatalogEntry, SizeSpec, TaskKind, TaskState
|
||||
from svcforge_core.domain.states import InstanceState
|
||||
from svcforge_core.obs import RECONCILER_LAST_TICK
|
||||
from svcforge_core.repo.db import DictPool
|
||||
from svcforge_core.repo.instances import InstanceRepo
|
||||
from svcforge_core.repo.reconcile import ReconcileRepo
|
||||
from svcforge_core.repo.tasks import TaskRepo
|
||||
from svcforge_core.settings import Settings
|
||||
from tests.fakes import FakeClock, FakeNotifier, FakeProvisioner
|
||||
from tests.integration.helpers import build_instance
|
||||
|
||||
NOW = datetime(2026, 7, 17, 12, 0, tzinfo=UTC) # a Friday
|
||||
OLD_VERSION = "21.3.19"
|
||||
NEW_VERSION = "21.3.20"
|
||||
|
||||
# '0 3 * * 0' is 03:00 Sunday. From a Friday noon that is always in the future, which is
|
||||
# the whole assertion of the window test.
|
||||
SUNDAY_0300_HCM = "0 3 * * 0|Asia/Ho_Chi_Minh"
|
||||
|
||||
|
||||
def _entry(*, version: str = NEW_VERSION, security: bool = False) -> CatalogEntry:
|
||||
return CatalogEntry(
|
||||
service_type="elasticsearch",
|
||||
chart="bitnamilegacy/elasticsearch",
|
||||
chart_version=version,
|
||||
security=security,
|
||||
sizes={"small": SizeSpec(replicas=1, resources={})},
|
||||
)
|
||||
|
||||
|
||||
def _settings(**over: object) -> Settings:
|
||||
return Settings(pg_dsn="postgresql://u:p@localhost:5432/db", **over) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _deps(
|
||||
pool: DictPool,
|
||||
provisioner: FakeProvisioner,
|
||||
*,
|
||||
catalog: dict[str, CatalogEntry] | None = None,
|
||||
max_in_flight: int = 1,
|
||||
**settings_over: object,
|
||||
) -> ReconcilerDeps:
|
||||
return ReconcilerDeps(
|
||||
pool=pool,
|
||||
instances=InstanceRepo(pool),
|
||||
tasks=TaskRepo(pool),
|
||||
reconcile=ReconcileRepo(pool),
|
||||
provisioner=provisioner,
|
||||
notifier=FakeNotifier(),
|
||||
clock=FakeClock(NOW),
|
||||
catalog=catalog if catalog is not None else {"elasticsearch": _entry()},
|
||||
settings=_settings(**settings_over),
|
||||
own_team="platform",
|
||||
max_in_flight=max_in_flight,
|
||||
)
|
||||
|
||||
|
||||
async def _seed(
|
||||
pool: DictPool,
|
||||
*,
|
||||
state: InstanceState = InstanceState.READY,
|
||||
team: str = "platform",
|
||||
chart_version: str = OLD_VERSION,
|
||||
expires_at: datetime | None = None,
|
||||
maintenance_window: str | None = None,
|
||||
) -> tuple[UUID, str, str]:
|
||||
"""Insert one instance. Returns (id, release_name, namespace).
|
||||
|
||||
`expires_at` and `maintenance_window` go in with SQL rather than through
|
||||
`InstanceRepo.create`: create() does not write `maintenance_window` at all (nothing but
|
||||
the day-2 work list reads it), and that is a fact about the repo, not a gap in it.
|
||||
"""
|
||||
inst = build_instance(team=team, state=state, chart_version=chart_version)
|
||||
repo = InstanceRepo(pool)
|
||||
async with pool.connection() as conn:
|
||||
await repo.create(conn, inst)
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"update instances set expires_at = %s, maintenance_window = %s where id = %s",
|
||||
(expires_at, maintenance_window, inst.id),
|
||||
)
|
||||
return inst.id, inst.release_name, inst.namespace
|
||||
|
||||
|
||||
async def _tasks_for(pool: DictPool, instance_id: UUID) -> list[dict[str, Any]]:
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"select id, kind, state, run_after, traceparent from tasks where instance_id = %s order by id",
|
||||
(instance_id,),
|
||||
)
|
||||
return list(await cur.fetchall())
|
||||
|
||||
|
||||
async def _state_of(pool: DictPool, instance_id: UUID) -> str:
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute("select state from instances where id = %s", (instance_id,))
|
||||
row = await cur.fetchone()
|
||||
assert row is not None
|
||||
return str(row["state"])
|
||||
|
||||
|
||||
# --- Check 1: drift ------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_drift_reprovisions_a_ready_instance_whose_release_vanished(
|
||||
pool: DictPool,
|
||||
) -> None:
|
||||
"""`helm uninstall` by hand. Nobody sends an event; the next tick notices anyway.
|
||||
|
||||
This is the acceptance path from Module 7:
|
||||
helm uninstall <release> -n <ns> && python -m services.reconciler.main --once
|
||||
select kind, state from tasks order by id desc limit 1 -> provision | queued
|
||||
"""
|
||||
instance_id, _, _ = await _seed(pool)
|
||||
deps = _deps(pool, FakeProvisioner()) # empty cluster: the release is gone
|
||||
|
||||
await check_drift(deps)
|
||||
|
||||
tasks = await _tasks_for(pool, instance_id)
|
||||
assert [(t["kind"], t["state"]) for t in tasks] == [(TaskKind.PROVISION.value, TaskState.QUEUED.value)]
|
||||
|
||||
|
||||
async def test_drift_leaves_the_instance_in_provisioning_not_failed(pool: DictPool) -> None:
|
||||
"""The two-hop state change, and why it matters.
|
||||
|
||||
`handle_provision` returns early on a `ready` row and ends with a
|
||||
`provisioning -> ready` CAS. Hand it anything else and helm runs but the bookkeeping
|
||||
lands nowhere. So the reconciler must leave the row in `provisioning` — via `failed`,
|
||||
because LEGAL has no `ready -> provisioning` edge — before the worker can claim it.
|
||||
"""
|
||||
instance_id, _, _ = await _seed(pool)
|
||||
|
||||
await check_drift(_deps(pool, FakeProvisioner()))
|
||||
|
||||
assert await _state_of(pool, instance_id) == InstanceState.PROVISIONING.value
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute("select error from instances where id = %s", (instance_id,))
|
||||
row = await cur.fetchone()
|
||||
assert row is not None
|
||||
assert "drift" in row["error"] # the tenant gets told why, not just that
|
||||
|
||||
|
||||
async def test_drift_ignores_an_instance_whose_release_is_present(pool: DictPool) -> None:
|
||||
"""The happy path is the same code. It must enqueue nothing at all."""
|
||||
instance_id, release, namespace = await _seed(pool)
|
||||
provisioner = FakeProvisioner()
|
||||
await provisioner.install(release, namespace, _entry(), {})
|
||||
|
||||
await check_drift(_deps(pool, provisioner))
|
||||
|
||||
assert await _tasks_for(pool, instance_id) == []
|
||||
assert await _state_of(pool, instance_id) == InstanceState.READY.value
|
||||
|
||||
|
||||
async def test_drift_is_idempotent_across_ticks(pool: DictPool) -> None:
|
||||
"""Two ticks, one task. A provision takes minutes; ticks are 60 seconds apart.
|
||||
|
||||
Without the guard the second tick sees a `provisioning` row — no longer `ready`, so the
|
||||
drift branch skips it. The guard is what covers the case where it is `ready` again
|
||||
before the task is done.
|
||||
"""
|
||||
instance_id, _, _ = await _seed(pool)
|
||||
deps = _deps(pool, FakeProvisioner())
|
||||
|
||||
await check_drift(deps)
|
||||
await check_drift(deps)
|
||||
|
||||
assert len(await _tasks_for(pool, instance_id)) == 1
|
||||
|
||||
|
||||
async def test_drift_never_deletes_an_orphan_release(pool: DictPool) -> None:
|
||||
"""A release the DB has never heard of. Log it, bill nobody, delete nothing.
|
||||
|
||||
v1 policy, and it is a policy about evidence: "no row in this table" is not proof the
|
||||
release is unowned. It might belong to another tool, another team, or a migration that
|
||||
is half done. An operator deletes it after reading the log.
|
||||
"""
|
||||
provisioner = FakeProvisioner()
|
||||
await provisioner.install("someone-elses-redis", "other-ns", _entry(), {})
|
||||
|
||||
await check_drift(_deps(pool, provisioner))
|
||||
|
||||
assert "someone-elses-redis" in provisioner.releases
|
||||
assert provisioner.uninstalled == []
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute("select count(*) as n from tasks")
|
||||
row = await cur.fetchone()
|
||||
assert row is not None
|
||||
assert row["n"] == 0
|
||||
|
||||
|
||||
async def test_drift_does_not_call_an_in_flight_provision_an_orphan(pool: DictPool) -> None:
|
||||
"""A `requested` instance a worker is installing right now is not an orphan.
|
||||
|
||||
`known_releases` covers every row in any state for exactly this reason. Scope it to
|
||||
`ready` and every provision in progress gets reported as an orphan on every tick, which
|
||||
trains everyone to ignore the orphan log.
|
||||
"""
|
||||
_, release, namespace = await _seed(pool, state=InstanceState.REQUESTED)
|
||||
provisioner = FakeProvisioner()
|
||||
await provisioner.install(release, namespace, _entry(), {})
|
||||
|
||||
await check_drift(_deps(pool, provisioner))
|
||||
|
||||
assert provisioner.uninstalled == []
|
||||
|
||||
|
||||
# --- Check 2: lease expiry -----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_lease_expiry_returns_a_dead_workers_task_to_the_queue(pool: DictPool) -> None:
|
||||
"""SIGKILL leaves `running` with `locked_by` set and nobody running it.
|
||||
|
||||
No cleanup code in the worker can fix this, because the worker is the part that died.
|
||||
The lease is the only thing that recovers the row.
|
||||
"""
|
||||
instance_id, _, _ = await _seed(pool)
|
||||
tasks = TaskRepo(pool)
|
||||
await tasks.enqueue_standalone(instance_id, TaskKind.PROVISION)
|
||||
claimed = await tasks.claim("worker-that-is-about-to-die")
|
||||
assert claimed is not None
|
||||
|
||||
# The worker died six minutes ago and never reported.
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"update tasks set locked_at = now() - interval '6 minutes' where id = %s",
|
||||
(claimed.id,),
|
||||
)
|
||||
|
||||
await check_lease_expiry(_deps(pool, FakeProvisioner(), lease_seconds=300))
|
||||
|
||||
rows = await _tasks_for(pool, instance_id)
|
||||
assert rows[0]["state"] == TaskState.QUEUED.value
|
||||
|
||||
|
||||
async def test_lease_expiry_leaves_a_live_worker_alone(pool: DictPool) -> None:
|
||||
"""A task claimed a second ago is not a dead worker. Reclaiming it would double-provision.
|
||||
|
||||
Handlers are idempotent, so a wrongly-freed lease is survivable — but survivable is not
|
||||
free, and this is why lease_seconds sits above helm's own --timeout.
|
||||
"""
|
||||
instance_id, _, _ = await _seed(pool)
|
||||
tasks = TaskRepo(pool)
|
||||
await tasks.enqueue_standalone(instance_id, TaskKind.PROVISION)
|
||||
assert await tasks.claim("worker-1") is not None
|
||||
|
||||
await check_lease_expiry(_deps(pool, FakeProvisioner(), lease_seconds=300))
|
||||
|
||||
rows = await _tasks_for(pool, instance_id)
|
||||
assert rows[0]["state"] == TaskState.RUNNING.value
|
||||
|
||||
|
||||
# --- Check 3: TTL --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_ttl_expired_instance_goes_to_deleting_with_a_deprovision_task(
|
||||
pool: DictPool,
|
||||
) -> None:
|
||||
"""The check that stops a demo cluster from becoming a permanent line on the bill."""
|
||||
instance_id, _, _ = await _seed(pool, expires_at=datetime.now(UTC) - timedelta(minutes=1))
|
||||
|
||||
await check_ttl(_deps(pool, FakeProvisioner()))
|
||||
|
||||
tasks = await _tasks_for(pool, instance_id)
|
||||
assert [(t["kind"], t["state"]) for t in tasks] == [(TaskKind.DEPROVISION.value, TaskState.QUEUED.value)]
|
||||
# `deleting` before the worker claims it: handle_deprovision ends with a
|
||||
# `deleting -> deleted` CAS, and a `ready` row would leave the DB advertising an
|
||||
# endpoint for a release helm has already removed.
|
||||
assert await _state_of(pool, instance_id) == InstanceState.DELETING.value
|
||||
|
||||
|
||||
async def test_ttl_ignores_an_instance_that_has_not_expired(pool: DictPool) -> None:
|
||||
"""And ignores one with no expires_at at all: null means no TTL, not expired."""
|
||||
live, _, _ = await _seed(pool, expires_at=datetime.now(UTC) + timedelta(hours=1))
|
||||
forever, _, _ = await _seed(pool, expires_at=None)
|
||||
|
||||
await check_ttl(_deps(pool, FakeProvisioner()))
|
||||
|
||||
assert await _tasks_for(pool, live) == []
|
||||
assert await _tasks_for(pool, forever) == []
|
||||
|
||||
|
||||
async def test_ttl_is_idempotent_across_ticks(pool: DictPool) -> None:
|
||||
"""A deprovision takes minutes and ticks are 60s apart. One task, not four."""
|
||||
instance_id, _, _ = await _seed(pool, expires_at=datetime.now(UTC) - timedelta(minutes=1))
|
||||
deps = _deps(pool, FakeProvisioner())
|
||||
|
||||
await check_ttl(deps)
|
||||
await check_ttl(deps)
|
||||
await check_ttl(deps)
|
||||
|
||||
assert len(await _tasks_for(pool, instance_id)) == 1
|
||||
|
||||
|
||||
async def test_ttl_recovers_a_deleting_instance_whose_task_was_never_enqueued(
|
||||
pool: DictPool,
|
||||
) -> None:
|
||||
"""The API's DELETE crashed between the CAS and the enqueue. This is the sweep it relies on.
|
||||
|
||||
That statement order is chosen *because* this check exists. The other order leaves a
|
||||
deprovision task pointing at a `ready` instance, and a worker tears down a live service
|
||||
nobody asked to delete.
|
||||
"""
|
||||
instance_id, _, _ = await _seed(pool, state=InstanceState.DELETING)
|
||||
|
||||
await check_ttl(_deps(pool, FakeProvisioner()))
|
||||
|
||||
tasks = await _tasks_for(pool, instance_id)
|
||||
assert [t["kind"] for t in tasks] == [TaskKind.DEPROVISION.value]
|
||||
assert await _state_of(pool, instance_id) == InstanceState.DELETING.value
|
||||
|
||||
|
||||
async def test_ttl_re_enqueues_after_a_deprovision_exhausted_its_attempts(
|
||||
pool: DictPool,
|
||||
) -> None:
|
||||
"""`done` and `failed` are not outstanding. A transient outage must not strand the row.
|
||||
|
||||
The guard asks "is one queued or running", not "has one ever existed" — otherwise a
|
||||
deprovision that burned its five attempts during a cluster outage would leave the
|
||||
instance billing forever with nothing left to retry it.
|
||||
"""
|
||||
instance_id, _, _ = await _seed(pool, state=InstanceState.DELETING)
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"insert into tasks (instance_id, kind, state) values (%s, %s, %s)",
|
||||
(instance_id, TaskKind.DEPROVISION.value, TaskState.FAILED.value),
|
||||
)
|
||||
|
||||
await check_ttl(_deps(pool, FakeProvisioner()))
|
||||
|
||||
states = [t["state"] for t in await _tasks_for(pool, instance_id)]
|
||||
assert TaskState.QUEUED.value in states
|
||||
|
||||
|
||||
# --- Check 4: version drift ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_version_drift_enqueues_one_upgrade_for_the_own_team_instance_first(
|
||||
pool: DictPool,
|
||||
) -> None:
|
||||
"""max_in_flight=1 across three stale instances, and it picks ours.
|
||||
|
||||
Eating your own dog food is an `order by`: we are the tenant who finds out the chart is
|
||||
broken, and the halt stops the other two before they ever hear about it.
|
||||
"""
|
||||
await _seed(pool, team="payments")
|
||||
await _seed(pool, team="search")
|
||||
ours, _, _ = await _seed(pool, team="platform")
|
||||
|
||||
await check_version_drift(_deps(pool, FakeProvisioner()))
|
||||
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute("select instance_id, kind from tasks")
|
||||
rows = list(await cur.fetchall())
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["instance_id"] == ours
|
||||
assert rows[0]["kind"] == TaskKind.UPGRADE.value
|
||||
|
||||
|
||||
async def test_version_drift_enqueues_nothing_while_the_rollout_is_halted(
|
||||
pool: DictPool,
|
||||
) -> None:
|
||||
"""One column stops the fleet. A failed verify writes it; a human clears it with SQL."""
|
||||
instance_id, _, _ = await _seed(pool)
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"insert into catalog_versions (service_type, rollout_state) values ('elasticsearch', 'halted')"
|
||||
)
|
||||
|
||||
await check_version_drift(_deps(pool, FakeProvisioner()))
|
||||
|
||||
assert await _tasks_for(pool, instance_id) == []
|
||||
|
||||
|
||||
async def test_version_drift_ignores_an_instance_already_on_the_catalog_version(
|
||||
pool: DictPool,
|
||||
) -> None:
|
||||
"""`chart_version` is written only after helm succeeds, which is what makes this the query."""
|
||||
instance_id, _, _ = await _seed(pool, chart_version=NEW_VERSION)
|
||||
|
||||
await check_version_drift(_deps(pool, FakeProvisioner()))
|
||||
|
||||
assert await _tasks_for(pool, instance_id) == []
|
||||
|
||||
|
||||
async def test_version_drift_parks_the_upgrade_until_the_maintenance_window(
|
||||
pool: DictPool,
|
||||
) -> None:
|
||||
"""The queue does the waiting, in `where run_after <= now()`.
|
||||
|
||||
Not a scheduler and not an in-memory timer: a task parked in Postgres until 03:00 Sunday
|
||||
survives a reconciler restart. That is the whole reason `run_after` exists.
|
||||
"""
|
||||
instance_id, _, _ = await _seed(pool, maintenance_window=SUNDAY_0300_HCM)
|
||||
|
||||
await check_version_drift(_deps(pool, FakeProvisioner()))
|
||||
|
||||
tasks = await _tasks_for(pool, instance_id)
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]["run_after"] > NOW # a Friday; the next 03:00 Sunday is days away
|
||||
|
||||
|
||||
async def test_version_drift_bypasses_the_window_for_a_security_bump(pool: DictPool) -> None:
|
||||
"""A CVE with a public exploit does not wait until Sunday. That is what `security:` is for."""
|
||||
instance_id, _, _ = await _seed(pool, maintenance_window=SUNDAY_0300_HCM)
|
||||
catalog = {"elasticsearch": _entry(security=True)}
|
||||
|
||||
await check_version_drift(_deps(pool, FakeProvisioner(), catalog=catalog))
|
||||
|
||||
tasks = await _tasks_for(pool, instance_id)
|
||||
assert len(tasks) == 1
|
||||
assert tasks[0]["run_after"] <= NOW
|
||||
|
||||
|
||||
async def test_version_drift_is_idempotent_across_ticks(pool: DictPool) -> None:
|
||||
"""The guard that makes max_in_flight mean anything.
|
||||
|
||||
The instance stays on the work list for the whole duration of its own upgrade —
|
||||
`chart_version` is only written on success — and for the hours it spends parked waiting
|
||||
for 03:00. Without the guard, `max_in_flight=1` is sixty tasks an hour against one
|
||||
release.
|
||||
"""
|
||||
instance_id, _, _ = await _seed(pool, maintenance_window=SUNDAY_0300_HCM)
|
||||
deps = _deps(pool, FakeProvisioner())
|
||||
|
||||
for _ in range(3):
|
||||
await check_version_drift(deps)
|
||||
|
||||
assert len(await _tasks_for(pool, instance_id)) == 1
|
||||
|
||||
|
||||
async def test_version_drift_skips_one_bad_window_and_keeps_going(pool: DictPool) -> None:
|
||||
"""One tenant's typo must not freeze everyone else's security rollout."""
|
||||
broken, _, _ = await _seed(pool, team="payments", maintenance_window="not a cron|Asia/Ho_Chi_Minh")
|
||||
deps = _deps(pool, FakeProvisioner(), max_in_flight=5)
|
||||
|
||||
await check_version_drift(deps) # must not raise
|
||||
|
||||
assert await _tasks_for(pool, broken) == []
|
||||
|
||||
|
||||
# --- The tick ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _AngryProvisioner(FakeProvisioner):
|
||||
"""A cluster that cannot be reached. The drift check's worst day."""
|
||||
|
||||
async def list_releases(self) -> list[Any]:
|
||||
raise RuntimeError("dial tcp: i/o timeout")
|
||||
|
||||
|
||||
async def test_tick_runs_the_other_three_checks_when_one_blows_up(pool: DictPool) -> None:
|
||||
"""A helm binary that cannot reach the API server must not stop TTLs from expiring.
|
||||
|
||||
This is the entire argument for wrapping each check independently, and it is asserted
|
||||
rather than assumed because the failure mode — a tick that dies on check one — looks
|
||||
exactly like a tick that found nothing to do.
|
||||
"""
|
||||
expired, _, _ = await _seed(pool, expires_at=datetime.now(UTC) - timedelta(minutes=1))
|
||||
deps = _deps(pool, _AngryProvisioner())
|
||||
|
||||
await tick(deps) # must not raise
|
||||
|
||||
assert [t["kind"] for t in await _tasks_for(pool, expired)] == [TaskKind.DEPROVISION.value]
|
||||
|
||||
|
||||
async def test_tick_sets_the_gauges_and_the_heartbeat(pool: DictPool) -> None:
|
||||
"""queue_depth after the checks, not before, and the heartbeat unconditionally.
|
||||
|
||||
The heartbeat is what `SvcforgeReconcilerStale` reads. It answers "is the loop running",
|
||||
not "is everything fine" — the checks have their own alerts, and an alert that means two
|
||||
things gets muted.
|
||||
"""
|
||||
from prometheus_client import REGISTRY
|
||||
|
||||
await _seed(pool, expires_at=datetime.now(UTC) - timedelta(minutes=1))
|
||||
deps = _deps(pool, _AngryProvisioner()) # one check fails; the heartbeat still ticks
|
||||
|
||||
await tick(deps)
|
||||
|
||||
assert REGISTRY.get_sample_value("svcforge_queue_depth") == 1.0
|
||||
assert REGISTRY.get_sample_value("svcforge_instances", {"state": "deleting"}) == 1.0
|
||||
assert RECONCILER_LAST_TICK._value.get() == pytest.approx(NOW.timestamp())
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def tracing() -> InMemorySpanExporter:
|
||||
"""A real tracer provider for the process, collecting spans in memory.
|
||||
|
||||
Global because OTEL's is: `trace.set_tracer_provider` takes once per process, and
|
||||
`obs.tracer()` resolves it at call time. Session-scoped so the second call never
|
||||
happens.
|
||||
"""
|
||||
exporter = InMemorySpanExporter()
|
||||
provider = TracerProvider()
|
||||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||||
trace.set_tracer_provider(provider)
|
||||
return exporter
|
||||
|
||||
|
||||
async def test_a_task_the_tick_enqueues_carries_the_ticks_traceparent(
|
||||
pool: DictPool,
|
||||
tracing: InMemorySpanExporter,
|
||||
) -> None:
|
||||
"""Nothing propagates a trace through a table. The column is written at insert or never.
|
||||
|
||||
Driven through `tick`, not through `check_drift` with a span wrapped around it by the
|
||||
test — that version passed while the real entrypoint wrote null on every row, because
|
||||
the only span in the production path (`helm.list`) had already closed by the time the
|
||||
insert ran. A test that supplies the context under test proves the propagator works and
|
||||
nothing about this service.
|
||||
"""
|
||||
instance_id, _, _ = await _seed(pool)
|
||||
tracing.clear()
|
||||
|
||||
await tick(_deps(pool, FakeProvisioner()))
|
||||
|
||||
tasks = await _tasks_for(pool, instance_id)
|
||||
traceparent = tasks[0]["traceparent"]
|
||||
assert traceparent is not None, "the reconciler's own tasks are unjoinable to its tick"
|
||||
|
||||
# Same trace as the tick's span, which is the entire point of storing the column.
|
||||
tick_spans = [s for s in tracing.get_finished_spans() if s.name == "reconciler.tick"]
|
||||
assert len(tick_spans) == 1
|
||||
assert traceparent.split("-")[1] == format(tick_spans[0].context.trace_id, "032x")
|
||||
@@ -0,0 +1,494 @@
|
||||
"""Module 10 acceptance: the limiter, the idempotency store, the cache, and the budget.
|
||||
|
||||
Six checks, in the spec's order:
|
||||
|
||||
1. Rate limit 10/min — the 11th is refused.
|
||||
2. A check costs exactly ONE Redis command.
|
||||
3. Idempotency — the same key twice yields the same UUID.
|
||||
4. Cache — the second read costs one command and no DB query.
|
||||
5. Redis DOWN = the platform stays UP.
|
||||
6. The budget metric exists, and `scripts/redis_budget.py` reads it correctly.
|
||||
|
||||
**Two tiers, and the split is the budget.** Upstash's free tier is 500K commands/month;
|
||||
a test suite that hammers it is itself the bug this module is about. So the semantics are
|
||||
proved against `tests/fakes.py` (free, deterministic, runs on every commit), and only the
|
||||
things a fake cannot prove — that the Lua is valid Lua, that `KEYS`/`ARGV` are 1-based,
|
||||
that redis-py bills one EVALSHA, that `decode_responses=True` is set — are proved against
|
||||
real Upstash under `@pytest.mark.slow`. That tier spends roughly thirty commands per run,
|
||||
and `pytest -m "not slow"` skips it entirely.
|
||||
|
||||
Check 5 needs no server at all: a closed port is a more faithful outage than a mock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from prometheus_client import REGISTRY, generate_latest
|
||||
from redis.asyncio import Redis
|
||||
|
||||
from svcforge_core.adapters.redis import (
|
||||
IdempotencyStore,
|
||||
InstanceCache,
|
||||
RateLimiter,
|
||||
RateLimitResult,
|
||||
make_redis,
|
||||
)
|
||||
from svcforge_core.repo.db import DictPool
|
||||
from svcforge_core.repo.instances import InstanceRepo
|
||||
from svcforge_core.settings import Settings
|
||||
from tests.fakes import FakeClock, FakeIdempotencyStore, FakeInstanceCache, FakeRateLimiter
|
||||
from tests.integration.helpers import build_instance
|
||||
|
||||
# Any DSN that parses. These tests never open a Postgres connection through Settings; the
|
||||
# `pool` fixture owns the real database.
|
||||
_DUMMY_PG_DSN = "postgresql://unused:unused@127.0.0.1:5432/unused"
|
||||
|
||||
# A port nothing listens on. `make_redis` will build a client, every command will get
|
||||
# ECONNREFUSED, and that is the point of check 5.
|
||||
_DEAD_REDIS_DSN = "redis://127.0.0.1:1/0"
|
||||
|
||||
_T0 = datetime(2026, 7, 17, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def _metric(op: str) -> float:
|
||||
"""The budget counter for one op. Absent labels read as 0, not as an error."""
|
||||
value = REGISTRY.get_sample_value("svcforge_redis_commands_total", {"op": op})
|
||||
return value or 0.0
|
||||
|
||||
|
||||
def _errors(op: str) -> float:
|
||||
value = REGISTRY.get_sample_value("svcforge_redis_errors_total", {"op": op})
|
||||
return value or 0.0
|
||||
|
||||
|
||||
# --- Fixtures ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def upstash() -> AsyncIterator[Redis]:
|
||||
"""The real thing, from `~/.config/svcforge/secrets.env`. Skipped when unset.
|
||||
|
||||
Built through `make_redis` rather than `Redis.from_url` directly, so that
|
||||
`decode_responses=True` is covered by these tests instead of being a comment. Forget it
|
||||
and every assertion below dies on `bytes != str`, which is the whole reason it is the
|
||||
first bug everyone hits.
|
||||
"""
|
||||
dsn = os.getenv("SVCFORGE_REDIS_DSN")
|
||||
if not dsn:
|
||||
pytest.skip("SVCFORGE_REDIS_DSN unset; real-Upstash checks skipped")
|
||||
client = make_redis(Settings(pg_dsn=_DUMMY_PG_DSN, redis_dsn=dsn)) # type: ignore[arg-type] # pydantic coerces str -> *Dsn
|
||||
assert client is not None
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def dead_redis() -> AsyncIterator[Redis]:
|
||||
"""A client pointed at a closed port. No server was harmed, no commands were billed."""
|
||||
client = make_redis(Settings(pg_dsn=_DUMMY_PG_DSN, redis_dsn=_DEAD_REDIS_DSN)) # type: ignore[arg-type] # pydantic coerces str -> *Dsn
|
||||
assert client is not None
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
class _CommandCounter:
|
||||
"""Counts round trips by wrapping `execute_command` on one client instance.
|
||||
|
||||
This counts what Upstash bills, which is the only definition that matters here. The
|
||||
metric counter is our own bookkeeping and could be wrong in the same direction as the
|
||||
code it measures; this one cannot.
|
||||
"""
|
||||
|
||||
def __init__(self, r: Redis) -> None:
|
||||
self.count = 0
|
||||
self._inner: Callable[..., Any] = r.execute_command
|
||||
r.execute_command = self._counting # type: ignore[method-assign]
|
||||
|
||||
async def _counting(self, *args: Any, **kwargs: Any) -> Any: # noqa: ANN401
|
||||
self.count += 1
|
||||
return await self._inner(*args, **kwargs)
|
||||
|
||||
|
||||
def _budget_script() -> ModuleType:
|
||||
"""Import `scripts/redis_budget.py` by path — `scripts/` is not a package, deliberately."""
|
||||
path = Path(__file__).resolve().parents[2] / "scripts" / "redis_budget.py"
|
||||
spec = importlib.util.spec_from_file_location("redis_budget", path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
# --- 1. Rate limit: 10/min, the 11th is refused ------------------------------------------
|
||||
|
||||
|
||||
async def test_eleventh_request_in_the_window_is_refused() -> None:
|
||||
"""`200 x10` then `429`. The fake, so this runs on every commit for free."""
|
||||
limiter = FakeRateLimiter(limit=10, window_s=60, clock=FakeClock(start=_T0))
|
||||
|
||||
results = [await limiter.check("acme") for _ in range(11)]
|
||||
|
||||
assert [r.allowed for r in results] == [True] * 10 + [False]
|
||||
assert results[9].remaining == 0
|
||||
assert results[10].remaining == 0
|
||||
# What the handler puts in `Retry-After` on the 429. Never 0: a client told to retry
|
||||
# immediately retries into the same closed window.
|
||||
assert results[10].retry_after_s >= 1
|
||||
|
||||
|
||||
async def test_the_window_rolls_over_and_the_caller_is_allowed_again() -> None:
|
||||
"""A fixed window resets on a boundary, not on a sleep. Hence the injected clock."""
|
||||
clock = FakeClock(start=_T0)
|
||||
limiter = FakeRateLimiter(limit=2, window_s=60, clock=clock)
|
||||
|
||||
assert (await limiter.check("acme")).allowed
|
||||
assert (await limiter.check("acme")).allowed
|
||||
assert not (await limiter.check("acme")).allowed
|
||||
|
||||
clock.advance(datetime(2026, 7, 17, 12, 1, 0, tzinfo=UTC) - _T0)
|
||||
assert (await limiter.check("acme")).allowed
|
||||
|
||||
|
||||
async def test_teams_do_not_share_a_window() -> None:
|
||||
"""`rl:{team}:{window}` — a noisy tenant must not refuse a quiet one."""
|
||||
limiter = FakeRateLimiter(limit=1, window_s=60, clock=FakeClock(start=_T0))
|
||||
|
||||
assert (await limiter.check("acme")).allowed
|
||||
assert not (await limiter.check("acme")).allowed
|
||||
assert (await limiter.check("globex")).allowed
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
async def test_real_lua_refuses_the_eleventh(upstash: Redis) -> None:
|
||||
"""The same assertion against real Upstash. ~11 commands.
|
||||
|
||||
This is what a fake cannot prove: that the script is valid Lua, that `KEYS[1]` and
|
||||
`ARGV[1]` are 1-based (0-based indexing would read nil and compare false forever), and
|
||||
that `INCR`-then-conditional-`EXPIRE` actually holds a window open.
|
||||
"""
|
||||
team = f"test-{uuid4().hex[:8]}"
|
||||
limiter = RateLimiter(upstash, limit=10, window_s=60)
|
||||
|
||||
results = [await limiter.check(team) for _ in range(11)]
|
||||
|
||||
assert [r.allowed for r in results] == [True] * 10 + [False]
|
||||
assert not any(r.degraded for r in results)
|
||||
assert results[0].remaining == 9
|
||||
|
||||
# The EXPIRE fired on the first INCR only, so the key is not immortal. `Every key gets
|
||||
# a TTL` is a rule with no enforcement other than checking.
|
||||
window = int(time.time()) // 60
|
||||
keys = [f"rl:{team}:{window}", f"rl:{team}:{window - 1}"]
|
||||
ttls = [await upstash.ttl(k) for k in keys]
|
||||
assert any(0 < ttl <= 60 for ttl in ttls)
|
||||
await upstash.delete(*keys)
|
||||
|
||||
|
||||
# --- 2. It costs ONE command per check ---------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
async def test_a_check_costs_exactly_one_redis_command(upstash: Redis) -> None:
|
||||
"""One EVALSHA. Not GET+INCR+EXPIRE, which is three billed commands and a race.
|
||||
|
||||
At 500K/month the difference is not academic: three commands per request caps the
|
||||
platform at 166K requests/month instead of 500K, for a limiter that is also wrong.
|
||||
|
||||
The first call is excluded from the count on purpose. redis-py sends EVALSHA, Upstash
|
||||
answers NOSCRIPT because it has never seen the hash, and redis-py replays it as EVAL —
|
||||
two commands, once per Redis restart, and irrelevant to the steady state this measures.
|
||||
"""
|
||||
team = f"test-{uuid4().hex[:8]}"
|
||||
limiter = RateLimiter(upstash, limit=100, window_s=60)
|
||||
|
||||
await limiter.check(team) # warm the script cache
|
||||
|
||||
counter = _CommandCounter(upstash)
|
||||
before = _metric("ratelimit")
|
||||
result = await limiter.check(team)
|
||||
|
||||
assert counter.count == 1, "a rate limit check must be one round trip and one billed command"
|
||||
assert _metric("ratelimit") - before == 1, "the budget counter must agree with the wire"
|
||||
assert result.allowed
|
||||
|
||||
window = int(time.time()) // 60
|
||||
await upstash.delete(f"rl:{team}:{window}", f"rl:{team}:{window - 1}")
|
||||
|
||||
|
||||
# --- 3. Idempotency: the same key twice is one instance ----------------------------------
|
||||
|
||||
|
||||
async def test_same_idempotency_key_returns_the_first_instance_id() -> None:
|
||||
"""The first caller wins; the second is told who won and must not create anything."""
|
||||
store = FakeIdempotencyStore()
|
||||
key = str(uuid4())
|
||||
first, second = uuid4(), uuid4()
|
||||
|
||||
assert await store.claim(key, first) is None, "None means 'you won, go create it'"
|
||||
assert await store.claim(key, second) == first, "the loser gets the winner's id, not its own"
|
||||
|
||||
|
||||
async def test_different_idempotency_keys_do_not_collide() -> None:
|
||||
store = FakeIdempotencyStore()
|
||||
a, b = uuid4(), uuid4()
|
||||
|
||||
assert await store.claim(str(uuid4()), a) is None
|
||||
assert await store.claim(str(uuid4()), b) is None
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
async def test_real_set_nx_ex_claims_once(upstash: Redis) -> None:
|
||||
"""SET NX EX against Upstash. ~3 commands.
|
||||
|
||||
Also asserts the TTL, because a claim marker without one is a permanent record of a
|
||||
request from last March, and 256 MB of those ends by evicting the keys you cared about.
|
||||
"""
|
||||
key = f"test-{uuid4()}"
|
||||
first, second = uuid4(), uuid4()
|
||||
store = IdempotencyStore(upstash, ttl_s=60)
|
||||
|
||||
assert await store.claim(key, first) is None
|
||||
assert await store.claim(key, second) == first
|
||||
|
||||
assert 0 < await upstash.ttl(f"idem:{key}") <= 60
|
||||
await upstash.delete(f"idem:{key}")
|
||||
|
||||
|
||||
# --- 4. Cache: the second read costs one command and no DB query -------------------------
|
||||
|
||||
|
||||
class _CountingRepo:
|
||||
"""Wraps InstanceRepo and counts reads. The DB query count is the assertion."""
|
||||
|
||||
def __init__(self, repo: InstanceRepo) -> None:
|
||||
self._repo = repo
|
||||
self.gets = 0
|
||||
|
||||
async def get(self, id: UUID, team: str) -> Any: # noqa: ANN401
|
||||
self.gets += 1
|
||||
return await self._repo.get(id, team)
|
||||
|
||||
|
||||
async def _read_through(
|
||||
instance_id: UUID,
|
||||
team: str,
|
||||
cache: FakeInstanceCache | InstanceCache,
|
||||
repo: _CountingRepo,
|
||||
) -> Any: # noqa: ANN401
|
||||
"""Cache-aside, as `GET /v1/instances/{id}` performs it. Hit = 1 command, miss = 2."""
|
||||
cached = await cache.get(instance_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
inst = await repo.get(instance_id, team)
|
||||
if inst is not None:
|
||||
await cache.put(inst)
|
||||
return inst
|
||||
|
||||
|
||||
async def test_second_read_is_served_from_cache_without_touching_postgres(
|
||||
pool: DictPool,
|
||||
) -> None:
|
||||
"""Miss, then hit. The DB is read exactly once for two reads."""
|
||||
repo = InstanceRepo(pool)
|
||||
# `create` returns the row as Postgres stored it. Compare against that, never against
|
||||
# the model that went in: `created_at`/`updated_at` are DB defaults, and a timestamptz
|
||||
# comes back tagged `Etc/UTC` rather than `timezone.utc`. Same instant, different repr.
|
||||
async with pool.connection() as conn:
|
||||
inst = await repo.create(conn, build_instance(team="platform"))
|
||||
|
||||
counting = _CountingRepo(repo)
|
||||
cache = FakeInstanceCache()
|
||||
|
||||
first = await _read_through(inst.id, inst.team, cache, counting)
|
||||
second = await _read_through(inst.id, inst.team, cache, counting)
|
||||
|
||||
assert first == second == inst
|
||||
assert counting.gets == 1, "the second read must not reach Postgres"
|
||||
assert (cache.misses, cache.hits) == (1, 1)
|
||||
|
||||
|
||||
async def test_invalidate_sends_the_next_read_back_to_postgres() -> None:
|
||||
"""The worker calls this inside the code path that writes the state, not after it."""
|
||||
cache = FakeInstanceCache()
|
||||
inst = build_instance()
|
||||
|
||||
await cache.put(inst)
|
||||
assert await cache.get(inst.id) == inst
|
||||
|
||||
await cache.invalidate(inst.id)
|
||||
assert await cache.get(inst.id) is None
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
async def test_real_cache_hit_costs_one_command_and_no_db_query(pool: DictPool, upstash: Redis) -> None:
|
||||
"""Real Redis, real Postgres. ~4 commands.
|
||||
|
||||
The round-trip count is the acceptance criterion: a hit that costs two commands is a
|
||||
cache that has doubled the bill it was added to reduce.
|
||||
"""
|
||||
repo = InstanceRepo(pool)
|
||||
async with pool.connection() as conn:
|
||||
inst = await repo.create(conn, build_instance(team="platform"))
|
||||
|
||||
counting = _CountingRepo(repo)
|
||||
cache = InstanceCache(upstash, ttl_s=30)
|
||||
|
||||
miss = await _read_through(inst.id, inst.team, cache, counting)
|
||||
assert counting.gets == 1
|
||||
|
||||
counter = _CommandCounter(upstash)
|
||||
before = _metric("cache_get")
|
||||
hit = await _read_through(inst.id, inst.team, cache, counting)
|
||||
|
||||
assert counter.count == 1, "a cache hit is one GET"
|
||||
assert _metric("cache_get") - before == 1
|
||||
assert counting.gets == 1, "the second read must not reach Postgres"
|
||||
# Round-tripped through JSON and back: `decode_responses=True` and the datetime/UUID
|
||||
# serialisation both have to be right for this to compare equal.
|
||||
assert hit == miss == inst
|
||||
|
||||
assert 0 < await upstash.ttl(f"inst:{inst.id}") <= 30
|
||||
await cache.invalidate(inst.id)
|
||||
assert await cache.get(inst.id) is None
|
||||
|
||||
|
||||
# --- 5. Redis down = the platform stays up -----------------------------------------------
|
||||
|
||||
|
||||
async def test_rate_limiter_fails_open_when_redis_is_down(dead_redis: Redis) -> None:
|
||||
"""The load-bearing one. A limiter that fails closed turns a cache outage into an outage.
|
||||
|
||||
`degraded=True` and the error counter are what stop this from being invisible: fail
|
||||
open silently and you cannot tell a working limiter from one that has been allowing
|
||||
everything for a month.
|
||||
"""
|
||||
limiter = RateLimiter(dead_redis, limit=1, window_s=60)
|
||||
before = _errors("ratelimit")
|
||||
|
||||
results = [await limiter.check("acme") for _ in range(3)]
|
||||
|
||||
assert all(r.allowed for r in results), "Redis being down must never refuse legitimate traffic"
|
||||
assert all(r.degraded for r in results)
|
||||
assert _errors("ratelimit") - before == 3
|
||||
|
||||
|
||||
async def test_idempotency_falls_through_to_the_db_when_redis_is_down(dead_redis: Redis) -> None:
|
||||
"""None means "create it". Safe only because `instances.release_name` is UNIQUE."""
|
||||
store = IdempotencyStore(dead_redis)
|
||||
key = str(uuid4())
|
||||
|
||||
assert await store.claim(key, uuid4()) is None
|
||||
assert await store.claim(key, uuid4()) is None
|
||||
|
||||
|
||||
async def test_cache_misses_instead_of_raising_when_redis_is_down(dead_redis: Redis) -> None:
|
||||
"""A miss falls through to Postgres. `put` and `invalidate` swallow it too."""
|
||||
cache = InstanceCache(dead_redis)
|
||||
inst = build_instance()
|
||||
|
||||
assert await cache.get(inst.id) is None
|
||||
await cache.put(inst) # must not raise
|
||||
await cache.invalidate(inst.id) # must not raise
|
||||
|
||||
|
||||
async def test_platform_serves_reads_from_postgres_with_redis_down(pool: DictPool, dead_redis: Redis) -> None:
|
||||
"""The acceptance check: `docker compose stop redis` then GET -> 200.
|
||||
|
||||
Every Redis path degrades and the read still returns the row. Nothing here consults
|
||||
Redis for readiness, which is the other half of the rule — `/readyz` is Postgres-only,
|
||||
so a Redis outage cannot make a single pod unready.
|
||||
"""
|
||||
repo = InstanceRepo(pool)
|
||||
async with pool.connection() as conn:
|
||||
inst = await repo.create(conn, build_instance(team="platform"))
|
||||
|
||||
limiter = RateLimiter(dead_redis, limit=10, window_s=60)
|
||||
cache = InstanceCache(dead_redis)
|
||||
store = IdempotencyStore(dead_redis)
|
||||
|
||||
assert (await limiter.check(inst.team)).allowed
|
||||
assert await store.claim(str(uuid4()), inst.id) is None
|
||||
assert await cache.get(inst.id) is None
|
||||
assert await repo.get(inst.id, inst.team) == inst # the 200
|
||||
|
||||
|
||||
# --- 6. The budget metric exists and is sane ---------------------------------------------
|
||||
|
||||
|
||||
async def test_the_budget_metric_is_exposed_and_labelled_by_op() -> None:
|
||||
"""`curl -s localhost:8000/metrics | grep svcforge_redis_commands_total`."""
|
||||
limiter = FakeRateLimiter(limit=10, window_s=60, clock=FakeClock(start=_T0))
|
||||
await limiter.check("acme") # the fake does not touch the real counter
|
||||
RateLimitResult(allowed=True, limit=10, remaining=9, reset_at=_T0)
|
||||
|
||||
text = generate_latest(REGISTRY).decode()
|
||||
|
||||
assert "svcforge_redis_commands_total" in text
|
||||
assert "svcforge_redis_errors_total" in text
|
||||
# Per-op labels, because "you are over budget" is useless without "on cache_get".
|
||||
assert _metric("ratelimit") >= 0
|
||||
|
||||
|
||||
def test_budget_script_projects_a_five_second_poller_over_budget() -> None:
|
||||
"""The spec's headline number, reproduced: one worker polling every 5s = 518,400/month.
|
||||
|
||||
Exactly the free tier, spent doing nothing. This is the case the script exists to
|
||||
catch, so it is the case that is asserted rather than left to a comment.
|
||||
"""
|
||||
budget = _budget_script()
|
||||
now = time.time()
|
||||
# One hour at one command every five seconds.
|
||||
per_op, started_at = budget.collect(
|
||||
f'svcforge_redis_commands_total{{op="poll"}} 720.0\nprocess_start_time_seconds {now - 3600}\n'
|
||||
)
|
||||
|
||||
assert per_op == {"poll": 720.0}
|
||||
assert budget.report(per_op, started_at, budget._FREE_TIER_BUDGET, now) == 1
|
||||
|
||||
|
||||
def test_budget_script_passes_a_request_path_workload() -> None:
|
||||
"""Request-path volume is bounded by humans, and humans are slow. That is the whole rule."""
|
||||
budget = _budget_script()
|
||||
now = time.time()
|
||||
per_op, started_at = budget.collect(
|
||||
f'svcforge_redis_commands_total{{op="ratelimit"}} 100.0\n'
|
||||
f'svcforge_redis_commands_total{{op="cache_get"}} 40.0\n'
|
||||
f"process_start_time_seconds {now - 3600}\n"
|
||||
)
|
||||
|
||||
assert sum(per_op.values()) == 140.0
|
||||
assert budget.report(per_op, started_at, budget._FREE_TIER_BUDGET, now) == 0
|
||||
|
||||
|
||||
def test_budget_script_refuses_to_guess_without_the_metric() -> None:
|
||||
"""No counter means the process never imported the adapter — say so, do not print a 0."""
|
||||
budget = _budget_script()
|
||||
|
||||
with pytest.raises(budget.BudgetError, match="not exposed"):
|
||||
budget.collect("process_start_time_seconds 1.0\n")
|
||||
|
||||
with pytest.raises(budget.BudgetError, match="process_start_time_seconds"):
|
||||
budget.collect('svcforge_redis_commands_total{op="ratelimit"} 5.0\n')
|
||||
|
||||
|
||||
def test_budget_script_refuses_a_non_http_url() -> None:
|
||||
"""`--url file:///etc/passwd` is not a metrics endpoint."""
|
||||
budget = _budget_script()
|
||||
|
||||
with pytest.raises(budget.BudgetError, match="non-http"):
|
||||
budget.scrape("file:///etc/passwd")
|
||||
@@ -0,0 +1,155 @@
|
||||
"""TaskRepo: enqueue, complete, fail, backoff, leases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from svcforge_core.domain.models import TaskKind, TaskState
|
||||
from svcforge_core.domain.states import InstanceState
|
||||
from svcforge_core.repo.db import DictPool
|
||||
from svcforge_core.repo.instances import InstanceRepo
|
||||
from svcforge_core.repo.tasks import TaskRepo
|
||||
from tests.integration.helpers import make_instance
|
||||
|
||||
|
||||
async def _task_row(pool: DictPool, task_id: int) -> dict[str, object]:
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute("select * from tasks where id = %s", (task_id,))
|
||||
row = await cur.fetchone()
|
||||
assert row is not None
|
||||
return dict(row)
|
||||
|
||||
|
||||
async def test_enqueue_defaults_to_runnable_now(pool: DictPool) -> None:
|
||||
iid = await make_instance(pool)
|
||||
repo = TaskRepo(pool)
|
||||
tid = await repo.enqueue_standalone(iid, TaskKind.PROVISION)
|
||||
row = await _task_row(pool, tid)
|
||||
assert row["state"] == "queued"
|
||||
assert row["attempts"] == 0
|
||||
|
||||
|
||||
async def test_complete_marks_done_and_releases_lock(pool: DictPool) -> None:
|
||||
iid = await make_instance(pool)
|
||||
repo = TaskRepo(pool)
|
||||
tid = await repo.enqueue_standalone(iid, TaskKind.PROVISION)
|
||||
claimed = await repo.claim("w1")
|
||||
assert claimed is not None
|
||||
await repo.complete(claimed.id)
|
||||
row = await _task_row(pool, tid)
|
||||
assert row["state"] == "done"
|
||||
assert row["locked_by"] is None
|
||||
|
||||
|
||||
async def test_fail_under_max_attempts_requeues_with_future_run_after(pool: DictPool) -> None:
|
||||
iid = await make_instance(pool)
|
||||
repo = TaskRepo(pool)
|
||||
tid = await repo.enqueue_standalone(iid, TaskKind.PROVISION)
|
||||
claimed = await repo.claim("w1")
|
||||
assert claimed is not None
|
||||
assert claimed.attempts == 1 # attempts increments at CLAIM time, not on failure
|
||||
|
||||
await repo.fail(tid, "helm exploded", max_attempts=5)
|
||||
row = await _task_row(pool, tid)
|
||||
assert row["state"] == "queued"
|
||||
assert row["last_error"] == "helm exploded"
|
||||
assert row["locked_by"] is None
|
||||
# Backoff pushed it out; it must not be immediately runnable again.
|
||||
run_after = row["run_after"]
|
||||
assert isinstance(run_after, datetime)
|
||||
assert run_after >= datetime.now(UTC) - timedelta(seconds=1)
|
||||
|
||||
|
||||
async def test_fail_at_max_attempts_dead_letters_and_marks_instance(pool: DictPool) -> None:
|
||||
"""A dead-letter state, not an infinite retry."""
|
||||
iid = await make_instance(pool)
|
||||
tasks, instances = TaskRepo(pool), InstanceRepo(pool)
|
||||
tid = await tasks.enqueue_standalone(iid, TaskKind.PROVISION)
|
||||
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute("update tasks set attempts = 5 where id = %s", (tid,))
|
||||
|
||||
await tasks.fail(tid, "chart not found", max_attempts=5)
|
||||
|
||||
row = await _task_row(pool, tid)
|
||||
assert row["state"] == "failed"
|
||||
inst = await instances.get(iid, team="platform")
|
||||
assert inst is not None
|
||||
assert inst.state is InstanceState.FAILED
|
||||
assert inst.error == "chart not found"
|
||||
|
||||
|
||||
async def test_fail_does_not_resurrect_a_deleted_instance(pool: DictPool) -> None:
|
||||
"""Raw SQL must obey the same state machine domain.transition() enforces.
|
||||
|
||||
LEGAL[DELETED] is empty — deleted is terminal. A deprovision task that exhausts its
|
||||
retries after the instance is already gone must record nothing on it, not drag it
|
||||
back to 'failed'.
|
||||
"""
|
||||
iid = await make_instance(pool, state=InstanceState.DELETED)
|
||||
tasks, instances = TaskRepo(pool), InstanceRepo(pool)
|
||||
tid = await tasks.enqueue_standalone(iid, TaskKind.DEPROVISION)
|
||||
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute("update tasks set attempts = 5 where id = %s", (tid,))
|
||||
|
||||
await tasks.fail(tid, "helm uninstall kept failing", max_attempts=5)
|
||||
|
||||
# The task still dead-letters — that part is unconditional.
|
||||
row = await _task_row(pool, tid)
|
||||
assert row["state"] == "failed"
|
||||
|
||||
# But the instance stays deleted.
|
||||
inst = await instances.get(iid, team="platform")
|
||||
assert inst is not None
|
||||
assert inst.state is InstanceState.DELETED, "raw SQL bypassed the state machine"
|
||||
assert inst.error is None
|
||||
|
||||
|
||||
async def test_fail_truncates_error_to_2kb(pool: DictPool) -> None:
|
||||
iid = await make_instance(pool)
|
||||
repo = TaskRepo(pool)
|
||||
tid = await repo.enqueue_standalone(iid, TaskKind.PROVISION)
|
||||
await repo.claim("w1")
|
||||
await repo.fail(tid, "x" * 9000, max_attempts=5)
|
||||
row = await _task_row(pool, tid)
|
||||
assert isinstance(row["last_error"], str)
|
||||
assert len(row["last_error"]) == 2000
|
||||
|
||||
|
||||
async def test_run_after_in_the_future_is_not_claimable(pool: DictPool) -> None:
|
||||
iid = await make_instance(pool)
|
||||
repo = TaskRepo(pool)
|
||||
future = datetime.now(UTC) + timedelta(hours=1)
|
||||
await repo.enqueue_standalone(iid, TaskKind.PROVISION, run_after=future)
|
||||
assert await repo.claim("w1") is None
|
||||
|
||||
|
||||
async def test_reset_expired_leases_recovers_a_dead_workers_task(pool: DictPool) -> None:
|
||||
"""No distributed lock survives a power cut. Only the lease recovers this row."""
|
||||
iid = await make_instance(pool)
|
||||
repo = TaskRepo(pool)
|
||||
tid = await repo.enqueue_standalone(iid, TaskKind.PROVISION)
|
||||
claimed = await repo.claim("worker-that-will-die")
|
||||
assert claimed is not None
|
||||
|
||||
# Simulate: the worker was SIGKILLed 10 minutes ago and never reported.
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute("update tasks set locked_at = now() - interval '10 minutes' where id = %s", (tid,))
|
||||
|
||||
n = await repo.reset_expired_leases(lease_seconds=300)
|
||||
assert n == 1
|
||||
row = await _task_row(pool, tid)
|
||||
assert row["state"] == TaskState.QUEUED.value
|
||||
assert row["locked_by"] is None
|
||||
|
||||
# And it is claimable again.
|
||||
assert await repo.claim("w2") is not None
|
||||
|
||||
|
||||
async def test_fresh_lease_is_not_reset(pool: DictPool) -> None:
|
||||
iid = await make_instance(pool)
|
||||
repo = TaskRepo(pool)
|
||||
await repo.enqueue_standalone(iid, TaskKind.PROVISION)
|
||||
await repo.claim("w1")
|
||||
assert await repo.reset_expired_leases(lease_seconds=300) == 0
|
||||
@@ -0,0 +1,203 @@
|
||||
"""The worker loop: drains on SIGTERM, retries, stays idempotent.
|
||||
|
||||
These run in milliseconds against a FakeProvisioner. That is the payoff for putting helm
|
||||
behind a Protocol in Module 5: the crash-safety properties are testable without a cluster.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
from services.worker.deps import WorkerDeps
|
||||
from services.worker.main import run_worker
|
||||
from svcforge_core.adapters.clock import SystemClock
|
||||
from svcforge_core.domain.catalog import load_catalog
|
||||
from svcforge_core.domain.models import TaskKind
|
||||
from svcforge_core.domain.states import InstanceState
|
||||
from svcforge_core.repo.db import DictPool
|
||||
from svcforge_core.repo.instances import InstanceRepo
|
||||
from svcforge_core.repo.tasks import TaskRepo
|
||||
from svcforge_core.settings import Settings
|
||||
from tests.fakes import FakeNotifier, FakeProvisioner
|
||||
from tests.integration.helpers import build_instance
|
||||
|
||||
CATALOG = load_catalog(Path(__file__).resolve().parents[2] / "catalog.yaml")
|
||||
|
||||
|
||||
def _settings(**over: object) -> Settings:
|
||||
base: dict[str, object] = {
|
||||
"pg_dsn": "postgresql://x:x@127.0.0.1:5432/x",
|
||||
"worker_id": "w-test",
|
||||
"worker_concurrency": 4,
|
||||
"poll_interval_s": 0.05,
|
||||
"max_attempts": 3,
|
||||
}
|
||||
base.update(over)
|
||||
return Settings(**base) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _deps(
|
||||
pool: DictPool,
|
||||
prov: FakeProvisioner,
|
||||
notifier: FakeNotifier | None = None,
|
||||
**over: object,
|
||||
) -> WorkerDeps:
|
||||
return WorkerDeps(
|
||||
pool=pool,
|
||||
instances=InstanceRepo(pool),
|
||||
tasks=TaskRepo(pool),
|
||||
provisioner=prov,
|
||||
notifier=notifier or FakeNotifier(),
|
||||
clock=SystemClock(),
|
||||
catalog=CATALOG,
|
||||
settings=_settings(**over),
|
||||
)
|
||||
|
||||
|
||||
async def _seed(pool: DictPool, **kw: object) -> tuple[str, int]:
|
||||
inst = build_instance(**kw) # type: ignore[arg-type]
|
||||
async with pool.connection() as conn:
|
||||
await InstanceRepo(pool).create(conn, inst)
|
||||
tid = await TaskRepo(pool).enqueue_standalone(inst.id, TaskKind.PROVISION)
|
||||
return str(inst.id), tid
|
||||
|
||||
|
||||
async def _state_of(pool: DictPool, tid: int) -> str:
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute("select state from tasks where id = %s", (tid,))
|
||||
row = await cur.fetchone()
|
||||
assert row is not None
|
||||
return str(row["state"])
|
||||
|
||||
|
||||
async def test_worker_provisions_and_marks_ready(pool: DictPool) -> None:
|
||||
iid, tid = await _seed(pool)
|
||||
prov = FakeProvisioner()
|
||||
notifier = FakeNotifier()
|
||||
stop = asyncio.Event()
|
||||
|
||||
worker = asyncio.create_task(run_worker(_deps(pool, prov, notifier), stop))
|
||||
await asyncio.sleep(0.5)
|
||||
stop.set()
|
||||
await asyncio.wait_for(worker, timeout=5)
|
||||
|
||||
assert await _state_of(pool, tid) == "done"
|
||||
inst = await InstanceRepo(pool).get(UUID(iid), team="platform")
|
||||
assert inst is not None
|
||||
assert inst.state is InstanceState.READY
|
||||
assert inst.endpoint is not None
|
||||
assert len(prov.installed) == 1
|
||||
|
||||
# Assert the notification fired, and that it happened on the FIRST attempt.
|
||||
#
|
||||
# Without this the handler could raise after marking the instance ready — the task
|
||||
# requeues, the retry hits the idempotency early-return, and everything above still
|
||||
# passes while the worker is quietly crashing on every provision. Idempotency is
|
||||
# supposed to make crashes survivable, not invisible; asserting attempts==1 is what
|
||||
# keeps a masked crash from reading as success.
|
||||
assert notifier.events() == ["instance.ready"]
|
||||
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute("select attempts from tasks where id = %s", (tid,))
|
||||
row = await cur.fetchone()
|
||||
assert row is not None
|
||||
assert row["attempts"] == 1, "task was retried: the handler raised after doing the work"
|
||||
|
||||
|
||||
async def test_sigterm_drains_in_flight(pool: DictPool) -> None:
|
||||
"""Stop is requested mid-provision: the worker must FINISH the task, then exit.
|
||||
|
||||
Abandoning it would not lose the task — the lease would recover it — but only after
|
||||
five minutes of a tenant watching 'provisioning'. Draining costs two seconds.
|
||||
"""
|
||||
_, tid = await _seed(pool)
|
||||
prov = FakeProvisioner(delay=2.0)
|
||||
stop = asyncio.Event()
|
||||
|
||||
started = time.monotonic()
|
||||
worker = asyncio.create_task(run_worker(_deps(pool, prov), stop))
|
||||
await asyncio.sleep(0.5)
|
||||
stop.set() # mid-flight: the handler is still inside its 2s install
|
||||
|
||||
await asyncio.wait_for(worker, timeout=5)
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
assert await _state_of(pool, tid) == "done", "worker abandoned an in-flight task"
|
||||
assert elapsed >= 2.0, "worker returned before the in-flight task finished"
|
||||
assert elapsed < 5.0
|
||||
|
||||
|
||||
async def test_idle_worker_stops_promptly(pool: DictPool) -> None:
|
||||
"""Nothing queued: stop must wake the poll sleep, not wait it out."""
|
||||
stop = asyncio.Event()
|
||||
worker = asyncio.create_task(run_worker(_deps(pool, FakeProvisioner(), poll_interval_s=5.0), stop))
|
||||
await asyncio.sleep(0.2)
|
||||
started = time.monotonic()
|
||||
stop.set()
|
||||
await asyncio.wait_for(worker, timeout=2)
|
||||
assert time.monotonic() - started < 1.0, "stop did not interrupt the poll sleep"
|
||||
|
||||
|
||||
async def test_failed_task_is_requeued_with_backoff(pool: DictPool) -> None:
|
||||
_, tid = await _seed(pool)
|
||||
prov = FakeProvisioner(fail_on={"platform-elasticsearch"})
|
||||
stop = asyncio.Event()
|
||||
|
||||
worker = asyncio.create_task(run_worker(_deps(pool, prov), stop))
|
||||
await asyncio.sleep(0.6)
|
||||
stop.set()
|
||||
await asyncio.wait_for(worker, timeout=5)
|
||||
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute("select state, attempts, last_error from tasks where id = %s", (tid,))
|
||||
row = await cur.fetchone()
|
||||
assert row is not None
|
||||
assert row["state"] == "queued" # requeued, not failed — attempts remain
|
||||
assert row["attempts"] >= 1
|
||||
assert row["last_error"]
|
||||
|
||||
|
||||
async def test_provision_twice_installs_once(pool: DictPool) -> None:
|
||||
"""The idempotency claim, executed.
|
||||
|
||||
Simulates the crash window: the release is installed and the instance is READY, but
|
||||
the task got re-queued (worker died before reporting). Re-running must not re-install.
|
||||
"""
|
||||
from services.worker.handlers import handle_provision
|
||||
|
||||
inst = build_instance(state=InstanceState.REQUESTED)
|
||||
async with pool.connection() as conn:
|
||||
await InstanceRepo(pool).create(conn, inst)
|
||||
tid = await TaskRepo(pool).enqueue_standalone(inst.id, TaskKind.PROVISION)
|
||||
task = await TaskRepo(pool).claim("w1")
|
||||
assert task is not None
|
||||
|
||||
prov = FakeProvisioner()
|
||||
deps = _deps(pool, prov)
|
||||
|
||||
await handle_provision(task, deps)
|
||||
await handle_provision(task, deps) # the redelivery
|
||||
|
||||
assert len(prov.installed) == 1, "second run re-installed: handler is not idempotent"
|
||||
assert tid == task.id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("concurrency", [1, 4])
|
||||
async def test_concurrency_cap_is_respected(pool: DictPool, concurrency: int) -> None:
|
||||
"""The semaphore is what stops one worker from starting 200 helm processes."""
|
||||
for _ in range(6):
|
||||
await _seed(pool)
|
||||
|
||||
prov = FakeProvisioner(delay=0.2)
|
||||
stop = asyncio.Event()
|
||||
worker = asyncio.create_task(run_worker(_deps(pool, prov, worker_concurrency=concurrency), stop))
|
||||
await asyncio.sleep(0.5)
|
||||
stop.set()
|
||||
await asyncio.wait_for(worker, timeout=10)
|
||||
|
||||
assert prov.max_concurrent <= concurrency, f"ran {prov.max_concurrent} at once, cap was {concurrency}"
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Unit tests for retry backoff math."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from hypothesis import given
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from svcforge_core.domain.backoff import next_attempt_at
|
||||
|
||||
T = datetime(2026, 7, 17, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_attempt_zero_full_jitter_is_base() -> None:
|
||||
assert next_attempt_at(0, now=T, rand=lambda: 1.0) == T + timedelta(seconds=2)
|
||||
|
||||
|
||||
def test_zero_jitter_returns_now() -> None:
|
||||
assert next_attempt_at(0, now=T, rand=lambda: 0.0) == T
|
||||
|
||||
|
||||
def test_negative_attempt_raises_value_error() -> None:
|
||||
with pytest.raises(ValueError, match="attempt"):
|
||||
next_attempt_at(-1, now=T)
|
||||
|
||||
|
||||
@given(attempt=st.integers(min_value=0, max_value=64), r=st.floats(min_value=0.0, max_value=1.0))
|
||||
def test_delay_is_always_within_zero_and_cap(attempt: int, r: float) -> None:
|
||||
cap_s = 300.0
|
||||
got = next_attempt_at(attempt, now=T, cap_s=cap_s, rand=lambda: r)
|
||||
delay = (got - T).total_seconds()
|
||||
assert 0.0 <= delay <= cap_s
|
||||
|
||||
|
||||
@given(attempt=st.integers(min_value=0, max_value=63))
|
||||
def test_delay_is_non_decreasing_in_attempt(attempt: int) -> None:
|
||||
def ceiling_of(a: int) -> float:
|
||||
return (next_attempt_at(a, now=T, rand=lambda: 1.0) - T).total_seconds()
|
||||
|
||||
assert ceiling_of(attempt) <= ceiling_of(attempt + 1)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Unit tests for catalog loading and validation."""
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from svcforge_core.domain.catalog import CatalogError, load_catalog
|
||||
from svcforge_core.domain.models import CatalogEntry
|
||||
|
||||
VALID_YAML = textwrap.dedent("""
|
||||
services:
|
||||
redis:
|
||||
chart: bitnamilegacy/redis
|
||||
chart_version: 20.6.2
|
||||
sizes:
|
||||
small:
|
||||
replicas: 1
|
||||
resources:
|
||||
requests: {cpu: 100m, memory: 256Mi}
|
||||
medium:
|
||||
replicas: 3
|
||||
resources:
|
||||
requests: {cpu: 500m, memory: 1Gi}
|
||||
postgres:
|
||||
chart: bitnamilegacy/postgresql
|
||||
chart_version: 16.4.5
|
||||
sizes:
|
||||
small:
|
||||
replicas: 1
|
||||
resources:
|
||||
requests: {cpu: 250m, memory: 512Mi}
|
||||
""")
|
||||
|
||||
MISSING_CHART_VERSION_YAML = textwrap.dedent("""
|
||||
services:
|
||||
redis:
|
||||
chart: bitnamilegacy/redis
|
||||
sizes:
|
||||
small:
|
||||
replicas: 1
|
||||
resources: {}
|
||||
""")
|
||||
|
||||
ZERO_REPLICAS_YAML = textwrap.dedent("""
|
||||
services:
|
||||
redis:
|
||||
chart: bitnamilegacy/redis
|
||||
chart_version: 20.6.2
|
||||
sizes:
|
||||
small:
|
||||
replicas: 0
|
||||
resources: {}
|
||||
""")
|
||||
|
||||
|
||||
def _write(tmp_path: Path, body: str) -> Path:
|
||||
path = tmp_path / "catalog.yaml"
|
||||
path.write_text(body)
|
||||
return path
|
||||
|
||||
|
||||
def test_valid_yaml_loads_to_catalog_entries(tmp_path: Path) -> None:
|
||||
catalog = load_catalog(_write(tmp_path, VALID_YAML))
|
||||
|
||||
assert set(catalog) == {"redis", "postgres"}
|
||||
assert all(isinstance(entry, CatalogEntry) for entry in catalog.values())
|
||||
redis = catalog["redis"]
|
||||
assert redis.service_type == "redis"
|
||||
assert redis.chart_version == "20.6.2"
|
||||
assert set(redis.sizes) == {"small", "medium"}
|
||||
assert redis.sizes["medium"].replicas == 3
|
||||
|
||||
|
||||
def test_missing_chart_version_raises_catalog_error(tmp_path: Path) -> None:
|
||||
with pytest.raises(CatalogError) as excinfo:
|
||||
load_catalog(_write(tmp_path, MISSING_CHART_VERSION_YAML))
|
||||
|
||||
assert excinfo.value.key == "redis"
|
||||
assert "redis" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_zero_replicas_raises_catalog_error(tmp_path: Path) -> None:
|
||||
with pytest.raises(CatalogError) as excinfo:
|
||||
load_catalog(_write(tmp_path, ZERO_REPLICAS_YAML))
|
||||
|
||||
assert excinfo.value.key == "redis"
|
||||
|
||||
|
||||
def test_missing_file_raises_catalog_error(tmp_path: Path) -> None:
|
||||
with pytest.raises(CatalogError, match="cannot read catalog"):
|
||||
load_catalog(tmp_path / "nope.yaml")
|
||||
|
||||
|
||||
def test_unparseable_yaml_raises_catalog_error(tmp_path: Path) -> None:
|
||||
with pytest.raises(CatalogError, match="not valid YAML"):
|
||||
load_catalog(_write(tmp_path, "services: [unclosed\n"))
|
||||
|
||||
|
||||
def test_scalar_root_raises_catalog_error(tmp_path: Path) -> None:
|
||||
with pytest.raises(CatalogError, match="must be a mapping"):
|
||||
load_catalog(_write(tmp_path, "just-a-string\n"))
|
||||
|
||||
|
||||
def test_non_mapping_services_raises_catalog_error(tmp_path: Path) -> None:
|
||||
with pytest.raises(CatalogError, match="'services' must be a mapping"):
|
||||
load_catalog(_write(tmp_path, "services:\n - redis\n"))
|
||||
|
||||
|
||||
def test_non_mapping_entry_raises_catalog_error_naming_the_key(tmp_path: Path) -> None:
|
||||
with pytest.raises(CatalogError) as excinfo:
|
||||
load_catalog(_write(tmp_path, "services:\n redis: just-a-string\n"))
|
||||
|
||||
assert excinfo.value.key == "redis"
|
||||
assert "must be a mapping" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_non_string_field_key_raises_catalog_error_naming_the_key(tmp_path: Path) -> None:
|
||||
"""A non-string YAML key inside an entry breaks `**body`; it must surface as CatalogError."""
|
||||
body = textwrap.dedent("""
|
||||
services:
|
||||
redis:
|
||||
1: oops
|
||||
chart: bitnamilegacy/redis
|
||||
chart_version: 20.6.2
|
||||
sizes: {}
|
||||
""")
|
||||
with pytest.raises(CatalogError) as excinfo:
|
||||
load_catalog(_write(tmp_path, body))
|
||||
|
||||
assert excinfo.value.key == "redis"
|
||||
|
||||
|
||||
def test_bare_mapping_without_services_key_is_accepted(tmp_path: Path) -> None:
|
||||
"""The top-level `services:` wrapper is optional; a bare service_type mapping also loads."""
|
||||
body = textwrap.dedent("""
|
||||
redis:
|
||||
chart: bitnamilegacy/redis
|
||||
chart_version: 20.6.2
|
||||
sizes:
|
||||
small:
|
||||
replicas: 1
|
||||
resources: {}
|
||||
""")
|
||||
catalog = load_catalog(_write(tmp_path, body))
|
||||
|
||||
assert set(catalog) == {"redis"}
|
||||
|
||||
|
||||
def test_repo_catalog_yaml_is_valid() -> None:
|
||||
catalog = load_catalog(Path(__file__).parents[2] / "catalog.yaml")
|
||||
|
||||
assert set(catalog) == {"elasticsearch", "redis", "postgres"}
|
||||
for entry in catalog.values():
|
||||
assert set(entry.sizes) == {"small", "medium"}
|
||||
@@ -0,0 +1,272 @@
|
||||
"""obs.py: the three things that are wrong by default.
|
||||
|
||||
Not tested here: that structlog logs, that prometheus counts, that OTEL traces. Those are
|
||||
the libraries' tests. What is tested is every place where the default is a bug — the
|
||||
histogram buckets, the context that does not cross a queue, and the contextvars that leak
|
||||
between tasks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import structlog
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from prometheus_client import REGISTRY
|
||||
|
||||
from svcforge_core import obs
|
||||
from svcforge_core.settings import Settings
|
||||
|
||||
# W3C traceparent: version-traceid-spanid-flags.
|
||||
#
|
||||
# The flags byte is matched loosely and the sampled bit checked separately, on purpose.
|
||||
# Module 7's acceptance line says `00-<32 hex>-<16 hex>-01`, and current SDKs emit `-03`:
|
||||
# bit 0x01 is `sampled`, and bit 0x02 is `random-trace-id` from trace-context level 2. An
|
||||
# assertion pinned to `-01` fails on a spec revision that changed nothing we care about.
|
||||
# What we care about is the trace being sampled, which is bit 0x01 and nothing else.
|
||||
TRACEPARENT_RE = re.compile(
|
||||
r"^00-(?P<trace_id>[0-9a-f]{32})-(?P<span_id>[0-9a-f]{16})-(?P<flags>[0-9a-f]{2})$"
|
||||
)
|
||||
SAMPLED_BIT = 0x01
|
||||
|
||||
# A provision takes minutes. prometheus_client's default buckets end at 10 seconds.
|
||||
DEFAULT_TOP_BUCKET = 10.0
|
||||
|
||||
|
||||
def _settings(**over: object) -> Settings:
|
||||
return Settings(pg_dsn="postgresql://u:p@localhost:5432/db", **over) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def logs() -> Iterator[io.StringIO]:
|
||||
"""setup() against a captured stream, restoring the process-wide config afterwards.
|
||||
|
||||
setup() is deliberately global and deliberately once-only, which makes it deliberately
|
||||
awkward to test. Reaching into the module flag is the honest price of that; the
|
||||
alternative is a seam that exists only for tests.
|
||||
"""
|
||||
stream = io.StringIO()
|
||||
saved_handlers = logging.getLogger().handlers[:]
|
||||
saved_level = logging.getLogger().level
|
||||
saved_config = structlog.get_config()
|
||||
|
||||
obs._configured = False
|
||||
obs.setup("test-service", _settings())
|
||||
|
||||
# Re-point the handler setup() installed at our buffer; everything else it configured —
|
||||
# processors, formatter, the JSON renderer last — is exactly what production gets.
|
||||
handler = logging.getLogger().handlers[0]
|
||||
assert isinstance(handler, logging.StreamHandler)
|
||||
handler.setStream(stream)
|
||||
|
||||
try:
|
||||
yield stream
|
||||
finally:
|
||||
structlog.contextvars.clear_contextvars()
|
||||
structlog.configure(**saved_config)
|
||||
logging.getLogger().handlers = saved_handlers
|
||||
logging.getLogger().setLevel(saved_level)
|
||||
obs._configured = False
|
||||
|
||||
|
||||
def _lines(stream: io.StringIO) -> list[dict[str, Any]]:
|
||||
return [json.loads(line) for line in stream.getvalue().splitlines() if line.strip()]
|
||||
|
||||
|
||||
# --- Buckets ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_provision_histogram_has_a_bucket_for_a_thirty_minute_provision() -> None:
|
||||
"""The acceptance check, as a unit test: `le="1800"` exists.
|
||||
|
||||
With the library defaults the top finite bucket is 10s, every provision lands in +Inf,
|
||||
and histogram_quantile interpolates across a bucket spanning 10s to infinity. The p95
|
||||
it returns is not slow or fast, it is meaningless — and it is meaningless silently,
|
||||
which is why this is asserted rather than eyeballed on a dashboard.
|
||||
"""
|
||||
bounds = obs.PROVISION_TIME._upper_bounds
|
||||
assert 1800.0 in bounds
|
||||
assert bounds[-1] == float("inf")
|
||||
assert max(b for b in bounds if b != float("inf")) > DEFAULT_TOP_BUCKET
|
||||
|
||||
|
||||
def test_provision_histogram_exports_the_1800_bucket() -> None:
|
||||
"""Same claim, checked through the exposition format the scrape actually reads."""
|
||||
obs.PROVISION_TIME.observe(42.0)
|
||||
bucket = REGISTRY.get_sample_value("svcforge_provision_duration_seconds_bucket", {"le": "1800.0"})
|
||||
assert bucket is not None
|
||||
|
||||
|
||||
def test_metric_names_are_the_ones_the_alerts_query() -> None:
|
||||
"""The four alerts are PromQL strings in values.yaml; nothing type-checks them.
|
||||
|
||||
A rename here is a silent alert that never fires again. This test is the link between
|
||||
the chart's rules and the code.
|
||||
"""
|
||||
for name in (
|
||||
"svcforge_tasks_claimed_total",
|
||||
"svcforge_tasks_failed_total",
|
||||
"svcforge_provision_duration_seconds",
|
||||
"svcforge_queue_depth",
|
||||
"svcforge_instances",
|
||||
"svcforge_reconciler_last_tick_timestamp_seconds",
|
||||
):
|
||||
assert REGISTRY._names_to_collectors.get(name) is not None, f"{name} is queried by an alert"
|
||||
|
||||
|
||||
# --- Trace context across the queue -------------------------------------------------------
|
||||
|
||||
|
||||
def test_traceparent_round_trips_through_a_string() -> None:
|
||||
"""inject -> a W3C string -> extract -> the same trace. This is the queue crossing."""
|
||||
provider = TracerProvider()
|
||||
tracer = provider.get_tracer("test")
|
||||
|
||||
with tracer.start_as_current_span("api.post") as span:
|
||||
traceparent = obs.inject_traceparent()
|
||||
api_trace_id = span.get_span_context().trace_id
|
||||
|
||||
assert traceparent is not None
|
||||
match = TRACEPARENT_RE.match(traceparent)
|
||||
assert match is not None, traceparent
|
||||
assert match["trace_id"] == format(api_trace_id, "032x")
|
||||
assert int(match["flags"], 16) & SAMPLED_BIT, "unsampled: the worker's span would be dropped"
|
||||
|
||||
# The worker's side: a fresh context, minutes later, in another process.
|
||||
ctx = obs.context_from_traceparent(traceparent)
|
||||
with tracer.start_as_current_span("worker.claim", context=ctx) as worker_span:
|
||||
assert worker_span.get_span_context().trace_id == api_trace_id
|
||||
|
||||
|
||||
def test_inject_returns_none_without_a_span() -> None:
|
||||
"""A task the reconciler enqueued has no inbound request. Null column, not an error."""
|
||||
assert obs.inject_traceparent() is None
|
||||
|
||||
|
||||
def test_context_from_traceparent_survives_none_and_garbage() -> None:
|
||||
"""A malformed traceparent must start a new trace, never fail a provision.
|
||||
|
||||
Extract does not raise on bad input — it returns a context with no span. Asserted here
|
||||
because the alternative would be a tenant's provision failing over a telemetry header.
|
||||
"""
|
||||
for bad in (None, "", "not-a-traceparent", "00-tooshort-01"):
|
||||
ctx = obs.context_from_traceparent(bad)
|
||||
assert not trace.get_current_span(ctx).get_span_context().is_valid
|
||||
|
||||
|
||||
# --- Contextvars --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_every_line_carries_instance_id_task_id_and_team(logs: io.StringIO) -> None:
|
||||
"""The acceptance check: bind once at claim, and the keys ride on every line after."""
|
||||
instance_id = uuid4()
|
||||
obs.bind_task_context(instance_id, 42, "platform")
|
||||
|
||||
obs.get_logger("t").info("provision.started")
|
||||
obs.get_logger("t").warning("helm.slow")
|
||||
|
||||
for line in _lines(logs):
|
||||
assert line["instance_id"] == str(instance_id)
|
||||
assert line["task_id"] == 42
|
||||
assert line["team"] == "platform"
|
||||
|
||||
|
||||
def test_foreign_stdlib_logs_are_json_and_carry_the_context(logs: io.StringIO) -> None:
|
||||
"""psycopg and uvicorn log through stdlib `logging`, and their lines must parse too.
|
||||
|
||||
Without the ProcessorFormatter bridge these arrive as bare text on the same stdout, and
|
||||
every one of them is a parse failure in the collector.
|
||||
"""
|
||||
obs.bind_task_context(uuid4(), 7, "payments")
|
||||
logging.getLogger("some.library").warning("connection reset")
|
||||
|
||||
line = _lines(logs)[-1]
|
||||
assert line["event"] == "connection reset"
|
||||
assert line["task_id"] == 7
|
||||
assert line["team"] == "payments"
|
||||
|
||||
|
||||
def test_bind_task_context_clears_the_previous_task(logs: io.StringIO) -> None:
|
||||
"""The bug this prevents: task 2's log line naming task 1's tenant.
|
||||
|
||||
A worker coroutine reuses its context across claim-loop iterations. Bind without
|
||||
clearing and the stale instance_id survives into the next task, which means the log for
|
||||
the incident you are debugging points at the wrong customer.
|
||||
"""
|
||||
obs.bind_task_context(uuid4(), 1, "team-a")
|
||||
second = uuid4()
|
||||
obs.bind_task_context(second, 2, "team-b")
|
||||
|
||||
obs.get_logger("t").info("claimed")
|
||||
|
||||
line = _lines(logs)[-1]
|
||||
assert line["instance_id"] == str(second)
|
||||
assert line["task_id"] == 2
|
||||
assert line["team"] == "team-b"
|
||||
|
||||
|
||||
def test_json_renderer_is_last_and_output_is_one_object_per_line(logs: io.StringIO) -> None:
|
||||
"""JSONRenderer last in the chain, and nothing after it.
|
||||
|
||||
A processor appended after the renderer receives a `str` where it expects a dict and
|
||||
raises. The symptom is not a crash — structlog's default is to fail the log call, so
|
||||
the line simply never appears.
|
||||
"""
|
||||
obs.get_logger("t").info("hello", extra_key="value")
|
||||
|
||||
lines = _lines(logs)
|
||||
assert len(lines) == 1
|
||||
assert lines[0]["event"] == "hello"
|
||||
assert lines[0]["extra_key"] == "value"
|
||||
assert lines[0]["level"] == "info"
|
||||
assert lines[0]["service"] == "test-service"
|
||||
assert "timestamp" in lines[0]
|
||||
|
||||
|
||||
def test_setup_is_idempotent(logs: io.StringIO) -> None:
|
||||
"""Called twice, one handler, one line. Not a nicety: two handlers is two of every line.
|
||||
|
||||
Each service calls setup() from its entrypoint, and an entrypoint that imports another
|
||||
entrypoint (the CLI shelling into the reconciler) calls it twice.
|
||||
"""
|
||||
obs.setup("test-service", _settings())
|
||||
obs.setup("test-service", _settings())
|
||||
|
||||
# Count ours, not pytest's — its capture handler is on the root logger too.
|
||||
ours = [
|
||||
h
|
||||
for h in logging.getLogger().handlers
|
||||
if isinstance(h.formatter, structlog.stdlib.ProcessorFormatter)
|
||||
]
|
||||
assert len(ours) == 1
|
||||
|
||||
obs.get_logger("t").info("once")
|
||||
assert len(_lines(logs)) == 1
|
||||
|
||||
|
||||
def test_metrics_are_single_process_in_memory() -> None:
|
||||
"""One process per pod, scale with replicas. Multiprocess mode is not in use.
|
||||
|
||||
`ValueClass` is prometheus_client's fork in the road, chosen at import from
|
||||
PROMETHEUS_MULTIPROC_DIR: MutexValue keeps counters in memory, MultiProcessValue mmaps
|
||||
them into a shared directory. This repo takes the other fix for `uvicorn --workers 4`
|
||||
corrupting counters — one process per pod — so MutexValue is the correct answer, and a
|
||||
stray PROMETHEUS_MULTIPROC_DIR in a Deployment's env would silently change it to the
|
||||
other one along with the meaning of every gauge.
|
||||
"""
|
||||
from prometheus_client import values
|
||||
|
||||
assert values.ValueClass is values.MutexValue
|
||||
|
||||
# And the registry the services expose is the default in-memory one, not a
|
||||
# MultiProcessCollector reading files off disk.
|
||||
obs.QUEUE_DEPTH.set(3)
|
||||
assert REGISTRY.get_sample_value("svcforge_queue_depth") == 3.0
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Every Protocol, with both of its implementations, checked by mypy.
|
||||
|
||||
These functions have no assertions and cannot fail at runtime — the check happens under
|
||||
`mypy --strict`. If FakeProvisioner drifts from Provisioner (a renamed parameter, a changed
|
||||
return type), the type-check fails here rather than the fake quietly diverging from the real
|
||||
adapter and the worker's fast tests proving nothing about production.
|
||||
|
||||
The test bodies exist so pytest runs the imports too: a Protocol satisfied statically but
|
||||
broken at import time is still broken.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from svcforge_core.adapters.clock import Clock, SystemClock
|
||||
from svcforge_core.adapters.helm import HelmProvisioner, Provisioner
|
||||
from svcforge_core.adapters.notify import LogNotifier, Notifier, WebhookNotifier
|
||||
from svcforge_core.adapters.redis import (
|
||||
IdempotencyStore,
|
||||
IdempotencyStoreProto,
|
||||
InstanceCache,
|
||||
InstanceCacheProto,
|
||||
RateLimiter,
|
||||
RateLimiterProto,
|
||||
make_redis,
|
||||
)
|
||||
from svcforge_core.settings import Settings
|
||||
from tests.fakes import (
|
||||
FakeClock,
|
||||
FakeIdempotencyStore,
|
||||
FakeInstanceCache,
|
||||
FakeNotifier,
|
||||
FakeProvisioner,
|
||||
FakeRateLimiter,
|
||||
)
|
||||
|
||||
|
||||
def take(p: Provisioner) -> None:
|
||||
"""Accepts anything structurally a Provisioner. The whole assertion is the annotation."""
|
||||
|
||||
|
||||
def take_clock(c: Clock) -> None: ...
|
||||
|
||||
|
||||
def take_notifier(n: Notifier) -> None: ...
|
||||
|
||||
|
||||
def test_provisioner_implementations_conform() -> None:
|
||||
take(HelmProvisioner(kubeconfig=Path("/dev/null")))
|
||||
take(FakeProvisioner())
|
||||
print("ok")
|
||||
|
||||
|
||||
def test_clock_implementations_conform() -> None:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
take_clock(SystemClock())
|
||||
take_clock(FakeClock(start=datetime(2026, 1, 1, tzinfo=UTC)))
|
||||
print("ok")
|
||||
|
||||
|
||||
def test_notifier_implementations_conform() -> None:
|
||||
take_notifier(LogNotifier())
|
||||
take_notifier(WebhookNotifier(url="https://example.invalid/hook"))
|
||||
take_notifier(FakeNotifier())
|
||||
print("ok")
|
||||
|
||||
|
||||
def take_limiter(rl: RateLimiterProto) -> None: ...
|
||||
|
||||
|
||||
def take_idempotency(store: IdempotencyStoreProto) -> None: ...
|
||||
|
||||
|
||||
def take_cache(c: InstanceCacheProto) -> None: ...
|
||||
|
||||
|
||||
def test_redis_implementations_conform() -> None:
|
||||
"""A client at a closed port is still a Redis. Nothing here connects — no commands billed.
|
||||
|
||||
That is not a trick to keep the test fast; it is the module's thesis restated as a
|
||||
fixture. Every one of these classes has to be constructible and callable with Redis
|
||||
unreachable, because that is the state they are designed for.
|
||||
"""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
settings = Settings(
|
||||
# pydantic parses these strings into PostgresDsn/RedisDsn at runtime; the
|
||||
# annotation names the parsed type, so the ignores sit on the arguments.
|
||||
pg_dsn="postgresql://unused:unused@127.0.0.1:5432/unused", # type: ignore[arg-type]
|
||||
redis_dsn="redis://127.0.0.1:1/0", # type: ignore[arg-type]
|
||||
)
|
||||
r = make_redis(settings)
|
||||
assert r is not None
|
||||
|
||||
take_limiter(RateLimiter(r, limit=10, window_s=60))
|
||||
take_limiter(FakeRateLimiter(10, 60, FakeClock(start=datetime(2026, 1, 1, tzinfo=UTC))))
|
||||
take_idempotency(IdempotencyStore(r))
|
||||
take_idempotency(FakeIdempotencyStore())
|
||||
take_cache(InstanceCache(r))
|
||||
take_cache(FakeInstanceCache())
|
||||
|
||||
# redis_dsn=None EXPLICITLY. Omitting it does not mean "unset": pydantic-settings
|
||||
# reads SVCFORGE_REDIS_DSN from the environment, so on any machine that has the real
|
||||
# DSN exported this assertion sees a live Upstash client and fails — a green test that
|
||||
# depends on your shell being empty is not a test.
|
||||
assert make_redis(Settings(pg_dsn=settings.pg_dsn, redis_dsn=None)) is None
|
||||
print("ok")
|
||||
@@ -0,0 +1,4 @@
|
||||
def test_import_core() -> None:
|
||||
import svcforge_core
|
||||
|
||||
assert svcforge_core is not None
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Unit tests for the instance state machine."""
|
||||
|
||||
import pytest
|
||||
|
||||
from svcforge_core.domain.states import LEGAL, IllegalTransition, InstanceState, transition
|
||||
|
||||
|
||||
def test_requested_to_provisioning_is_legal() -> None:
|
||||
assert transition(InstanceState.REQUESTED, InstanceState.PROVISIONING) is InstanceState.PROVISIONING
|
||||
|
||||
|
||||
def test_deleted_to_ready_raises() -> None:
|
||||
with pytest.raises(IllegalTransition):
|
||||
transition(InstanceState.DELETED, InstanceState.READY)
|
||||
|
||||
|
||||
def test_failed_to_provisioning_is_legal_retry() -> None:
|
||||
assert transition(InstanceState.FAILED, InstanceState.PROVISIONING) is InstanceState.PROVISIONING
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", list(InstanceState))
|
||||
def test_every_state_has_a_legal_entry(state: InstanceState) -> None:
|
||||
"""A new state with no LEGAL entry must fail the suite, not KeyError at runtime."""
|
||||
assert state in LEGAL
|
||||
assert isinstance(LEGAL[state], frozenset)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", list(InstanceState))
|
||||
def test_every_legal_target_is_an_instance_state(state: InstanceState) -> None:
|
||||
for target in LEGAL[state]:
|
||||
assert isinstance(target, InstanceState)
|
||||
|
||||
|
||||
def test_deleted_is_terminal_with_an_empty_frozenset() -> None:
|
||||
assert LEGAL[InstanceState.DELETED] == frozenset()
|
||||
|
||||
|
||||
def test_strenum_compares_equal_to_its_value() -> None:
|
||||
# mypy calls this non-overlapping by declared type. That is exactly what is being
|
||||
# tested: StrEnum members ARE their values at runtime, which is why psycopg can
|
||||
# adapt them straight to text and model_validate round-trips them for free.
|
||||
assert (InstanceState.READY == "ready") is True # type: ignore[comparison-overlap]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user