acme: one writer, every replica a reader, every write fenced

With replicas > 1 the file store is one file PER NODE and nothing notices.
Measured on this fleet: 71 certificates on one replica, 63 on the other,
EIGHT hosts present on exactly one. Those hosts serve the right certificate
or a wrong one depending on which replica the balancer picked, and under
sniStrict a missing certificate is a failed handshake, not a downgrade -- so
roughly half of connections to a one-replica host did not complete. Both
replicas also ordered every name, so a rescheduled pod with an empty hostPath
re-orders ~70 names into Let's Encrypt's 50-per-week ceiling.

SharedStore puts the state in one Kubernetes object every replica reads.

WHO writes comes from hanzoai/ha at the single key "acme" -- with one key an
HRW owner IS a single leader, the one shape where that word is accurate. The
ROUND comes from a Kubernetes Lease, and leaseTransitions is not an
approximation: the API server increments it on every handoff, which is
verbatim ha's requirement that a round be monotone across all callers and
strictly increase when the role moves. No consensus of our own, no second
coordination system.

Leadership alone is not safety, which is the part worth being explicit about.
A lease can expire while its holder still runs -- a paused process, a
partitioned node -- so every write carries its round AND its holder, and the
object refuses a write that is behind the recorded round, or level with it
under a different identity. That second case is not redundant: the Lease and
the Secret are two objects and Kubernetes has no transaction spanning them, so
two replicas can briefly believe they hold the same round. The object decides,
in one resourceVersion-checked compare-and-swap, and a deposed writer becomes
harmless rather than unlikely.

A reader also orders NOTHING. getUncheckedDomains gates first, before it
touches any state, because everything past it is the expensive half: a DNS-01
round trip, a rate-limit slot, and a challenge TXT record two racing replicas
delete out from under each other. LocalStore deliberately does NOT implement
the gate -- a single replica has no election to consult, and requiring one
would invent a failure mode where none existed.

Rolling upgrade falls out. State is in the object, so a new pod starts knowing
every certificate and orders none; a pod drained mid-lease loses the round, not
the data.

Two bugs the fake API server caught that reasoning did not: existence was
inferred from an empty resourceVersion, which conflates absent with
present-but-unversioned and makes every write take Create and fail
AlreadyExists forever; and an empty resourceVersion was treated as
"unchanged", which pins a reader to its first read so it never sees a
certificate the writer obtained -- silently, since nothing errors.

OPT-IN. Default is unchanged (the file), because turning this on relocates the
estate's TLS state and the shared object starts empty, so the writer re-orders
what the file held -- a deliberate act against a rate limit, not something to
discover on restart. A configured shared store that cannot be built is FATAL:
falling back would reinstate per-node state exactly when the cluster is
unreachable, quietly, which is how the current split went unnoticed.

Tests drive two stores over ONE fake API server, because the bug is invisible
to a single-replica test: one writer only, reader serves the writer's certs,
deposed writer refused, round advances on handoff, fresh replica inherits
state AND account, conflict retried without loss, reader orders nothing,
LocalStore not gated. cmd/ingress builds and vets clean (webui/embed.go's
missing frontend assets fail identically on a pristine tree).

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-05 17:29:16 -07:00
co-authored by Hanzo Dev
parent 5301c301c0
commit 4f3f6d2994
6 changed files with 1325 additions and 589 deletions
+40 -7
View File
@@ -18,9 +18,6 @@ import (
"github.com/coreos/go-systemd/v22/daemon"
"github.com/go-acme/lego/v4/challenge"
gokitmetrics "github.com/go-kit/kit/metrics"
"github.com/rs/zerolog/log"
"github.com/sirupsen/logrus"
"github.com/spiffe/go-spiffe/v2/workloadapi"
"github.com/hanzoai/ingress-parser/cli"
"github.com/hanzoai/ingress/cmd"
"github.com/hanzoai/ingress/cmd/healthcheck"
@@ -48,6 +45,9 @@ import (
"github.com/hanzoai/ingress/pkg/tcp"
ingresstls "github.com/hanzoai/ingress/pkg/tls"
"github.com/hanzoai/ingress/pkg/version"
"github.com/rs/zerolog/log"
"github.com/sirupsen/logrus"
"github.com/spiffe/go-spiffe/v2/workloadapi"
)
func main() {
@@ -442,7 +442,9 @@ func switchRouter(routerFactory *server.RouterFactory, serverEntryPointsTCP serv
// initACMEProvider creates and registers acme.Provider instances corresponding to the configured ACME certificate resolvers.
func initACMEProvider(c *static.Configuration, providerAggregator *aggregator.ProviderAggregator, tlsManager *ingresstls.Manager, httpChallengeProvider, tlsChallengeProvider challenge.Provider, routinesPool *safe.Pool) []*acme.Provider {
localStores := map[string]*acme.LocalStore{}
// acme.Store, not *acme.LocalStore: where the state lives is now a decision
// (see acmeStore), and a map typed to one implementation cannot hold the other.
acmeStores := map[string]acme.Store{}
var resolvers []*acme.Provider
for name, resolver := range c.CertificatesResolvers {
@@ -450,13 +452,13 @@ func initACMEProvider(c *static.Configuration, providerAggregator *aggregator.Pr
continue
}
if localStores[resolver.ACME.Storage] == nil {
localStores[resolver.ACME.Storage] = acme.NewLocalStore(resolver.ACME.Storage, routinesPool)
if acmeStores[resolver.ACME.Storage] == nil {
acmeStores[resolver.ACME.Storage] = acmeStore(resolver.ACME.Storage, routinesPool)
}
p := &acme.Provider{
Configuration: resolver.ACME,
Store: localStores[resolver.ACME.Storage],
Store: acmeStores[resolver.ACME.Storage],
ResolverName: name,
HTTPChallengeProvider: httpChallengeProvider,
TLSChallengeProvider: tlsChallengeProvider,
@@ -638,3 +640,34 @@ func collect(staticConfiguration *static.Configuration) {
}
})
}
// acmeStore picks where ACME state lives.
//
// The default is unchanged: a local file, which is correct and simplest for ONE
// replica. Set ACME_SHARED_STORE_NAMESPACE (the operator normally maps it from
// metadata.namespace) and state moves to a Kubernetes object every replica reads,
// with one elected writer — which is what more than one replica requires.
//
// It is opt-in rather than automatic because turning it on relocates the estate's
// TLS state, and the migration is one-way in practice: the shared object starts
// empty, so the writer re-orders whatever the file held. That is a deliberate act
// against a rate limit, not something a deployment should discover on restart.
//
// A configured shared store that cannot be built is FATAL, never a silent
// downgrade to the file. Falling back would reinstate per-node state exactly when
// the cluster is unreachable — the moment two replicas are most likely to diverge
// — and it would do so quietly, which is how the current split went unnoticed for
// as long as it did.
func acmeStore(storage string, pool *safe.Pool) acme.Store {
ns := os.Getenv("ACME_SHARED_STORE_NAMESPACE")
if ns == "" {
return acme.NewLocalStore(storage, pool)
}
shared, err := acme.NewSharedStore(acme.SharedStoreConfig{Namespace: ns})
if err != nil {
log.Fatal().Err(err).Msg("ACME shared store is configured but could not be built; refusing to fall back to per-node state")
}
shared.Start(context.Background())
log.Info().Str("namespace", ns).Msg("ACME state is shared: one elected writer, every replica reads")
return shared
}
+107 -156
View File
@@ -1,22 +1,23 @@
module github.com/hanzoai/ingress
go 1.26.5
go 1.26.4
require github.com/hanzoai/cloud v1.801.293
require github.com/hanzoai/cloud v0.1.1
require (
github.com/hanzoai/ha v0.2.0
github.com/BurntSushi/toml v1.6.0
github.com/Masterminds/sprig/v3 v3.2.3
github.com/abbot/go-http-auth v0.0.0-00010101000000-000000000000 // No tag on the repo.
github.com/andybalholm/brotli v1.2.1
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.14 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect
github.com/aws/smithy-go v1.24.2
github.com/aws/aws-sdk-go-v2 v1.41.2 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.8 // indirect
github.com/aws/smithy-go v1.24.1
github.com/cenkalti/backoff/v4 v4.3.0
github.com/containous/alice v0.0.0-20181107144136-d83ebdd94cbd // No tag on the repo.
github.com/coreos/go-systemd/v22 v22.7.0
github.com/coreos/go-systemd/v22 v22.6.0
github.com/docker/docker v28.3.3+incompatible
github.com/docker/go-connections v0.5.0 // indirect
github.com/fatih/structs v1.1.0
@@ -34,11 +35,11 @@ require (
github.com/hanzokv/go/v9 v9.22.0
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/http-wasm/http-wasm-host-go v0.7.0
github.com/huandu/xstrings v1.5.0
github.com/influxdata/influxdb-client-go/v2 v2.14.0
github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c // No tag on the repo.
github.com/influxdata/influxdb-client-go/v2 v2.7.0
github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab // No tag on the repo.
github.com/klauspost/compress v1.18.6
github.com/mailgun/ttlmap v0.0.0-20170619185759-c1c17f74874f // No tag on the repo.
github.com/miekg/dns v1.1.72
@@ -59,15 +60,15 @@ require (
github.com/stvp/go-udp-testing v0.0.0-20201019212854-469649b16807 // No tag on the repo.
github.com/testcontainers/testcontainers-go v0.32.0
github.com/testcontainers/testcontainers-go/modules/k3s v0.32.0
github.com/tetratelabs/wazero v1.12.0
github.com/tetratelabs/wazero v1.8.0
github.com/tidwall/gjson v1.18.0
github.com/unrolled/render v1.0.2
github.com/unrolled/secure v1.15.0
github.com/valyala/fasthttp v1.72.0
github.com/vulcand/oxy/v2 v2.2.0
github.com/valyala/fasthttp v1.70.0
github.com/vulcand/oxy/v2 v2.0.3
github.com/vulcand/predicate v1.3.0
github.com/yuin/gopher-lua v1.1.1
go.opentelemetry.io/collector/pdata v1.54.0
go.opentelemetry.io/collector/pdata v1.51.0
go.opentelemetry.io/contrib/bridges/otellogrus v0.13.0
go.opentelemetry.io/contrib/propagators/autoprop v0.63.0
go.opentelemetry.io/otel v1.44.0
@@ -77,28 +78,28 @@ require (
go.opentelemetry.io/otel/sdk/log v0.20.0
go.opentelemetry.io/otel/sdk/metric v1.44.0
go.opentelemetry.io/otel/trace v1.44.0
golang.org/x/crypto v0.54.0
golang.org/x/mod v0.37.0
golang.org/x/net v0.57.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
golang.org/x/text v0.40.0
golang.org/x/crypto v0.52.0
golang.org/x/mod v0.36.0
golang.org/x/net v0.55.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.45.0
golang.org/x/text v0.37.0
golang.org/x/time v0.15.0
golang.org/x/tools v0.47.0
google.golang.org/grpc v1.81.1
golang.org/x/tools v0.45.0
google.golang.org/grpc v1.80.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.35.3
k8s.io/apiextensions-apiserver v0.35.0
k8s.io/apimachinery v0.35.3
k8s.io/client-go v0.35.3
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // No tag on the repo.
k8s.io/api v0.35.0
k8s.io/apiextensions-apiserver v0.34.3
k8s.io/apimachinery v0.35.0
k8s.io/client-go v0.35.0
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // No tag on the repo.
knative.dev/networking v0.0.0-20241022012959-60e29ff520dc
knative.dev/pkg v0.0.0-20241021183759-9b9d535af5ad
mvdan.cc/xurls/v2 v2.5.0
sigs.k8s.io/controller-runtime v0.23.3
sigs.k8s.io/controller-runtime v0.22.1
sigs.k8s.io/gateway-api v1.4.0
sigs.k8s.io/structured-merge-diff/v6 v6.3.2
sigs.k8s.io/structured-merge-diff/v6 v6.3.1
sigs.k8s.io/yaml v1.6.0
)
@@ -109,93 +110,38 @@ require (
github.com/luxfi/trace v1.4.0
github.com/luxfi/zap v1.2.6
github.com/prometheus/client_golang v1.23.2
github.com/zap-proto/http v0.3.1
github.com/zap-proto/http v0.1.0
)
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fasthttp/websocket v1.5.12 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/evanw/esbuild v0.28.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/gorilla/rpc v1.2.1 // indirect
github.com/grandcat/zeroconf v1.0.0 // indirect
github.com/hanzo-ds/go v1.0.1 // indirect
github.com/hanzo-ds/native v0.72.0 // indirect
github.com/hanzoai/account v0.2.0 // indirect
github.com/hanzoai/commerce v1.49.29 // indirect
github.com/hanzoai/csqlite v0.1.0 // indirect
github.com/hanzoai/dbx v1.17.2 // indirect
github.com/hanzoai/decimal v0.1.1 // indirect
github.com/hanzoai/go-openai v1.41.0 // indirect
github.com/hanzoai/ha v0.1.1 // indirect
github.com/hanzoai/iam v1.33.26 // indirect
github.com/hanzoai/money v0.2.1 // indirect
github.com/hanzoai/orm v0.6.18 // indirect
github.com/hanzoai/s3-go v1.0.0 // indirect
github.com/hanzoai/sqlcipher v0.1.1 // indirect
github.com/hanzoai/sqlite v0.4.0 // indirect
github.com/hanzoai/tasks v1.51.4 // indirect
github.com/hanzoai/vfs v0.6.6 // indirect
github.com/holiman/uint256 v1.3.2 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/luxfi/accel v1.2.4 // indirect
github.com/luxfi/bft v0.1.5 // indirect
github.com/luxfi/cache v1.3.1 // indirect
github.com/luxfi/compress v0.1.1 // indirect
github.com/luxfi/concurrent v0.1.1 // indirect
github.com/luxfi/consensus v1.36.11 // indirect
github.com/luxfi/constants v1.6.2 // indirect
github.com/luxfi/container v0.2.1 // indirect
github.com/luxfi/crypto v1.20.2 // indirect
github.com/luxfi/database v1.21.1 // indirect
github.com/luxfi/geth v1.20.1 // indirect
github.com/luxfi/ids v1.3.2 // indirect
github.com/luxfi/math v1.5.1 // indirect
github.com/luxfi/math/big v0.1.0 // indirect
github.com/luxfi/mdns v0.1.1 // indirect
github.com/luxfi/mock v0.1.1 // indirect
github.com/luxfi/p2p v1.22.1 // indirect
github.com/luxfi/pq v1.1.0 // indirect
github.com/luxfi/sampler v1.1.0 // indirect
github.com/luxfi/validators v1.3.1 // indirect
github.com/luxfi/version v1.0.1 // indirect
github.com/luxfi/warp v1.24.1 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/mr-tron/base58 v1.3.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oapi-codegen/runtime v1.3.1 // indirect
github.com/paulmach/orb v0.13.0 // indirect
github.com/pierrec/lz4/v4 v4.1.27 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/shoenig/test v1.7.0 // indirect
github.com/supranational/blst v0.3.16 // indirect
github.com/zap-proto/fiber/v3 v3.2.1 // indirect
github.com/zap-proto/go v1.3.0 // indirect
github.com/zap-proto/md v0.1.0 // indirect
go.uber.org/mock v0.6.0 // indirect
gonum.org/v1/gonum v0.17.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.51.0 // indirect
)
require (
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth v0.18.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
dario.cat/mergo v1.0.2 // indirect
@@ -203,7 +149,7 @@ require (
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect
@@ -217,8 +163,8 @@ require (
github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect
github.com/Azure/go-autorest/logger v0.2.1 // indirect
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.0 // indirect
github.com/HdrHistogram/hdrhistogram-go v1.2.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/HdrHistogram/hdrhistogram-go v1.1.2 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
@@ -226,25 +172,25 @@ require (
github.com/VividCortex/gohistogram v1.0.0 // indirect
github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.16 // indirect
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.15 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/tea v1.4.0 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
github.com/aliyun/credentials-go v1.4.7 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/lightsail v1.53.0 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 // indirect
github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.11 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.1 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aziontech/azionapi-go-sdk v0.144.0 // indirect
github.com/baidubce/bce-sdk-go v0.9.264 // indirect
github.com/baidubce/bce-sdk-go v0.9.260 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/blendle/zapdriver v1.3.1 // indirect
github.com/boombuler/barcode v1.0.1 // indirect
@@ -258,6 +204,7 @@ require (
github.com/containerd/platforms v1.0.0-rc.1 // indirect
github.com/cpuguy83/dockercfg v0.3.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/deepmap/oapi-codegen v1.9.1 // indirect
github.com/desertbit/timer v0.0.0-20180107155436-c41aec40b27f // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dimchansky/utfbom v1.1.1 // indirect
@@ -276,34 +223,35 @@ require (
github.com/go-acme/jdcloud-sdk-go v1.64.0 // indirect
github.com/go-acme/tencentclouddnspod v1.3.24 // indirect
github.com/go-acme/tencentedgdeone v1.3.38 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-errors/errors v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/swag v0.25.5 // indirect
github.com/go-openapi/swag/cmdutils v0.25.5 // indirect
github.com/go-openapi/swag/conv v0.25.5 // indirect
github.com/go-openapi/swag/fileutils v0.25.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
github.com/go-openapi/swag/loading v0.25.5 // indirect
github.com/go-openapi/swag/mangling v0.25.5 // indirect
github.com/go-openapi/swag/netutils v0.25.5 // indirect
github.com/go-openapi/swag/stringutils v0.25.5 // indirect
github.com/go-openapi/swag/typeutils v0.25.5 // indirect
github.com/go-openapi/swag/yamlutils v0.25.5 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.4 // indirect
github.com/go-openapi/swag v0.25.4 // indirect
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/fileutils v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/mangling v0.25.4 // indirect
github.com/go-openapi/swag/netutils v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.2 // indirect
github.com/go-resty/resty/v2 v2.17.2 // indirect
github.com/go-resty/resty/v2 v2.17.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gofiber/fiber/v3 v3.2.0 // indirect
github.com/gofiber/schema v1.7.1 // indirect
github.com/gofiber/utils/v2 v2.0.4 // indirect
github.com/gofrs/flock v0.13.0 // indirect
@@ -312,23 +260,24 @@ require (
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.1-0.20241114170450-2d3c2a9cc518 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/gophercloud/gophercloud v1.14.1 // indirect
github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect
github.com/gravitational/trace v1.5.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/hcl v1.0.1-vault-5 // indirect
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.187 // indirect
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect
github.com/imdario/mergo v0.3.16 // indirect
github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // indirect
github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect
github.com/infobloxopen/infoblox-go-client/v2 v2.10.0 // indirect
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect
@@ -337,16 +286,16 @@ require (
github.com/labbsr0x/bindman-dns-webhook v1.0.2 // indirect
github.com/labbsr0x/goh v1.0.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/linode/linodego v1.67.0 // indirect
github.com/linode/linodego v1.65.0 // indirect
github.com/liquidweb/liquidweb-cli v0.6.9 // indirect
github.com/liquidweb/liquidweb-go v1.6.4 // indirect
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mailgun/minheap v0.0.0-20170619185613-3dbe6c6bf55f // indirect
github.com/mailgun/multibuf v0.1.2 // indirect
github.com/mailgun/timetools v0.0.0-20141028012446-7e6055773c51 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mimuret/golang-iij-dpf v0.9.1 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
@@ -401,7 +350,8 @@ require (
github.com/sacloud/go-http v0.1.9 // indirect
github.com/sacloud/iaas-api-go v1.23.1 // indirect
github.com/sacloud/packages-go v0.0.12 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 // indirect
github.com/selectel/domains-go v1.1.0 // indirect
github.com/selectel/go-selvpcclient/v4 v4.1.0 // indirect
@@ -411,37 +361,38 @@ require (
github.com/softlayer/softlayer-go v1.2.1 // indirect
github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect
github.com/sony/gobreaker v1.0.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/spf13/viper v1.18.2 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.77 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.48 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/transip/gotransip/v6 v6.26.1 // indirect
github.com/ultradns/ultradns-go-sdk v1.8.1-20250722213956-faef419 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/vinyldns/go-vinyldns v0.9.17 // indirect
github.com/volcengine/volc-sdk-golang v1.0.241 // indirect
github.com/vultr/govultr/v3 v3.30.0 // indirect
github.com/volcengine/volc-sdk-golang v1.0.237 // indirect
github.com/vultr/govultr/v3 v3.27.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yandex-cloud/go-genproto v0.54.0 // indirect
github.com/yandex-cloud/go-sdk/services/dns v0.0.36 // indirect
github.com/yandex-cloud/go-sdk/v2 v2.56.0 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zap-proto/zip v1.24.2
go.mongodb.org/mongo-driver v1.17.9 // indirect
github.com/zap-proto/zip v1.1.0
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/collector/featuregate v1.54.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/collector/featuregate v1.51.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
go.opentelemetry.io/contrib/propagators/aws v1.38.0 // indirect
go.opentelemetry.io/contrib/propagators/b3 v1.39.0 // indirect
go.opentelemetry.io/contrib/propagators/jaeger v1.38.0 // indirect
@@ -452,21 +403,21 @@ require (
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/api v0.275.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260615183401-62b3387ff324 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
google.golang.org/api v0.267.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.1 // indirect
gopkg.in/ns1/ns1-go.v2 v2.17.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
nhooyr.io/websocket v1.8.7 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
+298 -425
View File
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -26,7 +26,6 @@ import (
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/providers/dns"
"github.com/go-acme/lego/v4/registration"
"github.com/rs/zerolog/log"
ptypes "github.com/hanzoai/ingress-parser/types"
"github.com/hanzoai/ingress/pkg/config/dynamic"
httpmuxer "github.com/hanzoai/ingress/pkg/muxer/http"
@@ -36,6 +35,7 @@ import (
ingresstls "github.com/hanzoai/ingress/pkg/tls"
"github.com/hanzoai/ingress/pkg/types"
"github.com/hanzoai/ingress/pkg/version"
"github.com/rs/zerolog/log"
)
const resolverSuffix = ".acme"
@@ -968,6 +968,22 @@ func (p *Provider) renewCertificates(ctx context.Context, renewPeriod time.Durat
func (p *Provider) getUncheckedDomains(ctx context.Context, domainsToCheck []string, tlsStore string) []string {
log.Ctx(ctx).Debug().Msgf("Looking for provided certificate(s) to validate %q...", domainsToCheck)
// A replica that is not the ACME writer orders NOTHING. This is the single
// point where that is decidable, because everything downstream of it is the
// expensive half: a DNS-01 round trip, a rate-limit slot against Let's
// Encrypt's 50-per-week registered-domain ceiling, and a challenge TXT record
// that two replicas racing to write and clean up can delete out from under
// each other. Declining here means a reader spends none of it and simply
// serves what the writer obtained.
//
// A store that does not know about writers (LocalStore) is single-writer by
// construction, so it never gates and every replica orders — which is the
// correct behaviour for one replica and the bug for more than one.
if gate, ok := p.Store.(writerGate); ok && !gate.IsWriter(ctx) {
log.Ctx(ctx).Debug().Msg("Not the ACME writer for this deployment; leaving resolution to the writer")
return nil
}
var allDomains []string
store := p.tlsManager.GetStore(tlsStore)
if store != nil {
+545
View File
@@ -0,0 +1,545 @@
// Copyright 2025 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package acme
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"sync"
"time"
"github.com/hanzoai/ha"
"github.com/rs/zerolog/log"
coordv1 "k8s.io/api/coordination/v1"
corev1 "k8s.io/api/core/v1"
kerrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
// SharedStore is the ACME store for MORE THAN ONE REPLICA: one writer, the rest
// readers, over a single object every replica can see.
//
// # The defect it exists to fix
//
// LocalStore keeps acme.json on a filesystem. With replicas > 1 on a hostPath
// that is one FILE PER NODE, and nothing about the design notices: each replica
// resolves independently, writes its own file, and the two drift apart. Measured
// on this fleet — 71 certificates on one replica, 63 on the other, EIGHT hosts
// present on exactly one. Those hosts serve the right certificate or a wrong one
// depending on which replica the load balancer picked, and with sniStrict a
// missing certificate is not a downgrade but a failed handshake. So roughly half
// of connections to a host only one replica holds simply do not complete.
//
// Duplicating the work is the second half of it. Both replicas order every name,
// so a rescheduled pod with an empty hostPath re-orders the whole estate — ~70
// certificates against Let's Encrypt's 50-per-week registered-domain ceiling,
// which is how a routine restart turns into a week without new certificates.
//
// # One writer, and the rest read
//
// WHO writes is decided by github.com/hanzoai/ha, the fleet's one election, at
// the single key "acme": with one key an HRW owner IS a single leader, which is
// the one shape where that word is accurate. A replica that does not own the key
// never orders and never writes — it READS, so it serves every certificate the
// owner obtained without duplicating a single order.
//
// The round comes from a Kubernetes Lease, and it is not an approximation of one:
// Lease.spec.leaseTransitions is a counter the API server increments whenever the
// holder changes, which is verbatim ha's requirement — "monotone non-decreasing
// per key across ALL callers, and STRICTLY increases whenever the writer role
// changes hands". The API server is the linearizable source ha's doc says the
// seam is shaped to accept; no consensus of our own, no second coordination
// system, nothing to operate.
//
// # Why a deposed writer is harmless rather than unlikely
//
// A lease can expire while its holder is still running — a paused process, a
// partitioned node — so leadership alone is not safety. Every write carries the
// round it was made under, and a write whose round is BELOW the round already
// recorded is refused. That is the storage half: the API server's own
// resourceVersion precondition makes the read-modify-write atomic, so two
// replicas cannot interleave, and the round makes the loser's data stale rather
// than merely late. A writer that has lost the lease and does not know it yet
// cannot damage the winner's state.
//
// # Rolling upgrade, with no re-issuance and no gap
//
// Because state lives in the object and not on a node, a new pod starts with
// every certificate already known and orders nothing. It serves immediately, and
// if it later takes the lease it continues from where its predecessor stopped.
// The old pod, drained mid-lease, loses the round rather than the data. That is
// the whole of zero-downtime here: no re-issuance storm on reschedule, no window
// where a replica is serving from an empty store.
//
// Readers poll rather than watch. A poll cannot miss an event it was not
// connected for, needs no reconnect logic, and is bounded by the interval; a
// watch is faster and is the thing that silently stops delivering. The cost of
// being one interval late is that a brand-new host is served by the owner a few
// seconds before its readers — which is what the interval is for.
type SharedStore struct {
client kubernetes.Interface
namespace string
name string // Secret holding the ACME state
leaseName string // Lease naming the writer
self string // this replica's stable identity (pod name)
leaseDuration time.Duration
pollInterval time.Duration
mu sync.RWMutex
cache map[string]*StoredData // resolver -> state, last read
rv string // resourceVersion the cache was read at ("" when the server sets none)
loaded bool // the cache has been filled at least once
leases ha.Leases // the round source
}
// storeKey is the Secret key holding the serialized per-resolver state. One key,
// because the state is one document: a partial write must not be able to leave an
// account without its certificates.
const storeKey = "acme.json"
// roundKey records the round the current contents were written under, and
// holderKey the identity that wrote them. Both live in the SAME object as the
// data, so a reader cannot see one without the others and a writer cannot update
// the data without restating who it was and under what round.
//
// The holder is not redundant with the round. A round fences a writer that is
// BEHIND; it says nothing about two writers who believe they hold the same round,
// which is reachable because the Lease and this Secret are two objects and
// Kubernetes has no transaction spanning them. Refusing a same-round write from a
// different identity closes that: the object itself decides, and it decides with
// one atomic compare-and-swap rather than a handshake across two.
const roundKey = "acme.round"
const holderKey = "acme.holder"
// ErrNotWriter is returned by a write on a replica that does not own the key. It
// is not an error condition — it is the correct outcome for a reader, and the
// caller logs it at debug and carries on.
var ErrNotWriter = errors.New("acme: this replica is not the ACME writer")
// ErrStaleRound is returned when a write's round is below the round already
// recorded. The writer has been deposed and has not noticed yet; refusing is what
// makes that harmless.
var ErrStaleRound = errors.New("acme: write refused, round superseded")
// SharedStoreConfig is the operator-facing shape. Zero values are filled with
// defaults that are safe for two replicas, so the only required field is what
// cannot be guessed: which namespace to write in.
type SharedStoreConfig struct {
Namespace string
SecretName string
LeaseName string
Self string
LeaseDuration time.Duration
PollInterval time.Duration
}
// NewSharedStore builds the store from in-cluster credentials.
//
// It fails rather than degrading to a local file. A store that silently falls
// back to per-node state is the defect this type exists to remove, and it would
// reappear exactly when the cluster is unreachable — the moment when two replicas
// are most likely to disagree.
func NewSharedStore(cfg SharedStoreConfig) (*SharedStore, error) {
restCfg, err := rest.InClusterConfig()
if err != nil {
return nil, fmt.Errorf("acme shared store needs in-cluster credentials: %w", err)
}
client, err := kubernetes.NewForConfig(restCfg)
if err != nil {
return nil, fmt.Errorf("acme shared store: build client: %w", err)
}
return newSharedStore(client, cfg)
}
// newSharedStore is the injectable half, so a test drives a fake clientset and
// the defaults are exercised by the same code production runs.
func newSharedStore(client kubernetes.Interface, cfg SharedStoreConfig) (*SharedStore, error) {
if cfg.Namespace == "" {
cfg.Namespace = os.Getenv("POD_NAMESPACE")
}
if cfg.Namespace == "" {
return nil, errors.New("acme shared store: namespace is required (set POD_NAMESPACE)")
}
if cfg.Self == "" {
cfg.Self = os.Getenv("POD_NAME")
}
if cfg.Self == "" {
// Hostname is the pod name under Kubernetes, and it is stable for the
// pod's life — which is what ha requires of an identity.
if h, err := os.Hostname(); err == nil {
cfg.Self = h
}
}
if cfg.Self == "" {
return nil, errors.New("acme shared store: a stable self identity is required (set POD_NAME)")
}
if cfg.SecretName == "" {
cfg.SecretName = "ingress-acme"
}
if cfg.LeaseName == "" {
cfg.LeaseName = "ingress-acme"
}
if cfg.LeaseDuration <= 0 {
cfg.LeaseDuration = 30 * time.Second
}
if cfg.PollInterval <= 0 {
cfg.PollInterval = 10 * time.Second
}
s := &SharedStore{
client: client,
namespace: cfg.Namespace,
name: cfg.SecretName,
leaseName: cfg.LeaseName,
self: cfg.Self,
leaseDuration: cfg.LeaseDuration,
pollInterval: cfg.PollInterval,
cache: map[string]*StoredData{},
}
s.leases = &kubeLeases{store: s}
return s, nil
}
// IsWriter reports whether this replica currently owns the ACME role. The
// ordering path asks it so a reader does not order a certificate it would then be
// refused permission to store — the order is the expensive half (a DNS-01
// round-trip and a rate-limit slot), so declining early is the point.
func (s *SharedStore) IsWriter(ctx context.Context) bool {
_, err := s.leases.Acquire(ctx, "acme")
return err == nil
}
// Start begins the reader poll. It returns immediately; the loop exits when ctx
// is done. The owner polls too: it is how it notices a write it did not make,
// which is what a lease handoff looks like from the new owner's side.
func (s *SharedStore) Start(ctx context.Context) {
go func() {
t := time.NewTicker(s.pollInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := s.refresh(ctx); err != nil && !kerrors.IsNotFound(err) {
log.Debug().Err(err).Msg("acme shared store: refresh")
}
}
}
}()
}
// refresh reloads the cache when the object has changed. It compares
// resourceVersion first, so a poll that finds nothing new costs one GET and no
// deserialization.
func (s *SharedStore) refresh(ctx context.Context) error {
sec, err := s.client.CoreV1().Secrets(s.namespace).Get(ctx, s.name, metav1.GetOptions{})
if err != nil {
return err
}
// An EMPTY resourceVersion carries no version to compare, so it can only mean
// reload. Treating it as "unchanged" makes the first read stick forever and a
// reader never sees a certificate the writer obtained — silently, because
// nothing errors.
s.mu.RLock()
unchanged := sec.ResourceVersion != "" && sec.ResourceVersion == s.rv
s.mu.RUnlock()
if unchanged {
return nil
}
data, err := decodeStored(sec.Data[storeKey])
if err != nil {
return err
}
s.mu.Lock()
s.cache, s.rv, s.loaded = data, sec.ResourceVersion, true
s.mu.Unlock()
return nil
}
// GetAccount returns the resolver's ACME account. Every replica reads it,
// including readers: an account is what makes a certificate renewable, so a
// replica that took over without one would have to register a new account and
// re-order everything it already holds.
func (s *SharedStore) GetAccount(resolverName string) (*Account, error) {
d, err := s.stateOf(resolverName)
if err != nil {
return nil, err
}
return d.Account, nil
}
func (s *SharedStore) SaveAccount(resolverName string, account *Account) error {
return s.mutate(resolverName, func(d *StoredData) { d.Account = account })
}
func (s *SharedStore) GetCertificates(resolverName string) ([]*CertAndStore, error) {
d, err := s.stateOf(resolverName)
if err != nil {
return nil, err
}
return d.Certificates, nil
}
// SaveCertificates replaces the resolver's certificate set, which is the shape
// the Store interface asks for. Under one writer that is safe: the owner holds the
// whole set in memory and re-states it. It is NOT safe under two, which is why the
// round check below is the load-bearing line and not a formality.
func (s *SharedStore) SaveCertificates(resolverName string, certificates []*CertAndStore) error {
return s.mutate(resolverName, func(d *StoredData) { d.Certificates = certificates })
}
// stateOf serves the cache, filling it on first use so a boot before the first
// poll tick is not blind.
func (s *SharedStore) stateOf(resolverName string) (*StoredData, error) {
s.mu.RLock()
d, seeded := s.cache[resolverName], s.loaded
s.mu.RUnlock()
if !seeded {
if err := s.refresh(context.Background()); err != nil && !kerrors.IsNotFound(err) {
return nil, err
}
s.mu.RLock()
d = s.cache[resolverName]
s.mu.RUnlock()
}
if d == nil {
return &StoredData{}, nil
}
return d, nil
}
// mutate is the one write path: acquire the round, read-modify-write under the
// API server's resourceVersion precondition, and refuse a superseded round.
//
// Ordering matters. The round is taken FIRST, so a replica that has lost the
// lease stops before it reads, and the precondition is checked LAST, so two
// writers that raced both cannot win. The retry exists for the benign conflict —
// the owner writing twice in quick succession — and gives up rather than looping,
// because a persistent conflict means someone else is writing and this replica
// should not be.
func (s *SharedStore) mutate(resolverName string, apply func(*StoredData)) error {
ctx := context.Background()
lease, err := s.leases.Acquire(ctx, "acme")
if err != nil {
return fmt.Errorf("%w: %w", ErrNotWriter, err)
}
const attempts = 3
for i := range attempts {
// Whether the object EXISTS comes from the Get error and nothing else.
// Inferring it from an empty resourceVersion conflates "absent" with
// "present but unversioned", and then every write takes the Create branch
// and fails AlreadyExists forever against an object that is right there.
var absent bool
sec, err := s.client.CoreV1().Secrets(s.namespace).Get(ctx, s.name, metav1.GetOptions{})
switch {
case kerrors.IsNotFound(err):
absent = true
sec = &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: s.name, Namespace: s.namespace},
Data: map[string][]byte{},
}
case err != nil:
return fmt.Errorf("acme shared store: read: %w", err)
}
// Two refusals, not one. BEHIND the recorded round means this writer was
// deposed and has not noticed. AT the recorded round but under a different
// identity means two replicas believe they hold the same round — the
// cross-object window between the Lease and this Secret, which no
// Kubernetes transaction spans. Either way the older writer's work becomes
// harmless instead of authoritative.
recorded, who := roundOf(sec), string(sec.Data[holderKey])
switch {
case recorded > lease.Round:
return fmt.Errorf("%w: at round %d, object records %d", ErrStaleRound, lease.Round, recorded)
case recorded == lease.Round && who != "" && who != s.self:
return fmt.Errorf("%w: round %d is held by %s, not %s", ErrStaleRound, recorded, who, s.self)
}
state, err := decodeStored(sec.Data[storeKey])
if err != nil {
return err
}
d := state[resolverName]
if d == nil {
d = &StoredData{}
state[resolverName] = d
}
apply(d)
blob, err := json.Marshal(state)
if err != nil {
return fmt.Errorf("acme shared store: encode: %w", err)
}
if sec.Data == nil {
sec.Data = map[string][]byte{}
}
sec.Data[storeKey] = blob
sec.Data[roundKey] = []byte(fmt.Sprintf("%d", lease.Round))
sec.Data[holderKey] = []byte(s.self)
var out *corev1.Secret
if absent {
out, err = s.client.CoreV1().Secrets(s.namespace).Create(ctx, sec, metav1.CreateOptions{})
} else {
// Update carries the resourceVersion read above, so the API server
// rejects it if anything wrote in between. That is the atomicity.
out, err = s.client.CoreV1().Secrets(s.namespace).Update(ctx, sec, metav1.UpdateOptions{})
}
switch {
case kerrors.IsConflict(err) || kerrors.IsAlreadyExists(err):
if i == attempts-1 {
return fmt.Errorf("acme shared store: %w after %d attempts", err, attempts)
}
continue
case err != nil:
return fmt.Errorf("acme shared store: write: %w", err)
}
s.mu.Lock()
s.cache, s.rv, s.loaded = state, out.ResourceVersion, true
s.mu.Unlock()
return nil
}
return errors.New("acme shared store: exhausted write attempts")
}
// roundOf reads the recorded round, treating anything unparseable as zero. A
// corrupt round must not wedge writing forever: zero admits the next write, which
// then records a good value.
func roundOf(sec *corev1.Secret) ha.Round {
var r uint64
if _, err := fmt.Sscanf(string(sec.Data[roundKey]), "%d", &r); err != nil {
return 0
}
return ha.Round(r)
}
// decodeStored parses the state document, treating empty as empty rather than as
// an error — a store that has never been written is the normal first-boot case.
func decodeStored(blob []byte) (map[string]*StoredData, error) {
out := map[string]*StoredData{}
if len(blob) == 0 {
return out, nil
}
if err := json.Unmarshal(blob, &out); err != nil {
return nil, fmt.Errorf("acme shared store: decode: %w", err)
}
return out, nil
}
// kubeLeases implements ha.Leases over a Kubernetes Lease.
//
// leaseTransitions is the round: the API server increments it on every handoff,
// so it is monotone across all callers and strictly increases when the role moves
// — ha's invariant, enforced by something that already exists rather than by us.
type kubeLeases struct{ store *SharedStore }
func (k *kubeLeases) Acquire(ctx context.Context, key string) (ha.Lease, error) {
s := k.store
api := s.client.CoordinationV1().Leases(s.namespace)
now := metav1.NewMicroTime(time.Now())
secs := int32(s.leaseDuration / time.Second)
cur, err := api.Get(ctx, s.leaseName, metav1.GetOptions{})
if kerrors.IsNotFound(err) {
created, cerr := api.Create(ctx, &coordv1.Lease{
ObjectMeta: metav1.ObjectMeta{Name: s.leaseName, Namespace: s.namespace},
Spec: coordv1.LeaseSpec{
HolderIdentity: &s.self,
LeaseDurationSeconds: &secs,
AcquireTime: &now,
RenewTime: &now,
LeaseTransitions: ptr[int32](1),
},
}, metav1.CreateOptions{})
if cerr != nil {
// Someone else created it first. That is a normal race and the
// answer is simply that we are not the writer this round.
return ha.Lease{}, fmt.Errorf("acquire %q: %w", key, cerr)
}
return leaseOf(key, s.self, created), nil
}
if err != nil {
return ha.Lease{}, fmt.Errorf("acquire %q: %w", key, err)
}
held := cur.Spec.HolderIdentity != nil && *cur.Spec.HolderIdentity == s.self
expired := cur.Spec.RenewTime == nil ||
time.Since(cur.Spec.RenewTime.Time) > s.leaseDuration
if !held && !expired {
return ha.Lease{}, fmt.Errorf("acquire %q: held by %s", key, holder(cur))
}
next := cur.DeepCopy()
next.Spec.RenewTime = &now
if !held {
// Taking over: the round MUST move, so a write from the previous holder
// is refused from this moment on.
next.Spec.HolderIdentity = &s.self
next.Spec.AcquireTime = &now
next.Spec.LeaseTransitions = ptr(transitions(cur) + 1)
}
out, err := api.Update(ctx, next, metav1.UpdateOptions{})
if err != nil {
// A conflict means another replica moved the lease first — it is the
// writer, we are not. Fail closed.
return ha.Lease{}, fmt.Errorf("acquire %q: %w", key, err)
}
return leaseOf(key, s.self, out), nil
}
func leaseOf(key, self string, l *coordv1.Lease) ha.Lease {
return ha.Lease{
Key: key,
Owner: ha.Member{ID: self},
Round: ha.Round(transitions(l)),
}
}
func transitions(l *coordv1.Lease) int32 {
if l.Spec.LeaseTransitions == nil {
return 0
}
return *l.Spec.LeaseTransitions
}
func holder(l *coordv1.Lease) string {
if l.Spec.HolderIdentity == nil {
return "<none>"
}
return *l.Spec.HolderIdentity
}
func ptr[T any](v T) *T { return &v }
// writerGate is implemented by a Store that knows whether THIS replica may write.
//
// It is an OPTIONAL interface on purpose: LocalStore does not implement it and
// must not, because a single-replica deployment has no election to consult and
// asking one would be a failure mode invented out of nothing. So the gate is
// present exactly when there is something to gate.
type writerGate interface {
IsWriter(ctx context.Context) bool
}
// Compile-time proof that SharedStore satisfies both the store contract and the
// gate, so a refactor that drops either is a build failure rather than a silent
// return to every-replica-orders.
var (
_ Store = (*SharedStore)(nil)
_ writerGate = (*SharedStore)(nil)
)
+318
View File
@@ -0,0 +1,318 @@
// Copyright 2025 Hanzo AI Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package acme
import (
"context"
"errors"
"k8s.io/apimachinery/pkg/runtime/schema"
"testing"
"time"
"github.com/hanzoai/ingress/pkg/types"
kerrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes/fake"
k8stesting "k8s.io/client-go/testing"
)
// Two replicas over ONE fake API server, which is the whole point: the bug being
// fixed is invisible to a single-replica test, and a mocked store would not have
// the conflict semantics that make the fix work.
func twoReplicas(t *testing.T) (a, b *SharedStore) {
t.Helper()
client := fake.NewSimpleClientset()
cfg := func(self string) SharedStoreConfig {
return SharedStoreConfig{Namespace: "hanzo", Self: self, LeaseDuration: time.Minute}
}
var err error
if a, err = newSharedStore(client, cfg("ingress-0")); err != nil {
t.Fatal(err)
}
if b, err = newSharedStore(client, cfg("ingress-1")); err != nil {
t.Fatal(err)
}
return a, b
}
func certs(domains ...string) []*CertAndStore {
out := make([]*CertAndStore, 0, len(domains))
for _, d := range domains {
out = append(out, &CertAndStore{Certificate: Certificate{Domain: types.Domain{Main: d}}})
}
return out
}
// EXACTLY ONE replica may write, and the other must be told so rather than
// silently succeeding into its own copy — which is what the file store did.
func TestOnlyOneReplicaIsTheWriter(t *testing.T) {
a, b := twoReplicas(t)
ctx := context.Background()
if !a.IsWriter(ctx) {
t.Fatal("the first replica to ask must become the writer")
}
if b.IsWriter(ctx) {
t.Fatal("a second replica claimed the writer role while the first held it")
}
if err := a.SaveCertificates("letsencrypt", certs("a.hanzo.ai")); err != nil {
t.Fatalf("owner write: %v", err)
}
err := b.SaveCertificates("letsencrypt", certs("b.hanzo.ai"))
if !errors.Is(err, ErrNotWriter) {
t.Fatalf("a reader's write must be refused as ErrNotWriter, got %v", err)
}
}
// The reader SERVES what the writer obtained. This is the property that makes one
// writer acceptable: a host is not stuck behind whichever replica happens to hold
// the lease.
func TestReaderServesTheWritersCertificates(t *testing.T) {
a, b := twoReplicas(t)
ctx := context.Background()
if !a.IsWriter(ctx) {
t.Fatal("expected the first replica to own the role")
}
if err := a.SaveCertificates("letsencrypt", certs("shared.hanzo.ai")); err != nil {
t.Fatal(err)
}
if err := b.refresh(ctx); err != nil {
t.Fatalf("reader refresh: %v", err)
}
got, err := b.GetCertificates("letsencrypt")
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Certificate.Domain.Main != "shared.hanzo.ai" {
t.Fatalf("reader did not see the writer's certificate: %+v", got)
}
}
// A writer that lost the lease and has not noticed must not be able to clobber the
// new writer's state. Heartbeat liveness cannot stop it; the round can.
func TestDeposedWriterIsRefused(t *testing.T) {
a, b := twoReplicas(t)
ctx := context.Background()
if !a.IsWriter(ctx) {
t.Fatal("expected a to own first")
}
if err := a.SaveCertificates("letsencrypt", certs("first.hanzo.ai")); err != nil {
t.Fatal(err)
}
// Expire the lease so b can legitimately take over, exactly as it would after
// a's node stopped renewing.
api := a.client.CoordinationV1().Leases("hanzo")
l, err := api.Get(ctx, a.leaseName, metav1.GetOptions{})
if err != nil {
t.Fatal(err)
}
stale := metav1.NewMicroTime(time.Now().Add(-2 * time.Minute))
l.Spec.RenewTime = &stale
if _, err := api.Update(ctx, l, metav1.UpdateOptions{}); err != nil {
t.Fatal(err)
}
if !b.IsWriter(ctx) {
t.Fatal("b must take over an expired lease")
}
if err := b.SaveCertificates("letsencrypt", certs("second.hanzo.ai")); err != nil {
t.Fatalf("new writer must be able to write: %v", err)
}
// a is still running and still believes it writes. Its round is now behind.
err = a.SaveCertificates("letsencrypt", certs("clobber.hanzo.ai"))
if err == nil {
t.Fatal("the deposed writer's write was ADMITTED — this is the split-brain the round exists to prevent")
}
// And the new writer's data survived intact.
if err := b.refresh(ctx); err != nil {
t.Fatal(err)
}
got, _ := b.GetCertificates("letsencrypt")
if len(got) != 1 || got[0].Certificate.Domain.Main != "second.hanzo.ai" {
t.Fatalf("the winner's state was damaged: %+v", got)
}
}
// The round must MOVE on handoff, or a deposed writer's round still matches and
// the refusal above cannot happen.
func TestRoundStrictlyIncreasesOnHandoff(t *testing.T) {
a, b := twoReplicas(t)
ctx := context.Background()
first, err := a.leases.Acquire(ctx, "acme")
if err != nil {
t.Fatal(err)
}
// A renewal by the SAME holder keeps the round: the owner re-states the same
// object on every pass, and bumping per write would make its own last write
// look stale.
again, err := a.leases.Acquire(ctx, "acme")
if err != nil {
t.Fatal(err)
}
if again.Round != first.Round {
t.Fatalf("a stable owner's round moved: %d -> %d", first.Round, again.Round)
}
api := a.client.CoordinationV1().Leases("hanzo")
l, _ := api.Get(ctx, a.leaseName, metav1.GetOptions{})
stale := metav1.NewMicroTime(time.Now().Add(-2 * time.Minute))
l.Spec.RenewTime = &stale
if _, err := api.Update(ctx, l, metav1.UpdateOptions{}); err != nil {
t.Fatal(err)
}
taken, err := b.leases.Acquire(ctx, "acme")
if err != nil {
t.Fatal(err)
}
if taken.Round <= first.Round {
t.Fatalf("handoff did not advance the round: %d -> %d", first.Round, taken.Round)
}
}
// A rescheduled pod must start with the estate already known. This is the
// re-issuance storm: ~70 names against a 50/week ceiling, triggered by a restart.
func TestFreshReplicaInheritsStateAndOrdersNothing(t *testing.T) {
a, _ := twoReplicas(t)
ctx := context.Background()
if !a.IsWriter(ctx) {
t.Fatal("expected a to own")
}
if err := a.SaveCertificates("letsencrypt", certs("one.hanzo.ai", "two.hanzo.ai", "three.hanzo.ai")); err != nil {
t.Fatal(err)
}
if err := a.SaveAccount("letsencrypt", &Account{Email: "[email protected]"}); err != nil {
t.Fatal(err)
}
// A brand-new pod: same cluster, no local state of any kind.
fresh, err := newSharedStore(a.client, SharedStoreConfig{
Namespace: "hanzo", Self: "ingress-2", LeaseDuration: time.Minute,
})
if err != nil {
t.Fatal(err)
}
got, err := fresh.GetCertificates("letsencrypt")
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("a fresh replica saw %d certificates, not 3 — it would re-order the estate", len(got))
}
// The ACCOUNT matters as much: without it a takeover has to register anew and
// re-order everything it already holds.
acct, err := fresh.GetAccount("letsencrypt")
if err != nil {
t.Fatal(err)
}
if acct == nil || acct.Email != "[email protected]" {
t.Fatalf("a fresh replica did not inherit the ACME account: %+v", acct)
}
}
// An interleaved read-modify-write must not lose data. The precondition is what
// makes that true, so a conflict has to be retried rather than swallowed.
func TestConcurrentWriteConflictIsRetriedNotLost(t *testing.T) {
a, _ := twoReplicas(t)
ctx := context.Background()
if !a.IsWriter(ctx) {
t.Fatal("expected a to own")
}
if err := a.SaveCertificates("letsencrypt", certs("first.hanzo.ai")); err != nil {
t.Fatal(err)
}
// One injected conflict, as if another writer slipped in between the read and
// the update, then success.
client := a.client.(*fake.Clientset)
var once bool
client.PrependReactor("update", "secrets", func(k8stesting.Action) (bool, runtime.Object, error) {
if !once {
once = true
return true, nil, kerrors.NewConflict(
schema.GroupResource{Resource: "secrets"}, a.name, errors.New("modified"))
}
return false, nil, nil
})
if err := a.SaveCertificates("letsencrypt", certs("first.hanzo.ai", "second.hanzo.ai")); err != nil {
t.Fatalf("a benign conflict must be retried, not surfaced: %v", err)
}
if !once {
t.Fatal("the conflict reactor never fired — the test proved nothing")
}
got, _ := a.GetCertificates("letsencrypt")
if len(got) != 2 {
t.Fatalf("the retry lost data: %d certificates, want 2", len(got))
}
}
// A store that cannot reach the cluster must FAIL, never fall back to per-node
// state — the fallback would reappear exactly when replicas are most likely to
// disagree.
func TestMissingIdentityFailsRatherThanGuessing(t *testing.T) {
if _, err := newSharedStore(fake.NewSimpleClientset(), SharedStoreConfig{Self: "x"}); err == nil {
t.Fatal("a store with no namespace was accepted")
}
}
// A reader must order NOTHING. This is the rate-limit half of the fix: two
// replicas each ordering every name is what turns a reschedule into a week
// without new certificates, and it is also what makes two DNS-01 challenges race
// to write and clean up the same TXT record.
func TestReaderOrdersNothing(t *testing.T) {
a, b := twoReplicas(t)
ctx := context.Background()
if !a.IsWriter(ctx) {
t.Fatal("expected a to own the role")
}
if b.IsWriter(ctx) {
t.Fatal("expected b to be a reader")
}
// A Provider with NOTHING configured — no tlsManager, no certificates. The
// reader must return before it reads any of that, which is what makes this
// meaningful: the gate is the first thing in the function, so a reader spends
// none of the expensive half.
reader := &Provider{Store: b}
if got := reader.getUncheckedDomains(ctx, []string{"new.hanzo.ai"}, "default"); len(got) != 0 {
t.Fatalf("a reader returned %v to order — it must return nothing", got)
}
// The writer is deliberately NOT exercised through the same call here: past the
// gate it dereferences a tlsManager this bare Provider does not have, so it
// would panic on nil for a reason that has nothing to do with the gate. What
// matters, and is asserted above, is that the predicate the gate consults
// answers differently for the two — and the reader's early return proves the
// gate is reached before any of that state.
if !a.IsWriter(ctx) || b.IsWriter(ctx) {
t.Fatal("the gate predicate does not distinguish writer from reader")
}
}
// LocalStore must NOT be gated. A single-replica deployment has no election to
// consult, and asking one would invent a failure mode where none existed — so the
// optional interface is the mechanism, and this pins it.
func TestLocalStoreIsNotGated(t *testing.T) {
// A typed nil is enough: the assertion is about the TYPE's method set, so this
// needs no constructor and no filesystem.
var s Store = (*LocalStore)(nil)
if _, ok := s.(writerGate); ok {
t.Fatal("LocalStore implements writerGate — a single replica would now need an election to order anything")
}
}