feat(platform, ingress): propagate operator wildcard certificate to per-tenant termination points - #2990
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a new ChangesWildcard TLS Secret Replication Controller
Sequence Diagram(s)sequenceDiagram
participant TenantNS as Tenant Namespace
participant Reconciler as wildcardsecret.Reconciler
participant APIServer as Kubernetes API
participant ValuesSecret as cozy-system/cozystack-values
participant SourceSecret as Publishing Namespace TLS Secret
participant ReplicaSecret as Tenant Namespace TLS Secret (replica)
APIServer->>Reconciler: Enqueue singleton key (Secret/Namespace event)
Reconciler->>APIServer: Get cozy-system/cozystack-values
APIServer-->>ValuesSecret: Return wildcard-secret-name, expose-ingress
Reconciler->>APIServer: Get source TLS Secret from publishing namespace
APIServer-->>SourceSecret: Return TLS Secret data
Reconciler->>APIServer: List Namespaces with termination-owner label
APIServer-->>TenantNS: Return matching tenant namespaces
Reconciler->>APIServer: Get existing replica in TenantNS
APIServer-->>Reconciler: NotFound or existing replica
Reconciler->>APIServer: Create/Update ReplicaSecret with CopyLabel+SourceRefAnnotation
APIServer-->>ReplicaSecret: Managed replica persisted
Reconciler->>APIServer: List managed replicas by CopyLabel for pruning
Reconciler->>APIServer: Delete stale replicas outside target set
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
1cc30d5 to
1f3f1d7
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/controller/wildcardsecret/reconciler_test.go`:
- Around line 476-489: The test TestReconcile_NoPlatformValuesIsNoOp currently
locks in destructive prune-on-missing-config behavior by asserting that an
absent values channel causes the wildcard-tls secret to not exist. This
conflicts with the intended design where secrets should only be pruned when
wildcard-secret-name is explicitly cleared, not when the values channel is
transiently absent. Modify the test to create an existing wildcard-tls secret in
the setup, then verify that the reconciler preserves it unchanged when the
values channel is missing, rather than asserting the secret should not exist.
In `@packages/extra/gateway/README.md`:
- Line 60: The documentation describes replica garbage collection as occurring
when "the source is removed," but the actual controller behavior only performs
garbage collection on explicit disable (clearing `wildcardSecretName`). Replicas
persist even when the source Secret is absent or mistyped. Update the wording in
the paragraph to clarify that replicas are garbage-collected only when
`wildcardSecretName` is explicitly cleared or disabled, not during transient
gaps or when the source Secret is temporarily unavailable, to accurately reflect
the controller's reconciliation logic and prevent operator confusion during
certificate rotation scenarios.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 4e6d523d-ded8-4d0c-87f8-63025db14320
📒 Files selected for processing (8)
cmd/cozystack-controller/main.gointernal/controller/wildcardsecret/reconciler.gointernal/controller/wildcardsecret/reconciler_test.gopackages/core/platform/values.yamlpackages/extra/gateway/README.mdpackages/extra/ingress/templates/nginx-ingress.yamlpackages/extra/ingress/tests/default_ssl_certificate_test.yamlpackages/system/cozystack-controller/templates/rbac.yaml
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request introduces a mechanism to propagate the operator-provided wildcard TLS certificate to child tenant namespaces. By replicating the secret into namespaces that own ingress or gateway termination points, child tenants can now securely serve the wildcard certificate without requiring cross-namespace access or widened RBAC. Highlights
New Features🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces a new WildcardSecret controller that replicates the operator-provided wildcard TLS Secret into tenant namespaces that terminate TLS. This allows per-tenant ingress controllers and Gateways to serve the wildcard certificate from their own namespaces without requiring cross-namespace Secret access or widening tenant RBAC. The changes include the reconciler implementation, comprehensive unit tests, RBAC updates, and adjustments to the ingress and gateway charts to utilize the replicated secrets. Feedback is provided to remove the unused Scheme field from the Reconciler struct to simplify the code and clean up unused imports.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| type Reconciler struct { | ||
| client.Client | ||
| Scheme *runtime.Scheme | ||
| } |
There was a problem hiding this comment.
The Scheme field is defined on the Reconciler struct but is not used anywhere in the reconciler implementation. Removing it simplifies the struct and allows removing the unused "k8s.io/apimachinery/pkg/runtime" import. Note that you will also need to remove the Scheme initialization in cmd/cozystack-controller/main.go and internal/controller/wildcardsecret/reconciler_test.go.
type Reconciler struct {
client.Client
}There was a problem hiding this comment.
Keeping Scheme for construction symmetry with the other reconcilers in this manager (all built with Client+Scheme in main.go). This controller tracks replicas by label and annotation rather than owner references — a cross-namespace owner reference would be invalid — so the field is intentionally unused, and I've added a comment on it documenting exactly that. Removing it would break the uniform construction pattern for a cosmetic gain.
1f3f1d7 to
986b153
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
internal/controller/wildcardsecret/reconciler.go (1)
142-146: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCorrect the stale pruning comments.
These comments still say source absence/non-TLS removes replicas, but the implementation intentionally preserves them unless the wildcard name is explicitly cleared.
Proposed comment fix
-// longer belong. When the feature is off (no source name), the source is -// absent, or the source is not a TLS Secret, every replica is removed. +// longer belong. When the feature is off (no source name), every replica +// is removed; an absent or non-TLS source keeps existing replicas.-// removes all managed replicas — used when the feature is off or the -// source is gone. +// removes all managed replicas — used when the feature is off.Also applies to: 338-341
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/controller/wildcardsecret/reconciler.go` around lines 142 - 146, The documentation comments for the Reconcile method at lines 142-146 and the similar comments at lines 338-341 are stale and incorrectly describe the behavior. The comments state that replicas are removed when the feature is off, the source is absent, or the source is not a TLS Secret, but the actual implementation preserves replicas unless the wildcard name is explicitly cleared. Update both comment blocks to accurately reflect that replicas are only removed when the wildcard name is explicitly cleared, not when the source is merely absent or not a TLS Secret.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/controller/wildcardsecret/reconciler.go`:
- Line 168: The pruneCopies function is being called without protecting the
configured source Secret from being pruned. Modify the pruneCopies calls (at
line 168 and line 213) to pass the configured source namespace as a parameter so
that it can be excluded from the pruning logic. Additionally, update the
pruneCopies function implementation (around lines 342-357) to accept the source
namespace parameter and add it to the keepSet to ensure the source Secret is
never deleted even if it carries the wildcard-secret-copy label.
- Around line 200-216: The pruning operation via r.pruneCopies is being executed
unconditionally even when transient upsert failures have been collected in the
errs slice, which can cause previously managed Secrets to be deleted before
replacement copies are successfully created. Add a check to only call
r.pruneCopies when the errs slice is empty, ensuring that pruning is deferred
until all retryable upserts have succeeded.
---
Nitpick comments:
In `@internal/controller/wildcardsecret/reconciler.go`:
- Around line 142-146: The documentation comments for the Reconcile method at
lines 142-146 and the similar comments at lines 338-341 are stale and
incorrectly describe the behavior. The comments state that replicas are removed
when the feature is off, the source is absent, or the source is not a TLS
Secret, but the actual implementation preserves replicas unless the wildcard
name is explicitly cleared. Update both comment blocks to accurately reflect
that replicas are only removed when the wildcard name is explicitly cleared, not
when the source is merely absent or not a TLS Secret.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 33754ee2-5d8e-468c-8244-58570cfd4972
📒 Files selected for processing (8)
cmd/cozystack-controller/main.gointernal/controller/wildcardsecret/reconciler.gointernal/controller/wildcardsecret/reconciler_test.gopackages/core/platform/values.yamlpackages/extra/gateway/README.mdpackages/extra/ingress/templates/nginx-ingress.yamlpackages/extra/ingress/tests/default_ssl_certificate_test.yamlpackages/system/cozystack-controller/templates/rbac.yaml
✅ Files skipped from review due to trivial changes (2)
- packages/extra/gateway/README.md
- packages/core/platform/values.yaml
🚧 Files skipped from review as they are similar to previous changes (4)
- cmd/cozystack-controller/main.go
- packages/extra/ingress/templates/nginx-ingress.yaml
- packages/extra/ingress/tests/default_ssl_certificate_test.yaml
- internal/controller/wildcardsecret/reconciler_test.go
3282256 to
4ea589a
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/controller/wildcardsecret/reconciler_test.go`:
- Around line 611-612: The disabled-path assertion that checks `res.RequeueAfter
!= 0` is incomplete and could miss a bug where Requeue is set to true. In
addition to the existing RequeueAfter check, add a second assertion to verify
that `res.Requeue` is false, ensuring that a disabled reconcile neither requeues
immediately nor schedules a requeue after a delay.
In `@internal/controller/wildcardsecret/reconciler.go`:
- Around line 159-163: The platformValues struct uses WildcardSecretName as a
string type that defaults to empty string when the key is missing from the YAML,
causing readConfig to incorrectly return present=true and triggering destructive
pruning behavior. Change WildcardSecretName from a string type to a pointer to
string (*string) so that nil represents a missing key while an explicit empty
string represents a disabled state. Apply this same fix to the similar field at
lines 288-298 in the ExposeIngress or related configuration. Update any code
that reads or uses these fields to check for nil before dereferencing to
properly handle the distinction between missing and explicitly empty values.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: aaacdcad-5074-4fa6-9db5-945c7f11adc3
📒 Files selected for processing (10)
cmd/cozystack-controller/main.gointernal/controller/wildcardsecret/reconciler.gointernal/controller/wildcardsecret/reconciler_test.gopackages/core/platform/values.yamlpackages/extra/gateway/README.mdpackages/extra/ingress/templates/nginx-ingress.yamlpackages/extra/ingress/tests/default_ssl_certificate_test.yamlpackages/system/cozystack-controller/Makefilepackages/system/cozystack-controller/templates/rbac.yamlpackages/system/cozystack-controller/tests/rbac_test.yaml
✅ Files skipped from review due to trivial changes (3)
- packages/system/cozystack-controller/tests/rbac_test.yaml
- packages/core/platform/values.yaml
- packages/extra/gateway/README.md
🚧 Files skipped from review as they are similar to previous changes (2)
- packages/extra/ingress/templates/nginx-ingress.yaml
- packages/extra/ingress/tests/default_ssl_certificate_test.yaml
5dd524a to
cf97635
Compare
myasnikovdaniil
left a comment
There was a problem hiding this comment.
APPROVE — thorough static review (Go build/vet + 18 reconciler unit tests + both helm-unittest suites + pre-commit all pass locally; not deployed: a storage roll is in progress on the dev cluster and this would require an operator redeploy).
This is the right trust model and it's implemented and documented carefully. Highlights I verified:
Security model (the make-or-break). The private-key replica lands only in namespaces that genuinely own a termination point — ownsTerminationPoint keys on the owner label equalling the namespace's own name, and that label is platform-written by packages/apps/tenant/templates/namespace.yaml (set to own-name only when the tenant runs its own ingress/Gateway), not tenant-editable. So the target set isn't spoofable in the normal trust model, inheriting namespaces are correctly excluded, and the publishing namespace is excluded so the source is never self-overwritten. The blast radius (a terminating tenant holding the wildcard key can impersonate any subdomain under the apex) is the same exposure as the per-host ACME Secret it replaces, and it's spelled out plainly in both values.yaml and the gateway README. Default-off (wildcardSecretName: ""), so existing installs are unchanged.
Cache safety. Scoping the manager-wide Secret informer to replicas + the values channel is safe here because none of the five sibling controllers (WorkloadMonitor, Workload, ApplicationDefinition×2, TenantGateway) nor the telemetry collector read Secrets via the cached client, and the other Secret-reading controllers (fluxplunger, backupcontroller, cozyvaluesreplicator) live in separate binaries. TestSecretCacheByObject_* pins the scope against future widening.
Correctness. Non-destructive on transient source/channel absence, prune-everything only on explicit disable, foreign-Secret collisions never clobber and never block siblings, rotation via bounded resync (source name is dynamic, so it can't be watched), replica self-heal. The test matrix is excellent.
Composition with #2988/#2989. Under wildcard mode the gateway renders certMode: existingSecret and mints no cert-manager Certificate, so there's no competing same-named Secret and no double-management.
make generate. The +kubebuilder:rbac markers are documentation only in this repo (no controller-gen role generation); RBAC is hand-maintained in the chart and correctly updated. No drift.
The only red CI check is the known cilium "IP already in use" E2E flake, unrelated to this change — the cozystack-controller HelmRelease upgraded successfully in that same run.
One optional, non-blocking suggestion left inline about surfacing the foreign-collision case as an Event.
| // skip it, do not requeue on it. Any other error is transient, | ||
| // so aggregate and return it for a back-off requeue. | ||
| if errors.Is(err, errForeignCollision) { | ||
| logger.Info("skipping wildcard replica: a non-managed Secret of the same name exists", "namespace", ns) |
There was a problem hiding this comment.
Optional (non-blocking): a foreign-Secret collision is handled with logger.Info + continue and no requeue — which is the correct safety behavior (never clobber a user Secret, retrying can't help). But for an operator it's silent: a tenant that happens to have a Secret of the wildcard name will simply never receive the wildcard, discoverable only by reading controller logs. Consider emitting a Warning Event on the affected namespace (or surfacing a status condition) so the misconfiguration is visible via kubectl get events. Not required for merge.
There was a problem hiding this comment.
Good call — done: the skipped collision now emits a Warning Event on the affected namespace (kept the log line), so it shows up in kubectl get events without grepping controller logs.
5989d4b
6ff0254 to
a367eae
Compare
There was a problem hiding this comment.
Code Review
This pull request introduces a new wildcardsecret controller that replicates the operator-provided wildcard TLS Secret into tenant namespaces that terminate TLS, allowing per-tenant ingress controllers and Gateways to serve it locally without cross-namespace reads. The implementation includes the reconciler logic, extensive unit tests, Helm chart updates, and RBAC adjustments. The review feedback suggests optimizing the reconciler by utilizing the cached client (r.Get and r.List) instead of the uncached API reader (r.Reader) for resources that are already cached, such as the platform values Secret, Namespaces, and copy-labeled Secrets, thereby reducing unnecessary API server load.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| // and logs a warning naming the namespace it looked in. | ||
| func (r *Reconciler) readConfig(ctx context.Context) (name, namespace string, present bool, err error) { | ||
| values := &corev1.Secret{} | ||
| err = r.Reader.Get(ctx, configKey, values) |
There was a problem hiding this comment.
Since the platform values Secret (cozystack-values) is explicitly watched and cached via SecretCacheByObject, we can read it directly from the cache using r.Get instead of bypassing the cache with r.Reader.Get. This reduces unnecessary direct API server load.
| err = r.Reader.Get(ctx, configKey, values) | |
| err = r.Get(ctx, configKey, values) |
There was a problem hiding this comment.
Done — cozy-system/cozystack-values is explicitly in the scoped cache (SecretCacheByObject), so this read now goes through the cached client instead of the APIReader.
| // Secret already lives there and must not be overwritten by a replica). | ||
| func (r *Reconciler) terminationNamespaces(ctx context.Context, sourceNS string) ([]string, error) { | ||
| list := &corev1.NamespaceList{} | ||
| if err := r.Reader.List(ctx, list); err != nil { |
There was a problem hiding this comment.
Namespaces are cached by default in the manager's cache. We can use r.List instead of r.Reader.List to list namespaces from the cache, avoiding a direct API server call that can be expensive on large clusters.
| if err := r.Reader.List(ctx, list); err != nil { | |
| if err := r.List(ctx, list); err != nil { |
There was a problem hiding this comment.
Done — the namespace list now uses the cached client. Pinned by TestReconcile_ReadsRouteToCacheExceptSourceAndCollision, which asserts the namespace list is served from the cache and not the APIReader.
| // reads as its source, and deleting it would flap the source. | ||
| func (r *Reconciler) pruneCopies(ctx context.Context, keep []string, sourceNS, sourceName string) error { | ||
| list := &corev1.SecretList{} | ||
| if err := r.Reader.List(ctx, list, client.MatchingLabels{CopyLabel: "true"}); err != nil { |
There was a problem hiding this comment.
Since the manager's Secret cache is configured to cache all Secrets with the CopyLabel label, we can list them directly from the cache using r.List instead of r.Reader.List. This avoids direct API server queries for resources that are already cached.
| if err := r.Reader.List(ctx, list, client.MatchingLabels{CopyLabel: "true"}); err != nil { | |
| if err := r.List(ctx, list, client.MatchingLabels{CopyLabel: "true"}); err != nil { |
There was a problem hiding this comment.
Done — the managed-replica prune list now reads from the cached client (the CopyLabel replicas are cached). A stale cache here can only defer a safe prune, never delete a live replica, because of the keepSet and source-slot guards. The two reads the scoped cache cannot serve — the dynamic-name source Get and the foreign-collision check — deliberately stay on the uncached APIReader, and the doc comments were updated to spell out that split.
a367eae to
1ac6350
Compare
There was a problem hiding this comment.
Code Review
This pull request introduces a new wildcardsecret controller that replicates the operator-provided wildcard TLS Secret into tenant namespaces terminating TLS, ensuring secure local access without cross-namespace reads. The changes include the reconciler implementation, extensive unit tests, RBAC updates, and integration with ingress and gateway packages. The review feedback recommends a robustness improvement to validate that the source Secret contains non-empty tls.crt and tls.key data before replication to prevent propagating malformed secrets and causing cluster-wide TLS outages.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if src.Type != corev1.SecretTypeTLS { | ||
| // Misconfigured source type — keep replicas, poll for a fix. | ||
| logger.Info("wildcard source is not a kubernetes.io/tls Secret; keeping existing replicas", | ||
| "secret", pubNS+"/"+name, "type", src.Type) | ||
| return ctrl.Result{RequeueAfter: sourceResyncInterval}, nil | ||
| } |
There was a problem hiding this comment.
While checking that the source Secret is of type kubernetes.io/tls is a good first step, it is also highly recommended to validate that the Secret actually contains non-empty tls.crt and tls.key data before replicating it. If an operator accidentally creates or updates the source Secret to be empty or malformed (e.g., during a failed ACME renewal), propagating this empty Secret to all tenant namespaces would cause a cluster-wide TLS outage for all tenants. Validating the presence of these keys ensures we fail-safe and keep the existing valid replicas in place.
| if src.Type != corev1.SecretTypeTLS { | |
| // Misconfigured source type — keep replicas, poll for a fix. | |
| logger.Info("wildcard source is not a kubernetes.io/tls Secret; keeping existing replicas", | |
| "secret", pubNS+"/"+name, "type", src.Type) | |
| return ctrl.Result{RequeueAfter: sourceResyncInterval}, nil | |
| } | |
| if src.Type != corev1.SecretTypeTLS { | |
| // Misconfigured source type — keep replicas, poll for a fix. | |
| logger.Info("wildcard source is not a kubernetes.io/tls Secret; keeping existing replicas", | |
| "secret", pubNS+"/"+name, "type", src.Type) | |
| return ctrl.Result{RequeueAfter: sourceResyncInterval}, nil | |
| } | |
| if len(src.Data["tls.crt"]) == 0 || len(src.Data["tls.key"]) == 0 { | |
| logger.Info("wildcard source Secret is missing tls.crt or tls.key; keeping existing replicas", | |
| "secret", pubNS+"/"+name) | |
| return ctrl.Result{RequeueAfter: sourceResyncInterval}, nil | |
| } |
1ac6350 to
93f2932
Compare
|
myasnikovdaniil rebased onto |
…espaces The operator-provided wildcard MVP serves the root tenant only: the root ingress controller and Gateway read a TLS Secret from their own namespace. Child tenants run their own ingress controller / Gateway in a separate, default-deny namespace and cannot read that Secret cross-namespace. Add a controller that mirrors the operator wildcard Secret into every tenant namespace that owns a TLS termination point (its namespace.cozystack.io/ingress or .../gateway label equals its own name). The source is identified by name from the platform values channel (_cluster.wildcard-secret-name) with no extra operator input; replicas carry a managed-by label and refresh on rotation. An individual replica is removed when its namespace stops terminating TLS; every replica is torn down only on an explicit disable (clearing wildcard-secret-name). A missing or mistyped source, or a transiently-absent values channel, keeps existing replicas, so tenant TLS never drops on a transient gap. The controller's Secret cache is scoped to managed replicas and the values channel, not every cluster Secret. Only the platform controller's ServiceAccount gains Secret writes — no tenant RBAC is widened, and every consumer reads only its own-namespace copy. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
…llers In operator wildcard mode each tenant's ingress controller now sets default-ssl-certificate to the wildcard Secret in its own namespace, so a child tenant serves the replicated wildcard instead of falling back to per-host ACME. ingress-nginx reads the default certificate only from its own namespace, and each ingress HelmRelease renders in the namespace that owns the controller, so the reference stays same-namespace for the root and every child alike. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
93f2932 to
8f60b27
Compare
IvanHunters
left a comment
There was a problem hiding this comment.
Approve.
The wildcard-secret replication controller is mechanically sound and the tenant-isolation invariant holds. ownsTerminationPoint selects a namespace only when the operator-controlled namespace.cozystack.io/{ingress,gateway} label equals the namespace's own name; that label value derives from .Release.Namespace via Flux, and tenants set only boolean flags, so a tenant cannot redirect replication. Prune only touches Secrets carrying the copy label, and the informer cache is scoped to copy-labeled replicas plus the values Secret. go test passes (24 cases incl. foreign-collision no-overwrite, prune-on-demotion, cache-scope pin) and helm unittest passes.
Non-blocking:
- RBAC: the ClusterRole gains cluster-wide secrets write. With dynamic target namespaces this can't be narrowed via resourceNames, and the reconciler only writes copy-labeled Secrets, but the grant is broad — worth a short justifying comment in the manifest.
- Suggest an operator-facing upgrade note: on upgrade, child tenants that terminate TLS switch from per-host ACME to the replicated wildcard, and stale per-host ACME Secrets are left in place (inert), not pruned.
Review scope note: verified statically + unit tests. The upgrade-time claim that ingress-nginx picks up the replica without a restart or connection drop was reasoned from how nginx watches the Secret, not observed on a live cluster.
|
Moving this back to draft. It is approved and the code is sound — the blocker is a collision with work that landed after the review, and it is not one a merge conflict would surface. This PR scopes the manager's Secret informer through The CA extraction controller (#3286) needs exactly the same knob, with a different label selector, also over Whoever merges second therefore cannot fix this by resolving a hunk. It is architectural, and it should be settled before either lands rather than discovered at merge time. The agreed direction is a shared marker key: both controllers scope their cache off one label key carrying different values, so a single I will push the reworked scoping here once the exact key and values are settled alongside #3286. Nothing else about this PR changes. |
|
Back to ready — this PR no longer needs to change, and I was wrong about the fix. I said above that the direction was a shared marker label, so that one The CA extraction controller will instead run its own cache with its own selector, leaving the manager-level scoping here untouched. Two narrow Secret informers rather than one — a real but small cost, in exchange for the two features staying entirely independent, with nothing to coordinate at merge time. One thing that surfaced while working this through, worth recording here because it constrains any future change to this reconciler: No changes pushed. Requesting merge as previously approved. |
There was a problem hiding this comment.
Code Review
This pull request introduces a new wildcardsecret controller that replicates the operator-provided wildcard TLS Secret into tenant namespaces that terminate TLS. This allows per-tenant ingress controllers and Gateways to serve TLS from their own namespaces without cross-namespace Secret reads or widened RBAC. The controller uses a scoped Secret cache to minimize memory footprint. Corresponding updates are made to the ingress and gateway packages, RBAC permissions, and documentation. The review feedback suggests a minor optimization in cloneData to return nil directly if the input map is empty, avoiding unnecessary map allocation.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| func cloneData(in map[string][]byte) map[string][]byte { | ||
| out := make(map[string][]byte, len(in)) | ||
| for k, v := range in { | ||
| b := make([]byte, len(v)) | ||
| copy(b, v) | ||
| out[k] = b | ||
| } | ||
| return out | ||
| } |
There was a problem hiding this comment.
If the input map in is nil or empty, we can return nil directly to avoid unnecessary map allocation. In Kubernetes, a nil Data field on a Secret is functionally equivalent to an empty map and is often preferred.
| func cloneData(in map[string][]byte) map[string][]byte { | |
| out := make(map[string][]byte, len(in)) | |
| for k, v := range in { | |
| b := make([]byte, len(v)) | |
| copy(b, v) | |
| out[k] = b | |
| } | |
| return out | |
| } | |
| func cloneData(in map[string][]byte) map[string][]byte { | |
| if len(in) == 0 { | |
| return nil | |
| } | |
| out := make(map[string][]byte, len(in)) | |
| for k, v := range in { | |
| b := make([]byte, len(v)) | |
| copy(b, v) | |
| out[k] = b | |
| } | |
| return out | |
| } |
What this PR does
The operator-provided wildcard certificate (
publishing.certificates.wildcardSecretName) currently serves the root tenant only: everything is same-namespace, so the root ingress controller and Gateway read the operator's TLS Secret directly. Child tenants run their own ingress controller / Gateway in their own namespace under a default-deny policy and cannot read that Secret across namespaces, so they fall back to per-host ACME.This propagates the operator wildcard to per-tenant termination points by replicating the Secret into each tenant namespace that owns one, keeping the root-MVP invariant intact: the wildcard is deliberately distributed to tenant namespaces, but no tenant RBAC is widened — every consumer reads only its own-namespace copy.
A new controller in
cozystack-controllerdoes the replication with no extra operator input. It reads the same platform values channel the consumers read (cozy-system/cozystack-values): the wildcard Secret name from_cluster.wildcard-secret-nameand the publishing namespace from_cluster.expose-ingress. It mirrors that Secret into every tenant namespace whosenamespace.cozystack.io/ingressornamespace.cozystack.io/gatewaylabel equals its own name — i.e. the namespace runs its own controller / Gateway. Because the source is derived from the same value that makes the consumers reference it, the replica is created whenever the consumers expect it — no manual labelling and no upgrade hazard where a child controller references a Secret that nothing creates.Replicas are refreshed on rotation and pruned when a namespace stops terminating TLS. Teardown of every replica happens on exactly one trigger: clearing
wildcardSecretName(explicit disable). A source that is merely absent or mistyped, or a transiently-missing values channel, leaves existing replicas in place, so a brief gap, a delete+recreate rotation, or a misconfigured publishing namespace never drops tenant TLS. The platform controller's own ServiceAccount gains Secret writes; tenant RBAC is untouched.The controller does not cache every Secret in the cluster: the manager's Secret informer is scoped to managed replicas and the values channel only, and the operator source / foreign collisions are read through the uncached APIReader. The source is not watched (its name is dynamic), so an in-place rotation is picked up by a bounded periodic resync while the publishing tenant serves the source with no lag.
On the consumer side, each tenant ingress controller now sets
--default-ssl-certificateto the wildcard Secret in its own namespace (.Release.Namespace/<name>), so a child tenant serves the replica instead of minting per-host ACME. The Gateway path already renderscertMode: existingSecretwith a same-namespacewildcardSecretReffor every tenant, so it consumes the replica with no chart change.Why not clustersecret-operator
The issue proposed the shipped
clustersecret-operator. It materializes inline template data into selected namespaces; it cannot mirror an existing source Secret, and it is not installed in any default bundle. Driving it would mean embedding the certificate and private key as literal data in a cluster-scoped object rendered from a Helmlookup— that broadcasts key material the design keeps off any shared channel, and alookupis invisible to the Flux helm-controller digest so it would not re-render on rotation. Its namespace selector also cannot express "the namespace whose owner label equals its own name", so it would over-replicate the key into every tenant namespace. A small dedicated reconciler targets exactly the termination namespaces and keeps the key off the values channel.Coverage caveat
Replication delivers the certificate bytes, not SAN coverage: a single
*.<root-host>does not match*.<child-apex>. Operators serving child-tenant subdomains supply a certificate whose SANs cover those apexes; the docs spell this out. The replica carries the wildcard private key into each terminating tenant namespace — the same exposure as the per-host ACME Secret it replaces, now shared across tenants.Tests
ownsTerminationPointtable, and the active-only requeue contract.default-ssl-certificatewiring (including a child-tenant controller pointing at its own-namespace replica) and for the cozystack-controller ClusterRole's new Secret-write rule.Screenshots
N/A — no UI changes.
Release note
Closes #2820
Part of #2811
Summary by CodeRabbit
Release Notes
New Features
Documentation
Bug Fixes
Tests
Chores