Commit Graph
18 Commits
Author SHA1 Message Date
Aleksei Sviridkin 156ded19d0 refactor(controller): use new() for bool-literal owner-reference flags
Go 1.26's extended new() builtin takes an expression, so new(true) and
new(false) replace ptr.To for the owner reference's Controller and
BlockOwnerDeletion flags. ptr.Deref, which has no builtin equivalent,
stays.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 17:37:56 +03:00
Aleksei Sviridkin 8f2395ea69 fix(controller): flag a missing tenant-ca label at the canonical name
A key-free Secret already sitting at the canonical trust-anchor name is
left untouched and reported Ready. But tenant visibility comes from the
internal.cozystack.io/tenant-ca label, and an engine-owned Secret at that
name carries none, so the lineage webhook marks it tenantresource=false
and the tenant is locked out while the controller reports "published".

Add the missing label to the canonical-name contract deviations so the
warning names it, and collect all deviations rather than only the first
so a single warning describes every way the object falls short.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 17:36:41 +03:00
Aleksei Sviridkin d0af407744 perf(controller): gate the ApplicationDefinition watch on spec.secrets
The ApplicationDefinition mapping fans out to every TenantProjection in
the cluster on every definition event, including Flux's periodic no-op
re-apply of the many *-rd definitions. The selectors-digest drift check
already suppresses the resulting writes, but not the load: each enqueued
reconcile still issues several uncached reads.

Gate the watch with a predicate that delivers an update only when
spec.secrets — the field the selectors digest is computed from — changed,
while letting creates and deletes through. Comparing the same field the
digest digests keeps the gate and the digest from drifting apart.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 17:34:26 +03:00
Aleksei Sviridkin b8b3b1945b docs(controller): describe both projection withdrawal mechanisms
The package doc claimed there is no withdrawal logic and that a retired
declaration is only ever a deleted sentinel. withdrawProjection has
since become the second mechanism: it deletes a projection when the
sentinel outlives its CACert entry, which owner-reference garbage
collection never reaps because the owner still exists. State both paths.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 17:31:13 +03:00
Aleksei Sviridkin a121bec10b fix(controller): resolve an empty sourceSecretName to a condition
An entry with an empty sourceSecretName sent the reconciler to Get a
Secret with an empty name. The apiserver answers that with "resource
name may not be empty" — not NotFound — so it escaped the not-found
branch, was returned from Reconcile, and wedged the sentinel on
exponential backoff. The error path returns before any status write, so
no Ready condition was ever recorded: the silent retry the sentinel was
introduced to make visible.

Pin sourceSecretName to MinLength=1 so a live apiserver rejects it at
admission, and guard the reconcile so an object admitted by an older CRD
resolves to Ready=False/SourceInvalid instead of an error.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 17:30:24 +03:00
Aleksei Sviridkin dd8ee25db4 fix(controller): withdraw a projection this sentinel owns by name
withdrawProjection recognised its projection by an exact owner-reference
match while adoption uses a name match, so the two disagreed about what
counts as "ours" — and withdrawal was the stricter, which is backwards:
"safe to overwrite" is a stronger claim than "safe to delete".

Two projections a name match adopts and rewrites were read as foreign
by the exact match and left in place: one carrying a previous
incarnation's UID (delete-and-recreate under the same name), which
owner-reference garbage collection never reaps once the CACert entry is
gone, so the retired anchor stayed tenant-readable forever; and one
whose BlockOwnerDeletion flag had drifted, the very drift the adoption
path normalizes. Decide ownership with isOurProjection so both paths
answer the question the same way.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 17:26:41 +03:00
Aleksei Sviridkin 1f9736226f fix(controller): store the full selectors digest and correct the withdraw note
The selectors digest kept only the first 8 bytes of the SHA-256. A collision
skips the re-admission the doc calls load-bearing — the revocation direction,
where a tenant would silently keep read access after a definition stopped
selecting the anchor — and the annotation has no length limit, so store the full
hash. Also correct the withdraw-branch comment: MinItems=1 prevents a
single-entry sentinel from emptying its list, so the branch is reached when a
future non-CACert entry replaces the CACert one, not by gating a lone entry off.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 17:18:15 +03:00
Aleksei Sviridkin 4f02c6ae1b fix(controller): requeue a contested sentinel so it recovers on its own
The two-sentinel refusal returned without a RequeueAfter. NoRelease and
MultipleCACert can do that because their fix edits the same sentinel, which the
For() watch delivers; a contest is cleared by deleting the SIBLING sentinel, and
nothing enqueues this one when a sibling changes. So after an operator removed
the duplicate, the surviving sentinel withheld its anchor until the informer's
global resync — hours. Return RequeueAfter: resyncInterval on the contest branch
so recovery rides the resync backstop, and clarify that an anchor an earlier
uncontested reconcile already published is left in place rather than withdrawn.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 17:18:14 +03:00
Aleksei Sviridkin 17653906a6 refactor(controller): rename CA-cert annotations and match app group
The projection carries two annotations whose keys still named a publish-ca-cert
discovery leg that no longer exists in the tree. Rename them to
internal.cozystack.io/ca-cert-source and internal.cozystack.io/ca-cert-selectors
while the API is unshipped; both stay functional (the selectors digest drives
the re-admission drift check, the source ref is a drift input and a traceability
record).

Resolving the ApplicationDefinition matched on Kind alone from a List of
unspecified order. Gate the match on the release's group label too, so a release
stamped with an unexpected group cannot resolve a same-kind definition by
accident.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 17:16:50 +03:00
Aleksei Sviridkin 9ac06cd1fc fix(controller): withdraw the CA projection and refuse contested releases
Two gaps in the sentinel-to-canonical-name lifecycle:

Withdrawal — the reconciler published each declared entry but never deleted
one. A sentinel that keeps existing with its CACert entry removed (a chart
gating it on tls.enabled, say) left the old <release>.tenant-ca served forever,
because owner-reference GC only fires when the whole sentinel is pruned. A
sentinel that reconciles with zero CACert entries now deletes the projection at
the canonical name, but only when it still carries that sentinel's owner
reference, so a foreign Secret or another sentinel's projection is never touched.

Contention — two sentinels in one namespace with the same release label both
resolve to the single canonical name. Whichever reconciled first would publish
an arbitrary CA and the two could flap ownership of one projection. Refuse both,
mirroring the more-than-one-CACert-entry refusal, so nothing is published until
the declaration lives on exactly one sentinel.

The ClusterRole comments claimed the reconciler never deletes a Secret; corrected
to describe the owner-scoped withdrawal delete.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 17:16:50 +03:00
Aleksei Sviridkin e6e68fadf6 fix(controller): wake the reconciler when the CA projection is tampered
The only watches on the projection Secret were the source-Secret index (keyed on
spec.projections.sourceSecretName) and the sentinel itself. The projection is
named <release>.tenant-ca, which is no sentinel's source, so an in-place rewrite
of its ca.crt was corrected only by the five-minute resync — a window in which a
forged trust anchor is served.

Map the projection back to its owning sentinel through the controller owner
reference it carries, on the same dedicated metadata cache the source watch uses.
Owns(&corev1.Secret{}) would register against the manager cache, whose Secret
informer is label-scoped to the WildcardSecret replicas, so a projection carrying
no wildcard label would never be delivered — it would compile and pass a fake
client yet never fire in a real cluster.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 17:16:50 +03:00
Aleksei Sviridkin 9be5f51083 fix(controller): adopt the CA projection by owner reference, not a strippable label
isOurProjection gated adoption on the ManagedLabel marker plus the Opaque
type. The marker is an ordinary label any Secret writer in the namespace can
strip: stripping it off the genuine projection made the controller disown its
own object, refuse it as a collision, and keep serving whatever bytes the same
actor wrote under ca.crt — a trust anchor stuck forged and never self-healing.

Key adoption on the owner reference back to this sentinel instead, matched by
name so a delete-and-recreate under the same name is still re-homed. Forging
the reference only causes an adopt-and-overwrite with the genuine key-free CA,
the self-healing direction; a Secret with no such reference is a stranger and
is refused as a collision, never overwritten. The Opaque type stays as a cheap
sanity guard but is no longer load-bearing — the update path is a
read-modify-write that never writes the immutable Type.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 17:16:50 +03:00
Aleksei Sviridkin 9f924101b6 test(controller): cover the sentinel discovery, status, and GC paths
Rewrite the cacert unit suite for the TenantProjection model: the
happy-path projection, the Ready=False/SourceNotFound status a missing
source now surfaces, ownership solely by the sentinel, the write-path
guards driven through a real source (private key and non-certificate
refused), the canonical-name cases, and the forged-projection heal. The
pure write-path guard tests — including the DER-reassembly guarantee —
carry over unchanged.

Replace the cacert chainsaw suite: it probes the writer policy against
the live API server (RBAC denies a tenant, the VAP denies a
non-flux writer, flux is allowed) and drives the controller end to end
(a flux-created sentinel reports SourceNotFound, then lifts a real CA
into a key-free, sentinel-owned projection). Drop the declared-leg
wiring test.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 17:16:50 +03:00
Aleksei Sviridkin b41b6cce89 feat(controller): discover the CA source through a TenantProjection sentinel
Drive the CA-extraction reconciler off the namespaced TenantProjection
sentinel a chart renders, replacing the publish-ca-cert label leg and
the cluster-scoped spec.caCert declaration leg. The sentinel names the
source Secret and key; the projected trust anchor takes an owner
reference to the sentinel, so the lineage webhook resolves the tenant
through sentinel -> HelmRelease and garbage collection prunes the
projection when the chart stops rendering the sentinel.

The source Secret is watched as metadata only, so the informer holds no
private key material, and the one source actually read goes through the
uncached reader. The sentinel's Ready status makes a missing or
unpopulated source visible with kubectl instead of a silent requeue.

The write-path guard, canonical-name contract, adoption rules and
selectors-digest re-admission are unchanged.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 17:16:49 +03:00
Aleksei Sviridkin d298d6edd4 fix(cozystack-controller): normalize BlockOwnerDeletion drift on the projection owner
releaseOwnerRef pins BlockOwnerDeletion to false so a trust anchor never
holds up the teardown of the application it belongs to, but the ownership
drift check compared every owner-reference field except that one. A sole
owner reference whose only drift was BlockOwnerDeletion flipped to true
therefore passed as already-correct and was never re-homed, leaving a
projection that blocks a foreground deletion of its HelmRelease — the
opposite of what the flag is set for.

Include BlockOwnerDeletion in the owner comparison so the write path
replaces a drifted reference and restores the flag to false.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 17:16:48 +03:00
Aleksei Sviridkin 1a7339537f fix(cozystack-controller): rebuild CA projection from validated blocks
The write-path guard validated the CA value as a certificate chain but
then published a clone of the original input. pem.Decode silently skips
bytes before the first PEM block and between blocks, and the private-key
header guard only matches a "-----BEGIN ... PRIVATE KEY-----" line, so a
raw DER or JWK private key placed ahead of or between real certificates
passes both checks and is copied verbatim into the tenant-readable
projection.

Assemble the projection from the re-encoded certificate DER instead of
cloning the input, so preamble and interstitial key material are
structurally unreachable. The empty-value, wrong-type, non-parseable and
trailing-byte rejections are unchanged.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 17:16:47 +03:00
Aleksei Sviridkin f2c2abfe88 feat(postgres): publish the CA trust anchor to tenants
Declare CNPG's operator-created CA Secret as the postgres trust-anchor
source (spec.caCert names {{ .release }}-ca), and add the generic
internal.cozystack.io/tenant-ca selector to spec.secrets so the key-free
<release>.tenant-ca projection the extraction controller publishes becomes
readable by the tenant.

postgres is the first converged engine, and the hardest: CNPG creates the
CA Secret itself, key-bearing, asynchronously, and offers no way to label
it, so it exercises the name-driven leg end to end. The test drives the
shipped ApplicationDefinition rather than a fixture, so the wiring and the
controller cannot drift apart unseen: a CNPG-shaped key-bearing
<release>-ca appears, and the projection must be produced key-free AND
selected for tenant visibility by the shipped selector.

Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 17:16:47 +03:00
Aleksei Sviridkin 08ad87e24f feat(controller): publish a key-free CA trust anchor per application
Every engine in the catalog issues a per-release CA, but the object that
holds ca.crt almost always holds a private key next to it, so any RBAC
path that hands a tenant the trust anchor hands over key material too.

Add a controller that publishes, for each application release, a
canonical Opaque Secret <release>.tenant-ca holding only ca.crt. That
name is claimed by none of the operators the platform ships, which
matters: <release>-ca-cert, the obvious candidate, is already taken and
means opposite things across engines — a key-free CA on one, a
key-BEARING Secret on another — so projecting onto it could only be done
by overwriting live key material.

The certificate is found two ways. A Secret labelled
internal.cozystack.io/publish-ca-cert is how a chart opts in, and it is
watched, so a rotation propagates at once. For the engines whose operator
owns the CA Secret and will not label it, the application's caCert
declaration names that Secret, which is read by name and re-read on a
resync. The two legs differ only in how the source is found — extraction,
sanitization, ownership and collision handling are one shared write path,
because forking it is where a key-leak bug would hide.

Exactly one whitelisted key is ever copied, and every byte written is
re-checked for a PEM private-key header first: on the name-driven leg the
source is key-bearing by construction. The projection is owner-referenced
to the release, so deleting the application collects it with no prune
logic, and a Secret the controller did not create is never overwritten —
a Secret carrying key material on the canonical name is refused with a
Warning Event rather than adopted.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
2026-07-21 17:16:46 +03:00