mirror of
https://github.com/certctl-io/certctl.git
synced 2026-08-30 05:51:24 +02:00
fix(helm): close BUNDLE 3 — Helm chart hardening + enterprise deploy
Bundle 3 closure (2026-05-12 acquisition diligence audit). Closes the
"chart claims production-ready but lying-fields silently break it"
hazard cluster: README install command had wrong key, required secrets
weren't fail-fast, external Postgres rendered the bundled StatefulSet
hostname, container-only security hardening fields landed at pod scope
(silently dropped by K8s API), and three advertised template surfaces
(ServiceMonitor, PodDisruptionBudget, NetworkPolicy) didn't render at
all even when their values.yaml toggles were on.
Source findings closed:
C2 C3 D1 D2 D3 D5 D7 D11 D12 (repo audit)
OPS-L1 OPS-L2 (cowork audit)
Source findings explicitly deferred (tracked in WORKSPACE-ROADMAP.md):
D6 OPS-H1 (backup automation — operator must choose target storage)
D10 (digest pinning of latest `:latest` tags)
OPS-M1 (prometheus/client_golang migration)
OPS-M2 (distributed tracing instrumentation)
Chart truth table (rendered with helm 3.16.3):
-f values.yaml + tls.existingSecret + auth.apiKey + pg.auth.password
→ 12 resources (default mode, no monitoring/PDB/networkpolicy)
+ postgresql.enabled=false + externalDatabase.url=…
→ NO StatefulSet, NO postgres-secret, NO postgres-service (D2)
+ server.tls.certManager.enabled=true
→ +1 Certificate (cert-manager mode)
+ replicas=3 + monitoring.enabled=true + serviceMonitor.enabled=true
+ podDisruptionBudget.enabled=true + networkPolicy.enabled=true
→ +1 ServiceMonitor + 1 PodDisruptionBudget + 1 NetworkPolicy (D5+D11)
tls.existingSecret AND tls.certManager.enabled both set
→ REFUSED with "EXACTLY ONE TLS ownership path" error (D7)
Missing required secrets (apiKey / pg password / external URL)
→ REFUSED at template time with operator-actionable guidance (D1)
Closures by source ID:
C2 — README Helm install example fixed. Was `--set postgresql.password=…`
(does not exist); now `--set postgresql.auth.password=…` matching
the chart key. README install block also wires TLS, mentions
fail-fast at template time, and links the external-Postgres example.
C3 — Kubernetes Secrets connector annotated PREVIEW in values.yaml.
The chart still exposes `kubernetesSecrets.enabled` for the RBAC
preview wiring, but the values block now states clearly that the
production K8s client at internal/connector/target/k8ssecret/
k8ssecret.go::realK8sClient is a stub (verified — go.mod imports
zero k8s.io/client-go packages). Production landing tracked in
WORKSPACE-ROADMAP.md.
D1 — `certctl.requiredSecrets` template helper. Fail-fasts at render
time when (a) server.auth.type=api-key + apiKey empty, (b)
postgresql.enabled=true + pg.auth.password empty, (c)
postgresql.enabled=false + externalDatabase.url + legacy env
CERTCTL_DATABASE_URL all empty. Each branch emits an
operator-actionable diagnostic with the openssl rand command or
values override needed. postgres-secret template additionally
uses Helm's `required` builtin so it can't render with the empty
fallback that pre-Bundle-3 produced ("changeme" literal).
D2 — externalDatabase.url first-class. New top-level values block.
certctl.databaseURL helper now branches on postgresql.enabled:
bundled path uses the helper-emitted in-cluster URL; external
path uses externalDatabase.url verbatim. postgres-secret,
postgres-statefulset, and postgres-service ALL gate on
postgresql.enabled — external mode renders ZERO postgres-*
resources. POSTGRES_PASSWORD env in server-deployment also gates.
D3 — Container-vs-pod security context split. K8s API silently drops
readOnlyRootFilesystem / allowPrivilegeEscalation / capabilities /
privileged when they land at pod scope (`spec.securityContext`);
they only work at container scope (`spec.containers[].securityContext`).
Pre-Bundle-3 all fields sat at pod scope so the chart's documented
"read-only rootfs + drop-all caps" hardening was effectively
unenforced. New certctl.podSecurityContext + containerSecurityContext
helpers split the operator-facing securityContext map by field-name
whitelist so existing values keep working byte-for-byte while
fields render at the K8s-valid scope. Applied to both
server-deployment.yaml and agent-daemonset.yaml (DaemonSet + Deployment
branches).
D5 — Prometheus ServiceMonitor template. New
templates/servicemonitor.yaml. Renders when monitoring.enabled AND
monitoring.serviceMonitor.enabled. Scrapes /api/v1/metrics/prometheus
(rbac-gated on metrics.read — needs bearerTokenSecret with an API
key holding that perm). values.yaml block extended with bearerTokenSecret,
tlsConfig, and relabelings knobs and the operator-facing comment
documenting the auth requirement.
D7 — TLS both-set rejection. certctl.tls.required helper extended.
Pre-Bundle-3 only the NEITHER-set case was caught; setting BOTH
rendered a dangling cert-manager Certificate alongside an
existing-Secret mount, two conflicting TLS sources of truth.
Now refuses with "EXACTLY ONE TLS ownership path" + remediation
steps for both possible operator intents.
D11 — PodDisruptionBudget + NetworkPolicy templates. New
templates/pdb.yaml (renders when podDisruptionBudget.enabled +
server.replicas > 1) + templates/networkpolicy.yaml (renders when
networkPolicy.enabled). PDB uses minAvailable / maxUnavailable
exclusivity per K8s spec. NetworkPolicy default-allows in-namespace
agent → server traffic, kube-DNS egress, and bundled-postgres
egress (when postgresql.enabled), with operator-extensible
extraIngress / extraEgress for CA / OIDC / SMTP egress. Both
default off so existing deploys don't lose network reach
unannounced.
D12 — Database max-conn config wired. Pre-Bundle-3
internal/repository/postgres/db.go::NewDB hard-coded
SetMaxOpenConns(25). config.go loaded CERTCTL_DATABASE_MAX_CONNS,
Validate() enforced the >= 1 floor, values.yaml documented it,
and docs/reference/configuration.md surfaced it — but the pool
ignored every operator setting. New NewDBWithMaxConns threads
the operator value into the pool with maxIdle = maxOpen / 5
(≥ 1) so the historical ratio carries forward. cmd/server/main.go
calls the new constructor; NewDB stays for compat at the default 25.
OPS-L1 — Chart version 0.1.0 → 1.0.0. Chart has shipped through 8 audit
closures since 2026-02 (M-018, U-1, U-2, U-3, H-1, G-1, B1, B2);
pre-1.0 version was implying instability the chart no longer has.
OPS-L2 — External-Postgres path is now properly documented in values.yaml
(externalDatabase block with mode-2 example), README install command
links the existing examples/values-external-db.yaml, and the chart
truth table above proves the external mode renders cleanly.
Receipts:
helm lint deploy/helm/certctl/ # clean
helm template c deploy/helm/certctl/ \
--set server.tls.existingSecret=ci \
--set postgresql.auth.password=p \
--set server.auth.apiKey=k # 12 kinds, default
helm template c deploy/helm/certctl/ \
--set server.tls.existingSecret=ci \
--set postgresql.enabled=false \
--set externalDatabase.url='postgres://u:p@h:5432/db?sslmode=require' \
--set server.auth.apiKey=k # 9 kinds, no postgres-*
helm template c deploy/helm/certctl/ \
--set server.tls.certManager.enabled=true \
--set server.tls.certManager.issuerRef.name=letsencrypt \
--set postgresql.auth.password=p --set server.auth.apiKey=k
# +1 Certificate (cert-manager)
helm template c deploy/helm/certctl/ \
--set server.tls.existingSecret=ci \
--set postgresql.auth.password=p --set server.auth.apiKey=k \
--set server.replicas=3 \
--set monitoring.enabled=true \
--set monitoring.serviceMonitor.enabled=true \
--set podDisruptionBudget.enabled=true \
--set networkPolicy.enabled=true # +ServiceMonitor +PDB +NetworkPolicy
(TLS both-set + missing apiKey + missing pg password + missing extDb URL all REFUSED.)
gofmt -l # clean
go vet ./internal/repository/postgres ./cmd/server # clean
go build ./cmd/server # clean
bash scripts/ci-guards/B3-helm-chart-coherence.sh # clean
Remaining operator warnings (deferred, tracked in WORKSPACE-ROADMAP.md):
- Backup CronJob + restore script (D6 + OPS-H1): operator chooses
target (S3, GCS, Azure Blob, NFS). Sample CronJob yaml may ship
in deploy/helm/examples/ once an operator workstation has run
one full backup-restore cycle.
- Distributed tracing (OPS-M2): otel/* are go.mod indirect deps,
not actively instrumented. Adding spans is a v3 work item.
- Prometheus client_golang migration (OPS-M1): the hand-rolled
/metrics/prometheus exposition format works today; client_golang
migration unlocks histograms + exemplars + native label sets.
Audit-Closes: BUNDLE-3 C2 C3 D1 D2 D3 D5 D7 D11 D12 OPS-L1 OPS-L2
Audit-Defers: D6 D10 OPS-H1 OPS-M1 OPS-M2
This commit is contained in:
@@ -272,6 +272,34 @@ server:
|
||||
# secret:
|
||||
# secretName: ca-cert
|
||||
|
||||
# ==============================================================================
|
||||
# External Database Configuration (Bundle 3 closure / D2 + OPS-L2)
|
||||
# ==============================================================================
|
||||
# When postgresql.enabled=false, the chart skips the bundled StatefulSet +
|
||||
# Secret + Service and instead consumes the URL below verbatim as the
|
||||
# server's CERTCTL_DATABASE_URL. The URL embeds username, password,
|
||||
# host, port, database, and sslmode — operators are responsible for
|
||||
# rotating credentials in this string out-of-band (Kubernetes Secret +
|
||||
# helm upgrade is the supported pattern).
|
||||
#
|
||||
# Recommended sslmode for managed Postgres (RDS, Cloud SQL, Azure DB):
|
||||
# verify-full — PCI-DSS Req 4 v4.0 §2.2.5 compliant; requires CA bundle.
|
||||
# Mount the CA via server.volumes / server.volumeMounts and
|
||||
# set sslrootcert=/path/in/pod/ca.crt in the URL.
|
||||
#
|
||||
# Example values overrides:
|
||||
# postgresql.enabled: false
|
||||
# externalDatabase.url: "postgres://certctl:HUNTER2@db.example.com:5432/certctl?sslmode=verify-full"
|
||||
#
|
||||
# Migration from the legacy `server.env.CERTCTL_DATABASE_URL` workaround:
|
||||
# both still work (env block overrides the helper-emitted Secret value at
|
||||
# pod-spec level), but the new path renders cleaner manifests with no
|
||||
# stranded postgres-* templates.
|
||||
externalDatabase:
|
||||
# Connection string used when postgresql.enabled=false.
|
||||
# Required in that mode — see certctl.requiredSecrets helper.
|
||||
url: ""
|
||||
|
||||
# ==============================================================================
|
||||
# PostgreSQL Configuration
|
||||
# ==============================================================================
|
||||
@@ -510,10 +538,26 @@ rbac:
|
||||
create: true
|
||||
|
||||
# ==============================================================================
|
||||
# Kubernetes Secrets Target Connector
|
||||
# Kubernetes Secrets Target Connector (PREVIEW — Bundle 3 closure / C3)
|
||||
# ==============================================================================
|
||||
# Bundle 3 audit closure (C3): the connector framework at
|
||||
# internal/connector/target/k8ssecret/ ships the Config + interface +
|
||||
# 14 unit tests, but the production K8s client at
|
||||
# k8ssecret.go::realK8sClient is documented as "a stub placeholder for
|
||||
# the real k8s.io/client-go implementation". The repo does not import
|
||||
# k8s.io/client-go (verified via `grep -n "client-go" go.mod`), so the
|
||||
# connector cannot deploy to a real cluster today.
|
||||
#
|
||||
# Setting kubernetesSecrets.enabled=true wires up the RBAC verbs the
|
||||
# real client will need (get/create/update/patch/delete on Secrets)
|
||||
# without making the connector functional — operators trying to use it
|
||||
# get the stub's error and a pointer to this note.
|
||||
#
|
||||
# Status: PREVIEW. Production client lands when the cluster-management
|
||||
# bundle ships (tracked in WORKSPACE-ROADMAP.md). Until then,
|
||||
# in-cluster deploys use the file-based connectors (NGINX, Apache,
|
||||
# HAProxy, etc.) via a Pod-mounted Secret + DaemonSet agent.
|
||||
kubernetesSecrets:
|
||||
# Enable RBAC rules for managing TLS Secrets
|
||||
enabled: false
|
||||
|
||||
# ==============================================================================
|
||||
@@ -527,6 +571,13 @@ podDisruptionBudget:
|
||||
# ==============================================================================
|
||||
# Monitoring Configuration
|
||||
# ==============================================================================
|
||||
# Bundle 3 closure (D5): the ServiceMonitor template at
|
||||
# templates/servicemonitor.yaml renders when both monitoring.enabled=true
|
||||
# AND monitoring.serviceMonitor.enabled=true. The endpoint scrapes
|
||||
# /api/v1/metrics/prometheus, which is rbac-gated on `metrics.read` —
|
||||
# operators MUST provide a bearer token via
|
||||
# monitoring.serviceMonitor.bearerTokenSecret pointing at a Secret with
|
||||
# an API key holding that permission. Without the token, scrapes 401.
|
||||
monitoring:
|
||||
enabled: false
|
||||
# Prometheus ServiceMonitor
|
||||
@@ -534,8 +585,53 @@ monitoring:
|
||||
enabled: false
|
||||
interval: 30s
|
||||
scrapeTimeout: 10s
|
||||
# Additional labels applied to the ServiceMonitor metadata.
|
||||
# labels: {}
|
||||
# selector: {}
|
||||
# Bearer-token Secret reference (required when the certctl server's
|
||||
# /api/v1/metrics/prometheus endpoint is gated by api-key auth).
|
||||
# Example:
|
||||
# bearerTokenSecret:
|
||||
# name: certctl-prometheus-key
|
||||
# key: api-key
|
||||
# bearerTokenSecret: {}
|
||||
# TLS config for the scrape endpoint. The certctl server presents
|
||||
# the same TLS cert the rest of the chart uses; insecureSkipVerify
|
||||
# defaults to true so demos work out of the box. Production deploys
|
||||
# should pin the CA via caFile or ca.secret.
|
||||
# tlsConfig:
|
||||
# caFile: /etc/prometheus/secrets/certctl-ca/ca.crt
|
||||
# serverName: certctl-server
|
||||
# tlsConfig: {}
|
||||
# Optional relabeling for the scrape job.
|
||||
# relabelings: []
|
||||
|
||||
# ==============================================================================
|
||||
# Network Policy (Bundle 3 closure / D11)
|
||||
# ==============================================================================
|
||||
# Default off so existing deploys don't suddenly lose network reach.
|
||||
# When enabled, restricts the server pod to:
|
||||
# - Ingress: from in-namespace agent pods only.
|
||||
# - Egress: kube-dns + bundled Postgres (if enabled).
|
||||
# Operators add CA / OIDC / SMTP egress via extraEgress.
|
||||
networkPolicy:
|
||||
enabled: false
|
||||
# Additional Ingress rules merged into the policy. Each entry is a
|
||||
# raw networking.k8s.io/v1 NetworkPolicyIngressRule.
|
||||
extraIngress: []
|
||||
# Additional Egress rules merged into the policy. Common operator
|
||||
# need: 443/TCP to an OIDC issuer, 443/TCP to a public CA endpoint,
|
||||
# 25/TCP to an SMTP relay.
|
||||
# Example:
|
||||
# extraEgress:
|
||||
# - to:
|
||||
# - ipBlock:
|
||||
# cidr: 0.0.0.0/0
|
||||
# except:
|
||||
# - 10.0.0.0/8
|
||||
# ports:
|
||||
# - protocol: TCP
|
||||
# port: 443
|
||||
extraEgress: []
|
||||
|
||||
# ==============================================================================
|
||||
# Advanced Configuration
|
||||
|
||||
Reference in New Issue
Block a user