mirror of
https://github.com/hanzoai/ingress.git
synced 2026-10-26 02:47:15 +00:00
An adopted plaintext keeps the same digest as the pre-seal snapshot, so while the process kept adopting, a rollback that replanted that snapshot would be re-admitted — the same bytes adoption had just taken. Seal.Persisted, called by both stores once a sealed write reaches disk or the Secret, ends adoption: the store is sealed now, so a later unsealed store is a different store and is refused. Reads of the sealed store are unaffected. It fires on the write that LANDS, not the one attempted — a conflicted SharedStore write does not reach it, so the retry re-read of a still-unsealed object is unchanged. The signal is a method on the Seal contract rather than a runtime type check: the store tells the seal a sealed write persisted, and what a seal does with that is the seal's own concern; the identity seal does nothing.
565 lines
21 KiB
Go
565 lines
21 KiB
Go
// 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"
|
|
"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)
|
|
seal Seal // how the state document reaches the Secret
|
|
|
|
leaseDuration time.Duration
|
|
pollInterval time.Duration
|
|
count counter
|
|
|
|
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
|
|
|
|
// Seal is how the state document reaches the Secret. A Kubernetes Secret
|
|
// is base64, not encryption: whoever can read the object reads the ACME
|
|
// account key and every leaf private key in it. Zero value is Plain(),
|
|
// which is that.
|
|
Seal Seal
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if cfg.Seal == nil {
|
|
cfg.Seal = Plain()
|
|
}
|
|
s := &SharedStore{
|
|
client: client,
|
|
namespace: cfg.Namespace,
|
|
name: cfg.SecretName,
|
|
leaseName: cfg.LeaseName,
|
|
self: cfg.Self,
|
|
leaseDuration: cfg.LeaseDuration,
|
|
pollInterval: cfg.PollInterval,
|
|
seal: cfg.Seal,
|
|
cache: map[string]*StoredData{},
|
|
}
|
|
s.leases = &kubeLeases{store: s}
|
|
return s, nil
|
|
}
|
|
|
|
// id names the object this store keeps its state in. It is what the seal binds
|
|
// the ciphertext to, so a document written for this Secret opens for this
|
|
// Secret and for nothing else — not for another namespace's, and not for a
|
|
// file on a node.
|
|
func (s *SharedStore) id() string { return s.namespace + "/" + s.name }
|
|
|
|
// 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, count, sealed, err := decodeStored(s.seal, s.id(), sec.Data[storeKey])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.count.read(count); err != nil {
|
|
return err
|
|
}
|
|
if !sealed {
|
|
// A reader cannot rewrite an adopted store — only the elected writer
|
|
// may — so it says so and the writer's next mutation seals it.
|
|
log.Warn().Str("secret", s.name).
|
|
Msg("ACME state adopted unsealed; the writer will seal it on its next write")
|
|
}
|
|
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, count, _, err := decodeStored(s.seal, s.id(), sec.Data[storeKey])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.count.read(count); err != nil {
|
|
return err
|
|
}
|
|
d := state[resolverName]
|
|
if d == nil {
|
|
d = &StoredData{}
|
|
state[resolverName] = d
|
|
}
|
|
apply(d)
|
|
|
|
want := s.count.next()
|
|
blob, err := encodeStored(s.seal, s.id(), want, state)
|
|
if err != nil {
|
|
return 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.count.wrote(want)
|
|
s.seal.Persisted()
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
)
|