c76154aeaa
ci / lint (push) Successful in 34s
ci / unit (push) Successful in 1m41s
ci / types (push) Successful in 1m41s
ci / dockerfile (push) Successful in 18s
ci / security (push) Successful in 1m27s
ci / chart (push) Failing after 1m11s
ci / integration (push) Successful in 1m10s
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
CORRECTNESS - lost-lease race: complete()/fail() did not check ownership, so a worker whose lease expired could mark a task done while another worker was running it, or requeue a task someone else owned. Reproduced, fixed with a CAS on (state, locked_by), pinned by two regression tests. - worker died on report failure: _run_one's docstring claimed no exception escapes the TaskGroup; fail()/complete() were outside the guarded block, so a DB blip cancelled every sibling provision on the pod. - claim query used an INNER join, which could strand a just-claimed task and report 'queue empty'. LEFT join. - InstanceRepo.set_error bypassed the state machine and had no callers. Deleted. - handle_deprovision ignored its CAS result, so a wrong-state instance kept a dangling endpoint and got re-provisioned by the drift check 60s later. - handle_verify re-notified on every retry: five pages for one halt. DEPLOY-BREAKING - the migration Job could never succeed: no Dockerfile copied migrations/, and migrate.py resolved the path relative to the source tree, which only works for an editable install. Added COPY + SVCFORGE_MIGRATIONS_DIR. - ServiceMonitor selector did not match the Service: API metrics never scraped. - SvcforgeReconcilerStale fired permanently from every pod, because the gauge is module-level and every service exports it as 0. Scoped to the reconciler job. - SvcforgeTaskFailed latched forever on a monotonic counter. Now increase()[15m]. - the digest guard accepted the all-zeros placeholder. - worker terminationGracePeriodSeconds was 60s against a 600s helm timeout. DEAD CODE THAT SHOULD NOT HAVE BEEN - adapters/k8s.py was never called, so tenant namespaces were never created and the first provision for a new team would fail. Wired into handle_provision. - adapters/redis.py was never imported by any service. Rate limiting is now wired into the API, failing open. - Settings.check_production() had no callers. Given an explicit environment and called from every entrypoint. OBSERVABILITY - the API never called obs.setup(): no JSON logs, no trace correlation, log_json silently inert. - LogNotifier's structured fields were discarded by the stdlib->structlog bridge. - bind_task_context cleared the 'service' binding for the life of every task. - split tasks_failed into task_attempts_failed and tasks_dead_lettered. SECURITY - trivy correctly blocked the worker/reconciler images: helm 3.16.2 and kubectl 1.31.2 carry CRITICAL Go stdlib CVEs. Bumped to helm 3.21.3 and kubectl 1.35.3, which also closes a four-minor skew against the v1.35.3 cluster. TESTS THAT COULD NOT FAIL - the concurrency cap test passed on a fully serial worker. - the alert/metric cross-check asserted a hardcoded list instead of reading the chart, so it could not catch a rename on the chart side. - fixed OTel tracer-provider pollution between test files. DOCS - ARCHITECTURE.md: mermaid diagrams, user stories, and the helm-vs-ArgoCD guarantee (verified with --dry-run=server). - AGENTS.md + CLAUDE.md. - prose sweep for back-and-forth phrasing across 19 files.
386 lines
18 KiB
YAML
386 lines
18 KiB
YAML
# 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.
|
|
#
|
|
# `--cov=svcforge_core.domain` — the MODULE, not a path. `--cov=libs/svcforge_core/domain`
|
|
# is a path that does not exist (the package nests one level deeper, at
|
|
# libs/svcforge_core/svcforge_core/domain), so coverage measured nothing and reported
|
|
# 0.00%. A path-based --cov that misses silently reports 0 rather than erroring, so
|
|
# without a --cov-fail-under this reads as a passing coverage gate over no code at all.
|
|
run: uv run pytest tests/unit --cov=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.
|
|
#
|
|
# `bandit[toml]` + `-c pyproject.toml`: without the toml extra bandit cannot read
|
|
# its own config and silently ignores it, which looks identical to a clean run.
|
|
run: uv run --with 'bandit[toml]' bandit -c pyproject.toml -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.
|
|
#
|
|
# Audits the LOCKED dependency set, not the installed environment. Auditing the env
|
|
# means auditing `svcforge` and `svcforge-core` too, which are ours, are installed
|
|
# editable, and are not on PyPI — under --strict that is a hard error ("distribution
|
|
# marked as editable"), so the choice was to drop --strict or to stop asking PyPI
|
|
# about packages it has never heard of. This asks about the 56 that actually came
|
|
# from PyPI, and keeps --strict.
|
|
run: |
|
|
uv export --frozen --no-dev \
|
|
--no-emit-project --no-emit-package svcforge-core \
|
|
-o /tmp/requirements-audit.txt
|
|
uv run --with pip-audit pip-audit --strict -r /tmp/requirements-audit.txt
|
|
|
|
# --- 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 9b: the chart must render --------------------------------------------------
|
|
# Without this, a chart that does not template reaches ArgoCD and fails in the cluster,
|
|
# where the error surfaces as a sync failure with no PR attached to it. `helm template`
|
|
# is the real gate: it is what ArgoCD does, and _helpers.tpl's image helper calls `fail`
|
|
# on anything that is not a full sha256 digest.
|
|
chart:
|
|
runs-on: ubuntu-latest
|
|
needs: [lint]
|
|
env:
|
|
# Pinned by digest like gitleaks and trivy, and the same helm the worker and
|
|
# reconciler images carry — CI renders with the version that ships.
|
|
HELM: alpine/helm:3.21.3@sha256:35da09ba0716fc7c3cd63b6b31ee380a9c7662e95f29ab0e4ae962420afd315b
|
|
steps:
|
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
|
|
- name: helm lint
|
|
run: |
|
|
docker run --rm -v "$PWD:/repo" -w /repo "$HELM" lint deploy/chart
|
|
|
|
- name: helm template (rejects unbumped digests)
|
|
# values.yaml ships all-zeros placeholders, so a bare `helm template` MUST fail.
|
|
# That is the guard working, not a broken chart — asserting the failure here is
|
|
# what stops the guard silently regressing into a prefix check again.
|
|
run: |
|
|
set -euo pipefail
|
|
if docker run --rm -v "$PWD:/repo" -w /repo "$HELM" \
|
|
template svcforge deploy/chart >/dev/null 2>&1; then
|
|
echo "FAIL: chart rendered against the placeholder digests in values.yaml." >&2
|
|
echo "The digest guard in _helpers.tpl is not guarding." >&2
|
|
exit 1
|
|
fi
|
|
echo "ok: placeholder digests rejected"
|
|
|
|
- name: helm template (renders with real digests)
|
|
# Dummy but well-formed digests: this checks the templates themselves render, with
|
|
# the two values-gated monitoring blocks explicitly on so they are covered too.
|
|
run: |
|
|
set -euo pipefail
|
|
A="sha256:$(printf 'a%.0s' $(seq 64))"
|
|
B="sha256:$(printf 'b%.0s' $(seq 64))"
|
|
C="sha256:$(printf 'c%.0s' $(seq 64))"
|
|
docker run --rm -v "$PWD:/repo" -w /repo "$HELM" \
|
|
template svcforge deploy/chart \
|
|
--set image.api.digest="$A" \
|
|
--set image.worker.digest="$B" \
|
|
--set image.reconciler.digest="$C" \
|
|
--set serviceMonitor.enabled=true \
|
|
--set prometheusRule.enabled=true \
|
|
>/dev/null
|
|
echo "ok: chart renders"
|
|
|
|
# --- 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, chart]
|
|
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
|
|
# Not gated to master any more: the build step now reads AND writes the layer cache
|
|
# in the registry, so every run needs credentials. Pushing the release image is
|
|
# still master-only — that gate lives on the `push by digest` step, where it belongs.
|
|
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.
|
|
#
|
|
# The layer cache is `type=registry`, NOT `type=gha`. Two reasons, both hard:
|
|
#
|
|
# 1. act_runner's cache server is backed by a 1Gi PVC that also holds `.runner`,
|
|
# the runner's own registration file. `mode=max` stores every intermediate layer
|
|
# of three images — several GB. Filling that volume does not merely lose the
|
|
# cache: the runner cannot write its state and has to be re-registered by hand.
|
|
# Trading "slow CI" for "broken CI" is not a trade.
|
|
# 2. act_runner evicts by AGE, with no size cap in its config. It will fill whatever
|
|
# it is given and then wedge. The registry has no such limit and already holds
|
|
# the images anyway.
|
|
#
|
|
# The uv/pip cache still uses the runner's cache service — that one is a few hundred
|
|
# MB and fits.
|
|
run: |
|
|
docker buildx build \
|
|
-f services/${{ matrix.svc }}/Dockerfile \
|
|
--build-arg BUILD_SHA=${{ github.sha }} \
|
|
--cache-from type=registry,ref=${REGISTRY}/${IMAGE_NS}/svcforge-${{ matrix.svc }}:buildcache \
|
|
--cache-to type=registry,ref=${REGISTRY}/${IMAGE_NS}/svcforge-${{ matrix.svc }}:buildcache,mode=max \
|
|
--load \
|
|
-t svcforge/${{ matrix.svc }}:ci \
|
|
.
|
|
|
|
- name: trivy
|
|
# Run trivy directly rather than via aquasecurity/trivy-action, for the same reason
|
|
# gitleaks is run directly above: the command is the documented one, pinned by
|
|
# digest, with no nested action resolution.
|
|
#
|
|
# It is also the only thing that works here. trivy-action internally does
|
|
# `uses: aquasecurity/setup-trivy@v0.2.2`, and that tag no longer exists upstream —
|
|
# the earliest published tag today is v0.2.6. The runner clones it and fails with
|
|
# "Unable to resolve v0.2.2: reference not found". A third-party action pinned by
|
|
# SHA still resolves ITS OWN dependencies by mutable tag, so pinning the outer
|
|
# action bought nothing.
|
|
#
|
|
# --ignore-unfixed: a CVE with no fix available is not something this PR can act
|
|
# on, and failing on it only teaches people to add ignore entries. Rebuilding on a
|
|
# new base image picks the fix up the day it exists.
|
|
#
|
|
# The mounted cache dir keeps the vuln DB (~50MB) across the three matrix legs on
|
|
# this runner instead of re-downloading it for each.
|
|
run: |
|
|
docker run --rm \
|
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
|
-v "$PWD/.trivycache:/root/.cache/trivy" \
|
|
aquasec/trivy:0.58.1@sha256:ab70a02200597efa04748f210f793936eb647cbcdb0ea69cc30b226d6f5a22c7 \
|
|
image \
|
|
--severity HIGH,CRITICAL \
|
|
--ignore-unfixed \
|
|
--exit-code 1 \
|
|
--format table \
|
|
--no-progress \
|
|
svcforge/${{ matrix.svc }}:ci
|
|
|
|
- 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=registry,ref=${REGISTRY}/${IMAGE_NS}/svcforge-${{ matrix.svc }}:buildcache \
|
|
--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: 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
|