mirror of
https://github.com/certctl-io/certctl.git
synced 2026-08-29 08:01:25 +02:00
Phase 13 Sprint 13.3 — the completion half of the ARCH-M1
substantive close. Sprint 13.2 shipped the Postgres-backed
sliding-window limiter + multi-replica integration test; Sprint 13.3
wires the 6 call sites in cmd/server/main.go through the operator-
chosen backend selector, adds the rate_limit_buckets scheduler
janitor sweep, rewrites the observability doc, exposes the env-var
in the helm chart, and promotes the multi-replica integration test
to a required CI status check.
Signature ground-truth (sprint 13.2 + 13.3)
===========================================
Prompt-template signatures: `Allow(key string) error` and "5 call
sites." Actual repo: `Allow(key string, now time.Time) error` and 6
NewSlidingWindowLimiter call sites in cmd/server/main.go (the prompt
miscounted the second EST per-principal arm). Per CLAUDE.md "the repo
is truth," matched the live shape.
What changed
============
internal/config/server.go (+40 LOC):
- Added `SlidingWindowBackend string` + `SlidingWindowJanitorInterval
time.Duration` to RateLimitConfig with full operator-facing
documentation of the two valid values (memory|postgres) +
when-to-use-which decision tree.
internal/config/config.go (+27 LOC):
- Load() reads CERTCTL_RATE_LIMIT_BACKEND (default "memory") +
CERTCTL_RATE_LIMIT_JANITOR_INTERVAL (default 5m).
- Validate() rejects anything other than ""/"memory"/"postgres"
(empty = memory equivalence for test-built Configs that bypass
Load()). Janitor interval must be ≥ 1 minute when set.
- Failure modes return clear ::error:: with the env-var name + the
valid values, so an operator typo ("postgress" → memory in a
3-replica cluster) fails fast at startup.
internal/ratelimit/factory.go (NEW, 67 LOC):
- NewLimiter(backend, db, maxN, window, mapCap) Limiter — single
factory the 6 cmd/server/main.go call sites route through.
- Drop-in signature: same maxN/window/mapCap as
NewSlidingWindowLimiter (mapCap accepted + ignored for postgres
— the rate_limit_buckets table grows until the janitor sweeps).
- Defensive panic on unknown backend (config.Validate is SoT;
this is belt-and-suspenders).
internal/ratelimit/postgres_gc.go (NEW, 73 LOC):
- PostgresGC struct + NewPostgresGC + GarbageCollect.
- Single-statement DELETE FROM rate_limit_buckets WHERE
updated_at < NOW() - maxWindow. Idempotent.
- maxWindow <= 0 is a no-op (operator opt-out).
internal/scheduler/scheduler.go (+90 LOC):
- New RateLimitGarbageCollector interface (mirrors the
ACMEGarbageCollector / SessionGarbageCollector contracts).
- rateLimitGC field + rateLimitGCInterval + rateLimitGCRunning
on Scheduler.
- SetRateLimitGarbageCollector(gc) + SetRateLimitGCInterval(d)
Setters following the existing acmeGC/sessionGC pattern.
- rateLimitGCLoop() — JitteredTicker + atomic.Bool guard +
per-tick context.WithTimeout(1m). Logs row count at Debug.
- Loop counted in the Start() WaitGroup only when the GC is
non-nil; cmd/server/main.go skips SetRateLimitGarbageCollector
when backend=memory so the loop never launches for that case.
cmd/server/main.go (35 LOC diff):
- All 6 ratelimit.NewSlidingWindowLimiter call sites now route
through ratelimit.NewLimiter(cfg.RateLimit.SlidingWindowBackend,
db, ...). Grep verification post-fix returns ZERO hits.
- Six sites: breakglass loginLimiter (580), ocspLimiter (1003),
exportLimiter (1068), EST failed-basic (1535), EST per-principal
SCEP-mTLS arm (1591), EST per-principal SCEP arm (1613). The
intune.NewPerDeviceRateLimiter site at line 1823 stays unmoved
— its inner type-alias wrapper is the prompt's
out-of-scope (cmd/server/*.go only).
- Conditionally constructs PostgresGC + wires the scheduler janitor
when backend=postgres; logs the wiring decision either way so
operators see "rate-limit GC sweep enabled (postgres backend)"
or "in-memory backend self-prunes" in the boot log.
internal/api/handler/{est,export,certificates,auth_breakglass}.go:
- Replaced 5 *ratelimit.SlidingWindowLimiter field/Setter types
with ratelimit.Limiter (the interface). Allow() satisfies the
same call shape on both backends; the in-memory tests that
construct *SlidingWindowLimiter still compile because the
concrete type satisfies the interface (compile-time check in
internal/ratelimit/limiter.go pins this).
docs/operator/observability.md (176 LOC diff):
- Replaced the "per-process, in-memory, reset-on-restart, not
shared across replicas" paragraph with the new
configurable-backend section: operator decision tree,
backend internals (memory vs postgres), janitor description,
falsifiable closure proof (the Sprint 13.2 integration test
name + invocation), helm chart wiring example.
- Updated inventory to reflect the actual handler file paths +
actual cap configurations (the prior doc said "60s window" for
several limiters that actually use 60m / 24h windows).
- Doc smoke confirmed: grep -c 'per-process, in-memory,
reset-on-restart' docs/operator/observability.md = 0.
deploy/helm/certctl/values.yaml + templates/server-configmap.yaml +
templates/server-deployment.yaml:
- Exposed server.rateLimiting.backend (default "memory") +
server.rateLimiting.janitorInterval (default "5m") under the
existing rateLimiting block.
- ConfigMap renders both as rate-limit-backend +
rate-limit-janitor-interval keys.
- Deployment wires CERTCTL_RATE_LIMIT_BACKEND +
CERTCTL_RATE_LIMIT_JANITOR_INTERVAL env vars from the configmap.
- Helm render: `helm template deploy/helm/certctl --set
server.rateLimiting.backend=postgres` shows the env-var on the
server-deployment.yaml output.
.github/workflows/ci.yml (+12 LOC):
- Added a new step in the Go Build & Test job that runs the
Sprint 13.2 multi-replica integration test
(TestRateLimit_PostgresBackend_CapEnforcedAcrossReplicas) with
-tags=integration -race -timeout=300s. Fails the CI status check
if the cross-replica row lock ever stops arbitrating across
replicas — the ARCH-M1 closure regression gate.
Verification (all green locally; postgres integration via CI)
============================================================
$ grep -nE 'NewSlidingWindowLimiter' cmd/server/*.go
(zero hits — Sprint 13.3 receipt)
$ go test -short -count=1 \
./internal/config/... ./internal/ratelimit/... \
./internal/scheduler/... ./internal/api/handler/... \
./cmd/server/...
ok internal/config 1.177s
ok internal/ratelimit 0.007s
ok internal/scheduler 9.165s
ok internal/api/handler 6.245s
ok cmd/server 0.390s
$ staticcheck ./internal/ratelimit/... ./internal/scheduler/... \
./internal/config/... ./internal/api/handler/... ./cmd/server/...
(clean)
$ gofmt -l internal/ cmd/server/
(clean)
$ grep -c 'per-process, in-memory, reset-on-restart' \
docs/operator/observability.md
0 (doc smoke — the audit's verbatim phrasing is gone)
$ bash scripts/ci-guards/G-3-env-docs-drift.sh
G-3 env-docs-drift: clean.
$ bash scripts/ci-guards/complete-path-config-coverage.sh
OK — every CERTCTL_* env var (197) has at least one non-config-
package consumer.
Selector contract verified — config.Validate() rejects any value
other than ""/memory/postgres at startup with a clear error message.
Sprint 13.4 next (ARCH-H1 OpenAPI authoring batch 1) is on a
different axis; ARCH-M1 closure is complete with this commit
modulo the Sprint 13.7 audit-HTML flip + zero-floor pin.
Closes: ARCH-M1 substantive remediation. The cross-replica rate-
limit-cap-enforcement gap that the audit recommended deferring to
v3 is closed; operators with server.replicas > 1 flip
CERTCTL_RATE_LIMIT_BACKEND=postgres and get exactly-cap enforcement
across the cluster (proved by the multi-replica integration test now
gating CI).
241 lines
9.6 KiB
YAML
241 lines
9.6 KiB
YAML
{{- include "certctl.tls.required" . }}
|
|
{{- include "certctl.validateAuthType" . }}
|
|
{{- include "certctl.requiredSecrets" . }}
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
labels:
|
|
{{- include "certctl.labels" . | nindent 4 }}
|
|
app.kubernetes.io/component: server
|
|
spec:
|
|
{{- if gt (int .Values.server.replicas) 1 }}
|
|
replicas: {{ .Values.server.replicas }}
|
|
{{- end }}
|
|
selector:
|
|
matchLabels:
|
|
{{- include "certctl.serverSelectorLabels" . | nindent 6 }}
|
|
template:
|
|
metadata:
|
|
labels:
|
|
{{- include "certctl.serverSelectorLabels" . | nindent 8 }}
|
|
annotations:
|
|
checksum/config: {{ include (print $.Template.BasePath "/server-configmap.yaml") . | sha256sum }}
|
|
checksum/secret: {{ include (print $.Template.BasePath "/server-secret.yaml") . | sha256sum }}
|
|
spec:
|
|
serviceAccountName: {{ include "certctl.serviceAccountName" . }}
|
|
# Bundle 3 closure (D3): pod-level fields only. The container-only
|
|
# fields (readOnlyRootFilesystem, allowPrivilegeEscalation,
|
|
# capabilities, privileged) render at container scope below —
|
|
# pre-Bundle-3 they all sat here at pod scope and the K8s API
|
|
# silently dropped them.
|
|
securityContext:
|
|
{{- include "certctl.podSecurityContext" .Values.server.securityContext | nindent 8 }}
|
|
{{- with .Values.imagePullSecrets }}
|
|
imagePullSecrets:
|
|
{{- toYaml . | nindent 8 }}
|
|
{{- end }}
|
|
containers:
|
|
- name: server
|
|
image: {{ include "certctl.serverImage" . }}
|
|
imagePullPolicy: {{ .Values.server.image.pullPolicy }}
|
|
# Bundle 3 closure (D3): container-scope security hardening.
|
|
# readOnlyRootFilesystem + allowPrivilegeEscalation +
|
|
# capabilities are container-only fields per the K8s API; the
|
|
# helper splits them out of the operator-facing
|
|
# server.securityContext map so existing values keep working.
|
|
securityContext:
|
|
{{- include "certctl.containerSecurityContext" .Values.server.securityContext | nindent 12 }}
|
|
ports:
|
|
- name: https
|
|
containerPort: {{ .Values.server.port }}
|
|
protocol: TCP
|
|
env:
|
|
- name: CERTCTL_SERVER_HOST
|
|
value: "0.0.0.0"
|
|
- name: CERTCTL_SERVER_PORT
|
|
value: "{{ .Values.server.port }}"
|
|
- name: CERTCTL_SERVER_TLS_CERT_PATH
|
|
value: "{{ .Values.server.tls.mountPath }}/tls.crt"
|
|
- name: CERTCTL_SERVER_TLS_KEY_PATH
|
|
value: "{{ .Values.server.tls.mountPath }}/tls.key"
|
|
- name: CERTCTL_DATABASE_URL
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: database-url
|
|
# Bundle 3 closure (D2): POSTGRES_PASSWORD is only needed
|
|
# for the bundled-Postgres mode. External Postgres mode
|
|
# embeds the password directly in externalDatabase.url.
|
|
{{- if .Values.postgresql.enabled }}
|
|
- name: POSTGRES_PASSWORD
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-postgres
|
|
key: password
|
|
{{- end }}
|
|
- name: CERTCTL_LOG_LEVEL
|
|
valueFrom:
|
|
configMapKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: log-level
|
|
- name: CERTCTL_LOG_FORMAT
|
|
value: "json"
|
|
- name: CERTCTL_AUTH_TYPE
|
|
valueFrom:
|
|
configMapKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: auth-type
|
|
{{- if eq .Values.server.auth.type "api-key" }}
|
|
- name: CERTCTL_AUTH_SECRET
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: api-key
|
|
{{- end }}
|
|
- name: CERTCTL_KEYGEN_MODE
|
|
valueFrom:
|
|
configMapKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: keygen-mode
|
|
- name: CERTCTL_RATE_LIMIT_RPS
|
|
valueFrom:
|
|
configMapKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: rate-limit-rps
|
|
- name: CERTCTL_RATE_LIMIT_BURST
|
|
valueFrom:
|
|
configMapKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: rate-limit-burst
|
|
# Phase 13 Sprint 13.3 (ARCH-M1) — cross-replica-consistent
|
|
# sliding-window rate limiter. Default memory; flip to
|
|
# postgres when server.replicas > 1.
|
|
- name: CERTCTL_RATE_LIMIT_BACKEND
|
|
valueFrom:
|
|
configMapKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: rate-limit-backend
|
|
- name: CERTCTL_RATE_LIMIT_JANITOR_INTERVAL
|
|
valueFrom:
|
|
configMapKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: rate-limit-janitor-interval
|
|
{{- if .Values.server.cors.origins }}
|
|
- name: CERTCTL_CORS_ORIGINS
|
|
valueFrom:
|
|
configMapKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: cors-origins
|
|
{{- end }}
|
|
{{- if .Values.server.networkScan.enabled }}
|
|
- name: CERTCTL_NETWORK_SCAN_ENABLED
|
|
value: "true"
|
|
- name: CERTCTL_NETWORK_SCAN_INTERVAL
|
|
valueFrom:
|
|
configMapKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: network-scan-interval
|
|
{{- end }}
|
|
{{- if .Values.server.est.enabled }}
|
|
- name: CERTCTL_EST_ENABLED
|
|
value: "true"
|
|
- name: CERTCTL_EST_ISSUER_ID
|
|
valueFrom:
|
|
configMapKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: est-issuer-id
|
|
{{- if .Values.server.est.profileID }}
|
|
- name: CERTCTL_EST_PROFILE_ID
|
|
valueFrom:
|
|
configMapKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: est-profile-id
|
|
{{- end }}
|
|
{{- end }}
|
|
{{- if .Values.server.smtp.enabled }}
|
|
- name: CERTCTL_SMTP_HOST
|
|
valueFrom:
|
|
configMapKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: smtp-host
|
|
- name: CERTCTL_SMTP_PORT
|
|
valueFrom:
|
|
configMapKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: smtp-port
|
|
- name: CERTCTL_SMTP_USERNAME
|
|
valueFrom:
|
|
configMapKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: smtp-username
|
|
- name: CERTCTL_SMTP_PASSWORD
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: smtp-password
|
|
- name: CERTCTL_SMTP_FROM_ADDRESS
|
|
valueFrom:
|
|
configMapKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: smtp-from-address
|
|
{{- end }}
|
|
{{- if .Values.server.issuer.acme.enabled }}
|
|
- name: CERTCTL_ACME_DIRECTORY_URL
|
|
valueFrom:
|
|
configMapKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: acme-directory-url
|
|
- name: CERTCTL_ACME_EMAIL
|
|
valueFrom:
|
|
configMapKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: acme-email
|
|
- name: CERTCTL_ACME_CHALLENGE_TYPE
|
|
valueFrom:
|
|
configMapKeyRef:
|
|
name: {{ include "certctl.fullname" . }}-server
|
|
key: acme-challenge-type
|
|
{{- end }}
|
|
{{- with .Values.server.env }}
|
|
{{- toYaml . | nindent 12 }}
|
|
{{- end }}
|
|
livenessProbe:
|
|
{{- toYaml .Values.server.livenessProbe | nindent 12 }}
|
|
readinessProbe:
|
|
{{- toYaml .Values.server.readinessProbe | nindent 12 }}
|
|
resources:
|
|
{{- toYaml .Values.server.resources | nindent 12 }}
|
|
volumeMounts:
|
|
- name: tmp
|
|
mountPath: /tmp
|
|
- name: tls
|
|
mountPath: {{ .Values.server.tls.mountPath }}
|
|
readOnly: true
|
|
{{- if .Values.server.volumeMounts }}
|
|
{{- toYaml .Values.server.volumeMounts | nindent 12 }}
|
|
{{- end }}
|
|
volumes:
|
|
- name: tmp
|
|
emptyDir: {}
|
|
- name: tls
|
|
secret:
|
|
secretName: {{ include "certctl.tls.secretName" . }}
|
|
defaultMode: 0400
|
|
{{- if .Values.server.volumes }}
|
|
{{- toYaml .Values.server.volumes | nindent 8 }}
|
|
{{- end }}
|
|
{{- if .Values.nodeAffinity }}
|
|
affinity:
|
|
nodeAffinity:
|
|
{{- toYaml .Values.nodeAffinity | nindent 10 }}
|
|
{{- else if .Values.podAntiAffinity }}
|
|
affinity:
|
|
podAntiAffinity:
|
|
{{- toYaml .Values.podAntiAffinity | nindent 10 }}
|
|
{{- else if .Values.podAffinity }}
|
|
affinity:
|
|
podAffinity:
|
|
{{- toYaml .Values.podAffinity | nindent 10 }}
|
|
{{- end }}
|