feat(san-cert): decouple services in handshake batches, bound renewal deferral - #105
feat(san-cert): decouple services in handshake batches, bound renewal deferral#105mhenrixon wants to merge 1 commit into
Conversation
… deferral ## Summary Three independent hardenings from the SAN grouping audit (issue #102): - Handshake batches are partitioned by service, as the dynamic issuer always was: a certificate never spans services, so one operator's primary domain cannot spend its lifetime coupled to hosts another service controls. The owner map is authoritative (pending entries restored from failed batches carry no service name). - Certificates covering deploy-registered hosts compact away quarantined or unreachable members 14 days before expiry instead of 7: tenant domains are individually expendable, the operator's own names are not, and a flapping batch-mate must not push their renewal into the final week. Tenant-only certificates keep the tight window and the renewal exemption it preserves. - Deferred renewals are a gauge (kamal_proxy_certificate_renewals_deferred), not just a log line, so the coupling is alertable before the compaction window rather than discoverable during an outage. ## Test Coverage - TestSANCertManager_HandshakeBatchNeverMixesServices - TestCertRenewer_RegisteredCertCompactsEarlierThanDynamic (3 windows) - TestCertRenewer_ReportsDeferredRenewals (set and cleared) ## Verification - [x] gofmt/vet clean, make test green, go test -race clean, lint 0 issues Closes #102
There was a problem hiding this comment.
3 issues found across 7 files
Confidence score: 3/5
- In
internal/server/san_cert_manager.go, the shared_batch_waiter can be released by an unrelated concurrent order while this service’s domain was filtered out of that in-flight batch, so callers may think provisioning completed when their domain is still pending. Tighten the batch/wait handshake so waiters are keyed to the specific domains/order they depend on. - In
internal/server/domain_renewal.go(compactionWindowFor), wildcard-covered deploy hosts can be classified as tenant-only because matching uses the wildcard member string instead of the concrete registered host, which can delay compaction for deploy domains. Normalize or expand wildcard membership checks against registered hosts to preserve deploy timing behavior. - In
internal/server/domain_renewal.goreconcile flow, early return onr.ctx.Err()skipsmetrics.Tracker.SetDeferredRenewals(deferred), leaving a stale non-zero gauge and masking true deferred-renewal state during cancellations. Ensure the metric is updated on all exit paths (for example via adefer) to keep observability accurate.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="internal/server/san_cert_manager.go">
<violation number="1" location="internal/server/san_cert_manager.go:568">
P1: When another service is provisioning concurrently, this filter leaves its domain out of the in-flight batch, but the shared `_batch_` waiter still returns immediately after that unrelated order. The waiting handshake therefore fails with `ErrCertNotFound` instead of starting or retrying its own batch. Use service-scoped provisioning single-flight keys, or retry batch acquisition after a waiter completes without obtaining this domain.</violation>
</file>
<file name="internal/server/domain_renewal.go">
<violation number="1" location="internal/server/domain_renewal.go:181">
P3: When the renewer context is cancelled mid-loop, reconcile returns at the `r.ctx.Err() != nil` guard before `metrics.Tracker.SetDeferredRenewals(deferred)` runs, so the gauge keeps a stale non-zero value instead of clearing. The gauge is only guaranteed correct when reconcile completes; on the shutdown path it can report a deferred count that no longer reflects reality.</violation>
<violation number="2" location="internal/server/domain_renewal.go:190">
P2: When a wildcard certificate covers a deploy-registered host, `compactionWindowFor` treats it as tenant-only because the certificate member is the wildcard string, not the concrete registered host. That can defer compaction until seven days before expiry and leave the registered host dependent on a quarantined batch member; apply the registered window when a wildcard member covers any renewable registered domain.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
| } | ||
| // The owner map is authoritative — a pending entry restored from a | ||
| // failed batch carries no service name. | ||
| if m.registeredDomains[pendingDomain] != batchService { |
There was a problem hiding this comment.
P1: When another service is provisioning concurrently, this filter leaves its domain out of the in-flight batch, but the shared _batch_ waiter still returns immediately after that unrelated order. The waiting handshake therefore fails with ErrCertNotFound instead of starting or retrying its own batch. Use service-scoped provisioning single-flight keys, or retry batch acquisition after a waiter completes without obtaining this domain.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/server/san_cert_manager.go, line 568:
<comment>When another service is provisioning concurrently, this filter leaves its domain out of the in-flight batch, but the shared `_batch_` waiter still returns immediately after that unrelated order. The waiting handshake therefore fails with `ErrCertNotFound` instead of starting or retrying its own batch. Use service-scoped provisioning single-flight keys, or retry batch acquisition after a waiter completes without obtaining this domain.</comment>
<file context>
@@ -559,6 +563,11 @@ func (m *SANCertManager) provisionCertificate(ctx context.Context, domain string
}
+ // The owner map is authoritative — a pending entry restored from a
+ // failed batch carries no service name.
+ if m.registeredDomains[pendingDomain] != batchService {
+ continue
+ }
</file context>
| // deploy-registered host compact a week earlier than tenant-only ones. | ||
| func (r *certRenewer) compactionWindowFor(domains []string) time.Duration { | ||
| for _, domain := range domains { | ||
| if r.manager.isRegisteredDomain(domain) { |
There was a problem hiding this comment.
P2: When a wildcard certificate covers a deploy-registered host, compactionWindowFor treats it as tenant-only because the certificate member is the wildcard string, not the concrete registered host. That can defer compaction until seven days before expiry and leave the registered host dependent on a quarantined batch member; apply the registered window when a wildcard member covers any renewable registered domain.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/server/domain_renewal.go, line 190:
<comment>When a wildcard certificate covers a deploy-registered host, `compactionWindowFor` treats it as tenant-only because the certificate member is the wildcard string, not the concrete registered host. That can defer compaction until seven days before expiry and leave the registered host dependent on a quarantined batch member; apply the registered window when a wildcard member covers any renewable registered domain.</comment>
<file context>
@@ -161,13 +172,28 @@ func (r *certRenewer) reconcile() {
+// deploy-registered host compact a week earlier than tenant-only ones.
+func (r *certRenewer) compactionWindowFor(domains []string) time.Duration {
+ for _, domain := range domains {
+ if r.manager.isRegisteredDomain(domain) {
+ return registeredQuarantineCompactionWindow
+ }
</file context>
| } | ||
| } | ||
|
|
||
| metrics.Tracker.SetDeferredRenewals(deferred) |
There was a problem hiding this comment.
P3: When the renewer context is cancelled mid-loop, reconcile returns at the r.ctx.Err() != nil guard before metrics.Tracker.SetDeferredRenewals(deferred) runs, so the gauge keeps a stale non-zero value instead of clearing. The gauge is only guaranteed correct when reconcile completes; on the shutdown path it can report a deferred count that no longer reflects reality.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/server/domain_renewal.go, line 181:
<comment>When the renewer context is cancelled mid-loop, reconcile returns at the `r.ctx.Err() != nil` guard before `metrics.Tracker.SetDeferredRenewals(deferred)` runs, so the gauge keeps a stale non-zero value instead of clearing. The gauge is only guaranteed correct when reconcile completes; on the shutdown path it can report a deferred count that no longer reflects reality.</comment>
<file context>
@@ -161,13 +172,28 @@ func (r *certRenewer) reconcile() {
}
}
+ metrics.Tracker.SetDeferredRenewals(deferred)
r.reportMetrics()
}
</file context>
Summary
Three independent hardenings from the SAN grouping / primary-domain audit, closing the structural half of it (#101/#103 closed the availability half):
internal/server/san_cert_manager.go— handshake batches are partitioned by service, exactly as the dynamic issuer always was (and by directory since fix(san-cert): honor per-service --tls-staging in the SAN cert manager #100): a certificate never spans services, so an operator's primary domain cannot spend its whole life coupled to hosts another service controls. Membership is decided by the authoritative owner map (registeredDomains), not the pending map, whose entries restored from failed batches carry no service name.internal/server/domain_renewal.go— certificates covering a deploy-registered host compact away quarantined/unreachable members 14 days before expiry instead of 7 (registeredQuarantineCompactionWindow). Tenant domains are individually expendable; the operator's own names are not, and a flapping batch-mate must no longer push their renewal into the final week. Tenant-only certificates keep the tight 7-day window — and the identical-set renewal exemption it preserves.internal/metrics/metrics.go+domain_renewal.go— deferred renewals are now a gauge,kamal_proxy_certificate_renewals_deferred, set on every reconcile (all four deferral paths report; a completed or failed order clears). Operators can alert on the coupling before the compaction window instead of discovering it during an outage.Not included, per the issue's own note: the cosmetic CN reorder (browsers ignore CN; optional).
Closes #102
Test plan
TestSANCertManager_HandshakeBatchNeverMixesServices— same-service mates batch, other services keep their pending slots and order separatelyTestCertRenewer_RegisteredCertCompactsEarlierThanDynamic— registered cert compacts at 10d, still defers at 20d; dynamic-only cert still defers at 10dTestCertRenewer_ReportsDeferredRenewals— gauge reports 1 while deferred, clears to 0 once the member recovers and the renewal landsgofmt/go vetclean,make testgreen,go test -race ./internal/server/clean,make lint0 issues,make buildcleanDeviations & judgment calls
compactionWindowFor(allowed)): one registered domain anywhere in the set gives the whole certificate the wider window — mixed legacy certs err on the side of the operator's names.renew()(all-quarantined, partial-quarantine wait, unreachable wait, all-preflight-failed); a placed order — even one that then fails at the CA — counts as not deferred, because the failure paths have their own signal (IncCertificateRenewals(domain, false)).Summary by cubic
Partitions SAN handshake batches by service, widens the renewal compaction window for certificates with registered domains, and exposes deferred renewals as a gauge. Previously, batches could mix services, all certs compacted 7 days before expiry, and deferred renewals were only logged.
registeredDomainsowner map. Expect smaller, safer batches.kamal_proxy_certificate_renewals_deferredset during each reconcile and cleared when an order is placed or fails, enabling proactive alerting on coupling. Addresses Decouple deploy-registered domains from cross-service SAN batches and bound renewal deferral #102 requirements.Review notes
provisionCertificate, confirm pending selection filters by both directory andregisteredDomainsservice owner; pending entries restored from failed batches carry no service, so the owner map is authoritative.domain_renewal, verifycompactionWindowForusesisRegisteredDomainto choose 14d vs 7d and that all defer paths return and count toward the gauge.metrics, the tracker interface gainsSetDeferredRenewals; Prometheus registers thekamal_proxy_certificate_renewals_deferredgauge.Rollout
kamal_proxy_certificate_renewals_deferred. No config or data migration required.Written for commit dbd065e. Summary will update on new commits.