Skip to content

feat(san-cert): SAN batch resilience — eviction grace, shrink guard, renewal preflight, batch quarantine - #97

Merged
mhenrixon merged 7 commits into
dashfrom
feature/san-batch-resilience
Aug 10, 2026
Merged

feat(san-cert): SAN batch resilience — eviction grace, shrink guard, renewal preflight, batch quarantine#97
mhenrixon merged 7 commits into
dashfrom
feature/san-batch-resilience

Conversation

@mhenrixon

@mhenrixon mhenrixon commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes the three gaps that let a dead tenant domain or a bad domain-source poll degrade or destroy SAN certificate coverage (the WM3 scenario: ~1100 tenant domains, many dead). No new flags; no RPC verbs; behavior-only.

  • Eviction grace period (internal/server/domain_renewal.go) — a certificate whose domains were all evicted is never deleted before its own NotAfter: it stops renewing and keeps serving. Only a superseded cert (no domain maps through it anymore — a loaded replacement answers every handshake) is removed immediately. A bad poll + reconcile + recovery now costs zero new ACME orders. The per-domain expiry gauge is reported only by the cert a domain currently maps to, so lingering certs can't clobber their successor's metric.
  • Shrink guard (internal/server/dynamic_domains.go) — a poll removing more than 30% of a service's applied domain set has its removals held (additions still apply) until 3 consecutive over-threshold polls confirm the shrink; a recovering poll clears the hold. Held domains keep serving existing certs but earn no new orders. Visible in kamal-proxy domains list (Removal held column) and domains stats; every held poll logs at Warn.
  • Renewal member preflight (internal/server/domain_renewal.go) — every renewal probes its dynamic members before spending an order; unreachable ones follow the existing quarantine policy (defer while there's time, compact inside the 7-day window). Deploy-registered hosts are not probed (may be DNS-01-only by design); wildcards have nothing to answer on.
  • Probe-based failure attribution (new internal/server/domain_failure.go) — identifyFailedDomains: parse lego's per-domain error lines; failing that, probe the members; failing that, hold the whole batch on the quarantine ladder. Fixes the renewer's quarantine-nobody hole (a doomed batch used to retry hourly, unbacked-off, until the whole cert expired) and gives the issuer culprit-aware survivor retries.
  • Handshake-batch guard (new internal/server/san_cert_batch_guard.go) — provisionCertificate filters batch-mates through quarantine + preflight (they keep their pending slot), and attributes order failures: culprits quarantined, survivors restored. The triggering domain is never dropped. Wired via SetIssuanceGuard from NewDynamicDomainManager at boot.

Closes #96

Test plan

  • make test — 1768 server-package tests green (21 new)
  • go test -race ./internal/server/ — clean
  • make lint — 0 issues (golangci-lint v2.11.3), gofmt -l empty, go vet clean
  • make docker && docker run --rm kamal-proxy kamal-proxy -h — image smoke test
  • Wipe scenario end-to-end at unit level: certs adopted → empty poll → reconcile → zero certs deleted, hold visible → recovery poll → zero new orders (TestCertRenewer_EvictedCertificateSurvivesSourceRecovery, TestDynamicDomainManager_ShrinkGuardHoldsEmptyPoll)
  • Staging: point a service's --tls-domains-source at an endpoint, return an empty list once, confirm domains list shows held removals and no cert files disappear from the store

Deviations & judgment calls

  • Discovery: since the plan was written, feat(wildcard-certs): per-zone DNS-01 provider selection #94 landed per-zone DNS-01 partitioning — certRenewer.renew now splits a renewal into one order per provider partition (renewPartition), and provisionCertificate narrows a handshake batch to one partition. The grace/probe logic wraps around that structure rather than the single-order flow the plan described.
  • Judgment call: the eviction grace period distinguishes superseded from evicted. A cert none of whose domains map to it anymore (superseded — a replacement is loaded) is GC'd immediately as before; only a cert that still serves at least one mapped domain is kept until its own NotAfter. Blanket keep-everything would leave zombie certs clobbering the per-domain expiry gauges.
  • Related judgment call: the per-domain expiry gauge is now reported only by the certificate a domain currently maps to — a lingering evicted cert must not overwrite its successor's metric (map iteration order made the winner random before).
  • Deviation: the plan said renewal probes "all members"; it probes only dynamic members. A deploy-registered host may be DNS-01-only and unreachable over HTTP by design — probing it would wrongly quarantine a domain that renews fine. Same skip applies to probe-based culprit identification on the renewal path.
  • Deviation: the plan's "wildcard members are never probed" renewal test is structurally unreachable (wildcards are never dynamic, so the dynamic-only rule already excludes them); the *. guard is kept as belt-and-braces and unit-tested in identifyFailedDomains instead.
  • Judgment call: during a shrink hold, held domains keep their allowlist entry and any live certificate but earn no new ACME orders — issuance follows the polled list. A name the source stopped reporting shouldn't spend orders while its removal is in question.
  • Judgment call: the shrink guard has no absolute floor — removing 1 of 2 domains (50%) is held like removing 600 of 1100. The cost of holding is ~2 extra polls (10 min at default interval); the cost of a false-negative wipe is the estate. Threshold and confirmation count are constants (30%, 3), not flags, per the interview decision.
  • Judgment call: hold state is memory-only. A restart reloads the last applied (unshrunk) set, so the confirmation count restarts — conservative direction.
  • Deviation from issuer semantics on the deploy path: an unattributable handshake-batch failure (generic ACME outage) quarantines nobody and restores everything to pending, exactly as the unguarded path did. The issuer/renewer quarantine the whole batch in that case; deploy-registered hosts are operator-curated and shouldn't ride a 24h ladder because Let's Encrypt had a bad day. Only culprits identified by error-parsing or probing are quarantined.
  • Discovery/refactor: provisionCertificate used to collect the batch and mark it non-pending under one lock. The guard's probes do network I/O, so the provisioning slot is now claimed first (concurrent handshakes wait on it), then probes run unlocked, then the kept domains leave pending. No new race: the slot was always the mutual exclusion.
  • Moved failedDomainsFromError from domain_issuer.go into the new domain_failure.go alongside identifyFailedDomains (shared by issuer + renewer + batch guard).

Summary by cubic

Improves SAN certificate resilience: prevents bad domain polls and failing tenants from tearing down coverage, and reduces wasted ACME orders with preflight checks and smarter failure handling. Addresses #96.

  • New Features

    • Eviction grace period: fully-evicted certs keep serving until their own expiry; only superseded certs are removed immediately. Per-domain expiry is reported only by the cert a domain currently maps to.
    • Shrink guard: holds removals when a poll drops >30% of a service’s applied set until 3 consecutive polls confirm; additions still apply. Held domains keep serving but get no new orders. Visible in kamal-proxy domains list (Removal held) and domains stats. While holding, the source ETag is cleared to avoid 304 loops; the hold resets on redeploy.
    • Renewal preflight: probes dynamic members with bounded concurrency before renewal; unreachable ones are quarantined and deferred (when there’s time) or compacted out inside the 7‑day window. Skips deploy-registered hosts and wildcards.
    • Probe-based failure attribution: parse per-domain ACME errors; if absent, probe members (bounded concurrency); else quarantine the whole batch so retries back off. Shared by issuer and renewer.
    • Handshake-batch guard: filters quarantined/unreachable batch-mates out of provisioning (they keep their pending slot and don’t consume batch capacity). Probes every non-trigger mate, quarantines identified culprits on failure, and restores survivors; unattributable failures restore everyone. Claims the provisioning slot before probes, clears batch failure history on success, persists quarantine changes, and is wired via SetIssuanceGuard.
  • Bug Fixes

    • Stops renewal batches from looping hourly with no backoff when ACME errors lack per-domain attribution.
    • Prevents a single bad poll from deleting certs or mass-evicting domains; recovery costs zero new ACME orders.
    • CLI/stats: domains list shows “Removal held”; domains stats counts held removals.

Written for commit 260ec16. Summary will update on new commits.

Review in cubic

A certificate whose domains were all evicted was garbage-collected (files
and key deleted) on the next hourly reconcile. Eviction can be a lying
domain source — one empty or truncated poll from the application, and the
entire dynamic certificate estate was destroyed, forcing a full re-issuance
against ACME rate limits when the source recovered.

Now only a superseded certificate (no domain maps through it anymore, so a
loaded replacement answers every handshake) is removed immediately. An
evicted certificate that still serves a mapped domain is kept — never
renewed — until its own NotAfter passes. The per-domain expiry gauge is
reported only by the certificate a domain currently maps to, so lingering
certs cannot clobber their successor's metric.

## Test Coverage
- TestCertRenewer_KeepsFullyEvictedCertificateUntilExpiry
- TestCertRenewer_RemovesFullyEvictedCertificateAfterExpiry
- TestCertRenewer_EvictedCertificateSurvivesSourceRecovery: bad poll + reconcile + recovery costs zero orders
- TestCertRenewer_RemovesSupersededCertificateImmediately
- TestCertRenewer_SkipsCertificatesNoLongerReferenced: partially superseded cert lingers for its evicted member

Refs #96
applyDomains replaced a service's domain set wholesale, so one empty or
truncated response from the application evicted every dynamic domain at
once. Combined with certificate GC this made a single bad poll capable of
tearing down TLS for an entire tenant fleet.

A poll that removes more than 30% of the applied set now has its removals
held: the previous set stays allowed (plus any additions from the poll),
and only three consecutive over-threshold polls confirm and apply the
shrink. A recovering poll clears the hold and resets the count. Held
domains keep serving existing certificates but earn no new ACME orders.
Holds are visible in `kamal-proxy domains list` (Removal held column) and
`domains stats`, and every held poll logs at Warn.

The hold lives in memory only: a restart reloads the last applied
(unshrunk) set, which restarts the count in the conservative direction.

## Test Coverage
- TestDynamicDomainManager_ShrinkGuardHoldsMassRemovals: 60% removal held, addition applies
- TestDynamicDomainManager_ShrinkGuardHoldsEmptyPoll
- TestDynamicDomainManager_ShrinkGuardAppliesSmallRemovals: 10% removal applies immediately
- TestDynamicDomainManager_ShrinkGuardConfirmsAfterConsecutivePolls
- TestDynamicDomainManager_ShrinkGuardCancelsOnRecovery

Refs #96
The pre-flight probe only guarded never-issued domains, so a tenant whose
DNS moved away after issuance sat in every renewal order for its batch,
failing it at the ACME server — and when the failure was not attributable
to one domain, nothing was quarantined and the doomed batch retried every
reconcile until the whole certificate expired.

renew now probes each dynamic member before spending an order and treats
unreachable ones exactly like quarantined ones: defer while there is time,
compact them out inside the compaction window. Deploy-registered hosts are
not probed — a DNS-01-only deployment may be unreachable over HTTP by
design — and wildcards have no name to answer on.

## Test Coverage
- TestCertRenewer_ProbesDynamicMembersBeforeRenewal: unreachable member compacted out near expiry
- TestCertRenewer_DefersRenewalWhenMemberFailsProbeFarFromExpiry
- TestCertRenewer_DefersRenewalWhenAllMembersFailProbe
- TestCertRenewer_SkipsProbeForRegisteredMembers: DNS-01-only deploys still renew

Refs #96
When an order failed without lego's per-domain error lines, the issuer
quarantined the whole batch blindly and the renewer quarantined nobody —
so a failing renewal batch retried every reconcile with no backoff and no
compaction, riding unchanged until the certificate expired.

identifyFailedDomains (new internal/server/domain_failure.go, absorbing
failedDomainsFromError) now attributes failures in three steps: parse the
error's per-domain lines; failing that, pre-flight-probe the members and
blame the unreachable ones; failing that, hold the entire batch on the
quarantine ladder. Both the issuer and the renewer use it — the issuer
gains probe-based attribution (survivors keep their retry), the renewer
gains the quarantine-nobody fix. The renewer probes only dynamic members,
since a registered host may be DNS-01-only and unreachable over HTTP by
design.

## Test Coverage
- TestIdentifyFailedDomains: table-driven precedence (parse > probe > all), wildcard skip
- TestDomainIssuer_Issue_ProbesForCulpritsOnUnattributableFailure
- TestCertRenewer_QuarantinesWholeBatchOnUnattributableFailure

Refs #96
provisionCertificate batched every pending deploy-registered host into the
triggering handshake's order with no protection: one typo'd --host failed
the whole order, everything returned to pending, and the same doomed batch
retried on every handshake, starving the healthy hosts of certificates.

A new issuance guard (san_cert_batch_guard.go), wired to the dynamic
subsystem's probe and quarantine at boot, now filters batch-mates before
the order — quarantined or unreachable mates keep their pending slot but
stay out of the order — and attributes failures afterwards: identified
culprits are quarantined while survivors return to pending. The triggering
domain is never dropped, and an unattributable failure (a generic ACME
outage) restores everything unquarantined, exactly as an unguarded batch
would. The provisioning slot is claimed before the probes run, so
concurrent handshakes wait instead of racing into duplicate orders.

## Test Coverage
- TestBatchGuard_QuarantinedBatchMateIsSkippedButStaysPending
- TestBatchGuard_UnreachableBatchMateIsQuarantinedWithoutBurningAnOrder
- TestBatchGuard_TriggerDomainIsNeverDropped
- TestBatchGuard_QuarantinesCulpritsAndRestoresSurvivorsOnFailure
- TestBatchGuard_UnattributableFailureRestoresEverythingUnquarantined
- TestBatchGuard_NoGuardInstalledPreservesBehavior

Refs #96

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 14 files

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread internal/server/san_cert_batch_guard.go Outdated
Comment thread internal/server/dynamic_domains.go
Comment thread internal/server/san_cert_manager.go
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread internal/server/san_cert_batch_guard.go Outdated
Comment thread internal/server/san_cert_batch_guard.go
Comment thread internal/server/san_cert_batch_guard.go
Comment thread internal/server/dynamic_domains.go
Comment thread internal/cmd/domains.go Outdated
@mhenrixon mhenrixon self-assigned this Aug 10, 2026
- Probe every non-trigger batch-mate, not just never-issued ones: an
  expiring host whose DNS moved away must not ride into the order on the
  strength of a certificate it once held.
- Run pre-flight probes with bounded concurrency (16) everywhere — batch
  filtering, renewal member checks, and failure attribution — so a batch
  of unreachable domains costs a few probe timeouts, not minutes of a
  blocked handshake or issuance slot.
- Clear the source's ETag while a shrink hold is active: an unchanged
  source would answer 304 forever and the confirmation count could never
  advance, leaving a legitimate mass removal held indefinitely.
- Reset the shrink hold on redeploy so confirmations counted against a
  replaced source do not carry over to its successor.
- Persist guard-driven quarantine mutations (new onChange hook wired to
  the dynamic-domains state save) and clear a batch's failure history on
  successful issuance, matching the dynamic issuer.
- Skip quarantined domains during batch collection so they cannot consume
  the batch's slots away from eligible hosts.
- CLI: constant-time held-removal lookup; README: correct the shrink-guard
  and eviction-grace claims (three confirmed polls do apply a removal;
  normal renewal rules still apply to recovered domains).

Refs #97
@mhenrixon
mhenrixon merged commit 59e4f61 into dash Aug 10, 2026
3 checks passed
@mhenrixon
mhenrixon deleted the feature/san-batch-resilience branch August 10, 2026 07:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SAN batch resilience: eviction grace + shrink guard, renewal preflight for all members, deploy-path quarantine

1 participant