feat: activator, releases/channels, fleet ops (ported from Eleven + titogo)
Activator: hibernation state machine with exec backend, sidecar co-spawn, due-time wakes — Eleven's 24 tests ported wholesale. Releases: content-addressed manifests, signed CAS channel pointers, bake windows (rollback never bakes), adopt pass with symlink-only for hibernating hosts. Ops: fleet litestream config gen (skips hibernating routes) + restore-verify.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
16 files changed,
+6114
−0
internal/activator/activator.go+1064 −0internal/activator/activator_test.go+1841 −0internal/activator/backend_exec.go+435 −0internal/ops/litestream.go+210 −0internal/ops/ops.go+139 −0internal/ops/ops_test.go+144 −0internal/release/adopt.go+217 −0internal/release/adopt_test.go+193 −0internal/release/bake.go+179 −0internal/release/bake_test.go+171 −0internal/release/release.go+477 −0internal/release/release_test.go+279 −0internal/release/store.go+280 −0internal/release/store_test.go+137 −0internal/release/versions.go+236 −0internal/release/versions_test.go+112 −0
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| new file mode 100644 |
| index 000000000..17615c317 |
| --- /dev/null |
| +++ b/internal/activator/activator.go |
| @@ -0,0 +1,1064 @@ |
| +// Package activator is the hibernation state machine: instances hibernate. |
| +// When the agent is given an S3 bucket (CARLOS_HIBERNATE_BUCKET) it stops |
| +// relying on systemd to keep instance processes alive and instead runs them |
| +// as its own children, on demand — "units are for the edge, instances are its |
| +// children". A request for a host with no live process claims the per-host S3 |
| +// single-writer lease, restores the DB from S3 if the local copy is missing, |
| +// spawns the instance, and proxies; after an idle window the instance is |
| +// checkpointed back to S3, the lease released, and the process stopped. State |
| +// parks in S3; processes exist only while someone's talking. |
| +// |
| +// Ported from lchat activator.go (Eleven's activator, issue #550 Part 4 v2), |
| +// with the mesh (multi-box placement: gossip/HRW, peer proxying) dropped for |
| +// single-box v1 — `// mesh seam:` comments mark every point where it slots |
| +// back in. Membership of one: hash picks self, leases are uncontested except |
| +// against a previous incarnation of ourselves. |
| +// |
| +// Sidecar co-spawn: a host with a sidecar staged (/data/<host>-sidecar.env) |
| +// gets its `sidecar run` child spawned right after the instance turns |
| +// healthy, and SIGTERMed first at sleep — the sidecar exists exactly while |
| +// its instance is awake, so its lifecycle solves itself. Its own connections |
| +// flow back through the edge (the box pins its hosted names to loopback), so |
| +// the sidecar marks them (SidecarHeader) and the activator excludes them from |
| +// the idle clock — otherwise the pair could never sleep, and an orphaned |
| +// sidecar's reconnect loop would wake the instance right back (see ServeHTTP). |
| +// |
| +// Opt-in is PER HOST (routes.hibernate): with the bucket env set, only |
| +// flagged instances are activator-managed; the rest stay on their systemd |
| +// units and are proxied straight. That's what makes a production canary safe — |
| +// the env ships fleet-wide as a no-op, then flipping one host's flag migrates |
| +// exactly one instance: its unit stops, the flag flips, and the first request |
| +// wakes it here. |
| +// |
| +// The due-time index: scheduled server-side work fires on time even while its |
| +// instance sleeps. At sleep the stop path asks the instance for its earliest |
| +// pending delivery (GET /api/next-due over the socket, admin-gated) and |
| +// records it in the registry (routes.next_due — persisted so it survives |
| +// agent restarts); the sweeper wakes any sleeping host whose time has |
| +// arrived, with no client request. Once up, the instance's own scheduler |
| +// delivers, and the normal idle window puts it back to sleep — which |
| +// re-records the NEXT due time, so the cycle maintains itself. Worst-case |
| +// lateness ≈ one activator sweep interval (≤30s) + spawn + one scheduler tick. |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "os" |
| + "path" |
| + "strconv" |
| + "strings" |
| + "sync" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// SidecarHeader marks every request a co-spawned sidecar makes to its own |
| +// instance through the edge. Marked traffic is activity-invisible: served |
| +// while the instance is awake, refused (503) while it isn't, never a wake. |
| +// The marker is deliberately spoofable-but-harmless: it only DOWNGRADES the |
| +// sender's own requests (no activity credit; 503 while the instance sleeps). |
| +// It cannot suppress other clients' activity stamps and cannot wake anything, |
| +// so an outsider sending it gains nothing. |
| +const SidecarHeader = "X-Carlos-Sidecar" |
| + |
| +// Config is the agent-process opt-in, read from env. Nil (bucket unset) keeps |
| +// proxy-only behavior byte-identical: instances run under systemd, the edge |
| +// only proxies. |
| +type Config struct { |
| + Bucket string // CARLOS_HIBERNATE_BUCKET (required to enable) |
| + Region string // CARLOS_HIBERNATE_REGION (optional; AWS default chain resolves "") |
| + Prefix string // CARLOS_HIBERNATE_PREFIX (optional key prefix inside the bucket) |
| + Idle time.Duration // CARLOS_HIBERNATE_IDLE (default 15m; seconds-granular, e.g. "90s") |
| +} |
| + |
| +// ConfigFromEnv reads the CARLOS_HIBERNATE_* env vars. A nil, nil return |
| +// means the bucket is unset: hibernation disabled, don't construct a Service. |
| +func ConfigFromEnv() (*Config, error) { |
| + bucket := strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_BUCKET")) |
| + if bucket == "" { |
| + return nil, nil |
| + } |
| + cfg := &Config{ |
| + Bucket: bucket, |
| + Region: strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_REGION")), |
| + Prefix: strings.Trim(strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_PREFIX")), "/"), |
| + Idle: 15 * time.Minute, |
| + } |
| + if v := strings.TrimSpace(os.Getenv("CARLOS_HIBERNATE_IDLE")); v != "" { |
| + d, err := time.ParseDuration(v) |
| + if err != nil || d <= 0 { |
| + return nil, fmt.Errorf("CARLOS_HIBERNATE_IDLE %q: want a positive duration like 15m or 90s", v) |
| + } |
| + cfg.Idle = d |
| + } |
| + return cfg, nil |
| +} |
| + |
| +// hibernatePrefix is the per-instance S3 key prefix: <Prefix>/<host>/ (the |
| +// lease's lock.json is colocated under it by the lease package). |
| +func (c Config) hibernatePrefix(host string) string { |
| + return path.Join(c.Prefix, host) |
| +} |
| + |
| +// mesh seam: Eleven also derived meshPrefix() here — <Prefix>/mesh/members, |
| +// the membership heartbeats' home beside the per-instance prefixes (the |
| +// bucket is the mesh's only shared substrate; instance prefixes are public |
| +// FQDNs and the bare label "mesh" never is one, so they can't collide). |
| + |
| +// --- backend seam ------------------------------------------------------------ |
| +// |
| +// The state machine's side effects (S3 lease, restore, process spawn) sit |
| +// behind these interfaces so the machine itself is unit-testable with fakes — |
| +// wake coalescing, idle→sleep, and lease-lost stand-down need no AWS. |
| + |
| +// Backend is the side-effect seam (Eleven's actBackend). ExecBackend is the |
| +// production implementation. |
| +type Backend interface { |
| + // Acquire claims the per-host single-writer lease. Contention surfaces as |
| + // errLeaseHeld so the edge can answer 503 + Retry-After. |
| + Acquire(ctx context.Context, host string) (Lease, error) |
| + // Restore materializes the local DB from S3 when it's absent (a no-op when |
| + // the warm-cache copy is still on disk). |
| + Restore(ctx context.Context, host, dbPath string) error |
| + // Start spawns the instance process and waits until its socket accepts. It |
| + // also returns the build it execed (the live/<host> symlink target read at |
| + // spawn), so the sweep can later spot a repoint under a still-running child |
| + // and converge it. Empty when unknown — the sweep treats that as "don't |
| + // cycle" (fail-safe). |
| + Start(ctx context.Context, rt registry.Route) (Proc, string, error) |
| + // NextDue asks a running instance for its earliest pending scheduled |
| + // delivery (unix seconds; 0 = nothing scheduled). Queried at sleep time, |
| + // while the socket still answers, to feed the due-time index. |
| + NextDue(ctx context.Context, rt registry.Route) (int64, error) |
| + // HasSidecar reports whether host has a sidecar staged on this box. |
| + HasSidecar(host string) bool |
| + // StartSidecar spawns the host's sidecar. Fire-and-forget: a sidecar is a |
| + // pure outbound client with no socket, so there's nothing to health-check — |
| + // its own connect/backoff loop recovers from a slow start. |
| + StartSidecar(ctx context.Context, rt registry.Route) (Proc, error) |
| +} |
| + |
| +// DueStore persists the due-time index (Eleven's actDueStore) — the registry |
| +// in production (routes.next_due), so a sleeping host's timer survives agent |
| +// restarts instead of dying with the process. *registry.Registry satisfies it |
| +// directly. |
| +type DueStore interface { |
| + SetNextDue(host string, due int64) error |
| + DueInstances(now int64) ([]registry.Route, error) |
| +} |
| + |
| +// Lease is a held-but-not-yet-replicating lease (wake in progress; Eleven's |
| +// actLease). |
| +type Lease interface { |
| + // Replicate trades the lease for running replication of the (now present) |
| + // local DB; the lease handle is spent on success. syncInterval is the |
| + // route's replication-class tick (replicationSyncInterval; 0 = stream). |
| + Replicate(ctx context.Context, dbPath string, syncInterval time.Duration) (Rep, error) |
| + // Release gives the lease up — the bail-out when a wake fails partway. |
| + Release(ctx context.Context) error |
| +} |
| + |
| +// Rep is running replication coupled to the lease (Eleven's actRep). |
| +type Rep interface { |
| + // Err reports lost leadership (a lease renewal failed). |
| + Err() error |
| + // Close does the final checkpoint/sync and releases the lease. |
| + Close(ctx context.Context) error |
| +} |
| + |
| +// Proc is a running instance child process (Eleven's actProc). |
| +type Proc interface { |
| + // Stop terminates gracefully (SIGTERM, bounded wait, then SIGKILL). |
| + Stop(ctx context.Context) error |
| + // Exited reports whether the process has died on its own. |
| + Exited() bool |
| +} |
| + |
| +// Replication classes — the registry's routes.replication vocabulary |
| +// ("" | stream | batched | daily), mapped to litestream sync intervals by |
| +// replicationSyncInterval. |
| +const ( |
| + replicationStream = "stream" // change-driven ~1s sync — loss window seconds |
| + replicationBatched = "batched" // sync at most every 5m — loss window minutes |
| + replicationDaily = "daily" // sync at wake + every 24h — loss window up to a day |
| +) |
| + |
| +// replicationSyncInterval maps a class to the litestream replica sync |
| +// interval it buys. 0 means "keep litestream's default" (1s, change-driven — |
| +// idle ticks make zero S3 calls), which is what stream and the unset "" |
| +// deliberately share so pre-ladder registries keep today's behavior exactly. |
| +func replicationSyncInterval(class string) time.Duration { |
| + switch class { |
| + case replicationBatched: |
| + return 5 * time.Minute |
| + case replicationDaily: |
| + return 24 * time.Hour |
| + } |
| + return 0 |
| +} |
| + |
| +// --- state machine ----------------------------------------------------------- |
| + |
| +// Per-host lifecycle. An absent entry is the fourth state: asleep. |
| +// |
| +// asleep --request--------------------> waking (one wake; concurrent |
| +// waking --wake ok--------------------> awake requests coalesce on it) |
| +// waking --wake failed----------------> asleep (error served; next request retries) |
| +// awake --idle timeout / lease lost / |
| +// child died----------------> stopping (final checkpoint, release lease) |
| +// stopping --stop done----------------> asleep (local DB left as a warm cache) |
| +// |
| +// A request that lands on waking waits (bounded) for the wake; one that lands |
| +// on stopping waits for the stop, then re-enters as a fresh wake. |
| +type actState int |
| + |
| +const ( |
| + actWaking actState = iota |
| + actAwake |
| + actStopping |
| +) |
| + |
| +type actEntry struct { |
| + host string |
| + route registry.Route |
| + state actState |
| + |
| + ready chan struct{} // waking: closed when the wake resolves; then check err |
| + err error // waking: the wake's outcome |
| + stopped chan struct{} // stopping: closed when the stop completes |
| + |
| + // Activity tracking: an instance is idle only when nothing is in flight |
| + // AND the idle window has passed since the last request finished. In-flight |
| + // counting (not just timestamps) is what keeps an instance with one open |
| + // WebSocket awake — the proxy's ServeHTTP blocks for the tunnel's life. |
| + // (Sidecar-marked requests register in neither — see BeginSidecarRequest.) |
| + inflight int |
| + lastActive time.Time |
| + |
| + rep Rep |
| + proc Proc |
| + |
| + // Convergence: the `live/<host>` symlink target this child was spawned |
| + // against. A running process never re-reads the symlink, so once a ship |
| + // repoints it, an awake child keeps serving the old build until it's |
| + // cycled. spawnVersion lets the sweep notice the drift; staleSince is when |
| + // it first did, so a brief transient (a symlink moved then rolled straight |
| + // back) doesn't trigger a needless cycle. Both empty/zero until the wake |
| + // resolves. |
| + spawnVersion string |
| + staleSince time.Time |
| + |
| + // Sidecar co-spawn: the sidecar exists exactly while its instance is |
| + // awake. All sidecar fields are written under a.mu in the awake state only |
| + // (wake's attach, the sweeper's restart); a stop flips the state under the |
| + // same lock first, so stop() owns them uncontended from then on. |
| + sidecar Proc // the co-spawned `sidecar run` child; nil = none running |
| + sidecarWanted bool // the host had a sidecar staged at wake time |
| + sidecarStarted time.Time // when the current sidecar spawned (backoff reset) |
| + sidecarBackoff time.Duration // last restart delay (doubles, capped) |
| + sidecarRetryAt time.Time // earliest next restart attempt |
| +} |
| + |
| +// Sidecar restart pacing — Restart=always semantics without a supervisor: the |
| +// sweeper redoes a dead sidecar with exponential backoff, capped, and a run |
| +// that survived sidecarHealthyRun earns a fresh backoff (so one crash a day |
| +// never escalates to the cap). |
| +const ( |
| + sidecarRestartBase = 2 * time.Second |
| + sidecarRestartMax = time.Minute |
| + sidecarHealthyRun = time.Minute |
| +) |
| + |
| +// sidecarStopTimeout bounds the sidecar's stop at sleep time. `sidecar run` |
| +// has no drain handler, so the SIGTERM is effectively immediate; the SIGKILL |
| +// fallback behind this timeout is insurance. |
| +const sidecarStopTimeout = 5 * time.Second |
| + |
| +// Build convergence: how long an awake child may serve a symlink-stale binary |
| +// before the sweep gracefully cycles it, and how many to cycle per tick. |
| +// - ConvergeGrace comfortably exceeds one sweep interval (≤30s) and a |
| +// sidecar's ~32s reconnect, so a genuinely-persistent drift is required — |
| +// a symlink that moved then rolled straight back never trips it, and a |
| +// briefly-busy host gets a chance to idle-hibernate (which converges for |
| +// free) first. |
| +// - one per tick staggers a fleet-wide ship across sweeps instead of blipping |
| +// every host's connections at once. |
| +// |
| +// Convergence is a latency/observability fix, not urgent (the supply-chain bake |
| +// window already holds releases), so a leisurely, gentle cadence is right. |
| +const ( |
| + ConvergeGrace = 90 * time.Second |
| + maxConvergeCyclesPerSweep = 1 |
| +) |
| + |
| +// ConvergeDecision is the pure state machine behind build convergence: given |
| +// the symlink target read this tick (cur/readErr), the build the child execed |
| +// (spawnVer), and when the drift was first seen (staleSince), it says whether |
| +// to cycle now and returns the staleSince to carry forward. Split out so the |
| +// grace/reset logic is unit-testable without a live activator. Three cases: |
| +// - read error: unknown — neither cycle nor clear the grace; retry next tick. |
| +// - converged / rolled-back / nothing to compare (cur=="" or spawnVer=="" or |
| +// cur==spawnVer): clear the grace clock, don't cycle. |
| +// - stale (symlink moved since spawn): start the grace clock on first sight, |
| +// cycle once the drift has persisted past grace. |
| +func ConvergeDecision(cur string, readErr error, spawnVer string, staleSince, now time.Time, grace time.Duration) (cycle bool, nextStale time.Time) { |
| + switch { |
| + case readErr != nil: |
| + return false, staleSince |
| + case cur == "" || spawnVer == "" || cur == spawnVer: |
| + return false, time.Time{} |
| + default: |
| + if staleSince.IsZero() { |
| + staleSince = now |
| + } |
| + return now.Sub(staleSince) >= grace, staleSince |
| + } |
| +} |
| + |
| +// wakeWaitBound caps how long a request queues on someone else's wake or stop |
| +// before giving up with 503 — a wedged wake must not pile up connections |
| +// forever. A var so tests can shrink it. |
| +var wakeWaitBound = 30 * time.Second |
| + |
| +// errLeaseHeld: the per-host lease is held elsewhere. On one box this means a |
| +// previous incarnation hasn't released (crashed agent; lease expires within |
| +// ~lease.LeaseTTL) — the edge answers 503 with Retry-After. |
| +type errLeaseHeld struct { |
| + holder string |
| + retryAfter time.Duration |
| +} |
| + |
| +func (e *errLeaseHeld) Error() string { |
| + return fmt.Sprintf("instance lease held by %q (retry in %s)", e.holder, e.retryAfter) |
| +} |
| + |
| +var errWakeTimeout = errors.New("timed out waiting for instance wake") |
| + |
| +// Service is the activator (Eleven's activatorSvc). It structurally satisfies |
| +// the edge package's Activator interface — internal/edge is deliberately not |
| +// imported: the dependency points edge→activator only. |
| +type Service struct { |
| + cfg Config |
| + backend Backend |
| + due DueStore |
| + now func() time.Time // a seam for the unit tests' clock |
| + liveVer func(string) (string, error) // live/<host> symlink target; a seam for tests |
| + |
| + // mesh seam: Eleven kept the multi-box placement layer here (mesh *meshSvc): |
| + // requests for hosts whose HRW owner is another live box were proxied |
| + // there, and the due-time sweeper only woke hosts this box owned. Nil |
| + // meant a single box — hashing would pick self everywhere, so no mesh |
| + // codepath was touched at all. Single-box v1 drops the field; it re-enters |
| + // with the mesh port. |
| + |
| + mu sync.Mutex |
| + entries map[string]*actEntry |
| + |
| + sweepStop chan struct{} |
| + sweepDone chan struct{} |
| +} |
| + |
| +// New wires the state machine to its side-effect backend and due-time store. |
| +// In production due is the *registry.Registry itself. |
| +func New(cfg Config, backend Backend, due DueStore) *Service { |
| + a := &Service{ |
| + cfg: cfg, |
| + backend: backend, |
| + due: due, |
| + now: time.Now, |
| + entries: make(map[string]*actEntry), |
| + } |
| + // The convergence sweep's live/<host> reader. The exec backend knows the |
| + // symlink layout (OptDir is its field, not a package global — Eleven used a |
| + // global here); any other backend — the unit tests' fakes — leaves |
| + // convergence off ("" = unknown → never cycle) unless the test wires |
| + // a.liveVer itself. |
| + if lv, ok := backend.(interface { |
| + LiveVersion(host string) (string, error) |
| + }); ok { |
| + a.liveVer = lv.LiveVersion |
| + } else { |
| + a.liveVer = func(string) (string, error) { return "", nil } |
| + } |
| + return a |
| +} |
| + |
| +// beginRequest ensures the host's instance is awake and registers one in-flight |
| +// request against it. The returned release must be called when the request |
| +// finishes (it stamps last-activity, which starts the idle clock). |
| +func (a *Service) beginRequest(ctx context.Context, rt registry.Route) (release func(), err error) { |
| + deadline := time.NewTimer(wakeWaitBound) |
| + defer deadline.Stop() |
| + for { |
| + a.mu.Lock() |
| + e := a.entries[rt.Host] |
| + if e == nil { |
| + // Asleep → kick off ONE wake; this request and any latecomers all |
| + // coalesce on e.ready rather than racing a second lease claim |
| + // (singleflight). The wake runs detached so even its initiator is |
| + // held to the same bounded wait as everyone queued behind it. |
| + e = &actEntry{host: rt.Host, route: rt, state: actWaking, ready: make(chan struct{})} |
| + a.entries[rt.Host] = e |
| + a.mu.Unlock() |
| + go a.wake(e) |
| + continue |
| + } |
| + |
| + switch e.state { |
| + case actAwake: |
| + // Stand down synchronously-detected failure: a dead child or a |
| + // lost lease found at request time turns into a stop, and the |
| + // request waits for it then re-wakes — recovery is the routine |
| + // wake path. |
| + if repErr := e.rep.Err(); repErr != nil || e.proc.Exited() { |
| + if repErr != nil { |
| + log.Printf("activator: LEASE LOST for %s (%v) — standing down", e.host, repErr) |
| + } else { |
| + log.Printf("activator: instance %s exited unexpectedly — recycling", e.host) |
| + } |
| + a.beginStopLocked(e) |
| + a.mu.Unlock() |
| + continue |
| + } |
| + e.inflight++ |
| + a.mu.Unlock() |
| + return a.releaseFunc(e), nil |
| + |
| + case actWaking: |
| + ready := e.ready |
| + a.mu.Unlock() |
| + select { |
| + case <-ready: |
| + // A failed wake reports to every coalesced waiter (retrying |
| + // here would turn one bad wake into a storm); a successful |
| + // one loops to register against the now-awake entry. |
| + if e.err != nil { |
| + return nil, e.err |
| + } |
| + continue |
| + case <-ctx.Done(): |
| + return nil, ctx.Err() |
| + case <-deadline.C: |
| + return nil, errWakeTimeout |
| + } |
| + |
| + case actStopping: |
| + stopped := e.stopped |
| + a.mu.Unlock() |
| + select { |
| + case <-stopped: |
| + continue // entry is gone; loop wakes it fresh |
| + case <-ctx.Done(): |
| + return nil, ctx.Err() |
| + case <-deadline.C: |
| + return nil, errWakeTimeout |
| + } |
| + } |
| + } |
| +} |
| + |
| +// errSidecarAsleep: a sidecar-marked request arrived for a host with no awake |
| +// instance. Sidecar traffic never wakes anything — the activator itself spawns |
| +// the sidecar as part of a wake, so a marked request for a sleeping host is |
| +// either a stray reconnect racing a stop or an outsider spoofing the marker; |
| +// both get a 503 and change nothing. |
| +var errSidecarAsleep = errors.New("instance asleep; sidecar requests do not wake it") |
| + |
| +// BeginSidecarRequest is beginRequest for sidecar-marked traffic: pass it |
| +// through to an awake instance WITHOUT registering activity — no in-flight |
| +// count, no lastActive stamp (the sidecar's own long-lived WebSocket must not |
| +// hold its instance awake; that non-count is the whole reason the pair can |
| +// sleep) — and refuse, rather than wake, when the instance is asleep or |
| +// stopping. |
| +func (a *Service) BeginSidecarRequest(ctx context.Context, rt registry.Route) error { |
| + deadline := time.NewTimer(wakeWaitBound) |
| + defer deadline.Stop() |
| + for { |
| + a.mu.Lock() |
| + e := a.entries[rt.Host] |
| + if e == nil { |
| + a.mu.Unlock() |
| + return errSidecarAsleep |
| + } |
| + switch e.state { |
| + case actAwake: |
| + // Same dead-child / lost-lease detection as a member request, but |
| + // the recovery stops at the stop: the loop then finds a stopping |
| + // (or absent) entry and turns this request away instead of |
| + // chaining into a re-wake. |
| + if repErr := e.rep.Err(); repErr != nil || e.proc.Exited() { |
| + if repErr != nil { |
| + log.Printf("activator: LEASE LOST for %s (%v) — standing down", e.host, repErr) |
| + } else { |
| + log.Printf("activator: instance %s exited unexpectedly — recycling", e.host) |
| + } |
| + a.beginStopLocked(e) |
| + a.mu.Unlock() |
| + continue |
| + } |
| + a.mu.Unlock() |
| + return nil |
| + |
| + case actWaking: |
| + // Joining a wake someone else started is fine — the co-spawned |
| + // sidecar boots moments before its entry flips awake, so its first |
| + // dial can land in that sliver; joining can't initiate anything. |
| + // A failed wake just reads as asleep (the member path already |
| + // logged it). |
| + ready := e.ready |
| + a.mu.Unlock() |
| + select { |
| + case <-ready: |
| + if e.err != nil { |
| + return errSidecarAsleep |
| + } |
| + continue |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + case <-deadline.C: |
| + return errWakeTimeout |
| + } |
| + |
| + default: // actStopping — the stop exists to shed exactly this connection |
| + a.mu.Unlock() |
| + return errSidecarAsleep |
| + } |
| + } |
| +} |
| + |
| +// mesh seam: Eleven exposed hasLiveEntry(host) here — the mesh's "already |
| +// living here" check (waking and stopping both counted: a waking host is |
| +// committed to this box, and a stopping one still holds the lease, so |
| +// proxying it away would only trade a short wait for a loud lease race) — |
| +// and awakeCount() for the fleet status page. |
| + |
| +func (a *Service) releaseFunc(e *actEntry) func() { |
| + var once sync.Once |
| + return func() { |
| + once.Do(func() { |
| + a.mu.Lock() |
| + e.inflight-- |
| + e.lastActive = a.now() |
| + a.mu.Unlock() |
| + }) |
| + } |
| +} |
| + |
| +// wake drives asleep→awake: lease, restore-if-missing, spawn, replicate, |
| +// co-spawn the sidecar. It runs on the first requester's goroutine but under |
| +// its own generous timeout, detached from that request's context — queued |
| +// requests still need the wake to finish even if the requester who started it |
| +// hangs up. |
| +func (a *Service) wake(e *actEntry) { |
| + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) |
| + defer cancel() |
| + w, err := a.doWake(ctx, e.route) |
| + |
| + a.mu.Lock() |
| + if err != nil { |
| + e.err = err |
| + delete(a.entries, e.host) // next request retries from scratch |
| + } else { |
| + e.state = actAwake |
| + e.rep, e.proc = w.rep, w.proc |
| + e.sidecar, e.sidecarWanted = w.sidecar, w.sidecarWanted |
| + e.sidecarStarted = a.now() |
| + e.sidecarBackoff = 0 |
| + e.sidecarRetryAt = time.Time{} // a wake-time spawn failure retries on the next sweep |
| + e.lastActive = a.now() |
| + // The build this child actually execed, captured at Start() inside the |
| + // backend (not re-read here — a ship during the wake window would make a |
| + // re-read name the new target while the process runs the old binary, and |
| + // the sweep would never converge it). Empty = unknown → the sweep won't |
| + // cycle. Fresh wake, so it can't be stale yet. |
| + e.spawnVersion = w.spawnVersion |
| + e.staleSince = time.Time{} |
| + } |
| + a.mu.Unlock() |
| + close(e.ready) |
| +} |
| + |
| +// wokenInstance is a successful wake's yield. sidecar is nil when the host has |
| +// none staged — or when the spawn failed, which is deliberately not an error: |
| +// members > bots, so a host whose sidecar won't start still serves (the |
| +// sweeper retries via sidecarWanted). |
| +type wokenInstance struct { |
| + rep Rep |
| + proc Proc |
| + sidecar Proc |
| + sidecarWanted bool |
| + spawnVersion string // live/<host> target read at exec — for build convergence |
| +} |
| + |
| +func (a *Service) doWake(ctx context.Context, rt registry.Route) (*wokenInstance, error) { |
| + host := rt.Host |
| + // The DB path rides the route; when empty the backend defaults it to |
| + // <DataDir>/<host>.db internally (Eleven defaulted here via a package |
| + // global — base dirs are backend fields now). |
| + dbPath := rt.DBPath |
| + |
| + // 1. The lease first: everything after this line is done as the prefix's |
| + // single writer. Contention is not retried here — on one box it means a |
| + // previous incarnation hasn't released; tell the client to come back after |
| + // the TTL instead of stalling its request. |
| + lease, err := a.backend.Acquire(ctx, host) |
| + if err != nil { |
| + var held *errLeaseHeld |
| + if errors.As(err, &held) { |
| + log.Printf("activator: LEASE HELD for %s by %q — a previous incarnation hasn't released; refusing to wake (retry after %s)", |
| + host, held.holder, held.retryAfter) |
| + } |
| + return nil, err |
| + } |
| + |
| + // 2. Restore the DB from S3 when the local copy is gone — the restore must |
| + // happen *under* the lock (restoring without it could later shadow newer |
| + // S3 state written by the true holder). When it's still on disk it is the |
| + // warm cache: on one box, with the lease held, the local copy can only be |
| + // what we ourselves checkpointed at the last sleep. |
| + if err := a.backend.Restore(ctx, host, dbPath); err != nil { |
| + _ = lease.Release(ctx) |
| + return nil, fmt.Errorf("restore %s: %w", host, err) |
| + } |
| + |
| + // 3. Spawn before replication starts: a fresh instance creates its own DB |
| + // on boot, so waiting for the socket guarantees the file exists for |
| + // litestream to open. Writes that land in the gap are picked up by the |
| + // replica's first sync. |
| + proc, spawnVer, err := a.backend.Start(ctx, rt) |
| + if err != nil { |
| + _ = lease.Release(ctx) |
| + return nil, fmt.Errorf("start %s: %w", host, err) |
| + } |
| + |
| + // 4. Trade the held lease for running replication, at the route's |
| + // replication-class sync interval (the per-plan durability ladder). |
| + rep, err := lease.Replicate(ctx, dbPath, replicationSyncInterval(rt.Replication)) |
| + if err != nil { |
| + stopCtx, cancel := context.WithTimeout(context.Background(), actStopTimeout) |
| + defer cancel() |
| + _ = proc.Stop(stopCtx) |
| + _ = lease.Release(ctx) |
| + return nil, fmt.Errorf("replicate %s: %w", host, err) |
| + } |
| + |
| + // 5. Co-spawn the sidecar, last — only once the instance is healthy and |
| + // replicating, so the sidecar always boots against a live instance (its |
| + // marked requests pass through the edge while we're awake). A spawn |
| + // failure is loud but never fails the wake: members come before bots, and |
| + // the sweeper retries with backoff. |
| + w := &wokenInstance{rep: rep, proc: proc, spawnVersion: spawnVer} |
| + if a.backend.HasSidecar(host) { |
| + w.sidecarWanted = true |
| + if sc, err := a.backend.StartSidecar(ctx, rt); err != nil { |
| + log.Printf("activator: SIDECAR for %s failed to start: %v — serving without it (the sweep retries)", host, err) |
| + } else { |
| + w.sidecar = sc |
| + } |
| + } |
| + return w, nil |
| +} |
| + |
| +// actStopTimeout bounds a graceful instance stop (the instance's own SIGTERM |
| +// drain is expected ~10s; leave headroom before the SIGKILL). |
| +const actStopTimeout = 20 * time.Second |
| + |
| +// beginStopLocked flips an awake entry to stopping and kicks the stop off; the |
| +// caller holds a.mu. In-flight requests are not waited for — a stop is only |
| +// initiated when the instance is idle, dead, or has lost its lease, and in the |
| +// latter two cases open requests are already doomed. |
| +func (a *Service) beginStopLocked(e *actEntry) { |
| + e.state = actStopping |
| + e.stopped = make(chan struct{}) |
| + go a.stop(e) |
| +} |
| + |
| +// stop drives awake→asleep: stop the child (its SIGTERM drain is the final |
| +// write), checkpoint what's on disk to S3 and release the lease, then forget |
| +// the entry. The local DB stays in place — it's the warm cache for the next |
| +// wake; S3 is the durable truth under the lease. |
| +func (a *Service) stop(e *actEntry) { |
| + // Co-stop, sidecar FIRST — before the next-due probe and the instance's |
| + // own SIGTERM. Two reasons: the sidecar must never outlive its instance |
| + // (an orphan's reconnect loop would be a standing wake source — the |
| + // ping-pong this feature exists to prevent — and its scheduled work would |
| + // keep firing into a host we're about to stop), and with the sidecar gone |
| + // first, the schedule the next-due probe reads can't shift under it. |
| + if e.sidecar != nil { |
| + scCtx, scCancel := context.WithTimeout(context.Background(), sidecarStopTimeout) |
| + if err := e.sidecar.Stop(scCtx); err != nil { |
| + log.Printf("activator: stop sidecar %s: %v", e.host, err) |
| + } |
| + scCancel() |
| + } |
| + |
| + // The due-time index: ask the instance for its earliest pending scheduled |
| + // delivery BEFORE stopping it — the socket dies with the process. (When the |
| + // stop was triggered by a dead child, the query just fails fast.) Any |
| + // failure records 0, i.e. "sleeps until a request": a missed probe must |
| + // degrade to pre-index behavior, never block or crash the sleep. A due time |
| + // already in the past is recorded as-is and the sweeper wakes the host on |
| + // its next tick — there is deliberately no minimum-sleep guard beyond that, |
| + // because thrash is already impossible: an awake instance only sleeps after |
| + // a full idle window, so wake/sleep cycles can never be spaced closer than |
| + // cfg.Idle. |
| + dueCtx, cancelDue := context.WithTimeout(context.Background(), 5*time.Second) |
| + due, err := a.backend.NextDue(dueCtx, e.route) |
| + cancelDue() |
| + if err != nil { |
| + log.Printf("activator: next-due probe %s: %v — recording none (it sleeps until a request)", e.host, err) |
| + due = 0 |
| + } |
| + if err := a.due.SetNextDue(e.host, due); err != nil { |
| + log.Printf("activator: record next-due %s: %v", e.host, err) |
| + } else if due > 0 { |
| + log.Printf("activator: %s next scheduled delivery at %s — will wake for it", e.host, time.Unix(due, 0).UTC().Format(time.RFC3339)) |
| + } |
| + |
| + // Separate budgets: a SIGTERM drain that runs long must not eat the final |
| + // checkpoint's time — the S3 sync is the part that makes sleep durable. |
| + procCtx, cancelProc := context.WithTimeout(context.Background(), actStopTimeout) |
| + err = e.proc.Stop(procCtx) |
| + cancelProc() |
| + if err != nil { |
| + log.Printf("activator: stop %s: %v", e.host, err) |
| + } |
| + // Close = final sync + lease release. After a lost lease the release part |
| + // fails harmlessly (someone else owns the lock); the sync still preserves |
| + // a local checkpoint. |
| + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) |
| + defer cancel() |
| + if err := e.rep.Close(ctx); err != nil { |
| + log.Printf("activator: final checkpoint %s: %v", e.host, err) |
| + } |
| + a.mu.Lock() |
| + delete(a.entries, e.host) |
| + a.mu.Unlock() |
| + close(e.stopped) |
| + log.Printf("activator: %s hibernated", e.host) |
| +} |
| + |
| +// Start launches the background sweeper that puts idle instances to sleep and |
| +// stands down lost leases/dead children between requests. |
| +func (a *Service) Start() { |
| + a.sweepStop = make(chan struct{}) |
| + a.sweepDone = make(chan struct{}) |
| + // Sweep well inside the idle window so a sleep happens promptly after it |
| + // elapses; clamp so second-granular test idles still sweep sanely. |
| + interval := a.cfg.Idle / 4 |
| + if interval < 100*time.Millisecond { |
| + interval = 100 * time.Millisecond |
| + } |
| + if interval > 30*time.Second { |
| + interval = 30 * time.Second |
| + } |
| + go func() { |
| + defer close(a.sweepDone) |
| + t := time.NewTicker(interval) |
| + defer t.Stop() |
| + for { |
| + select { |
| + case <-a.sweepStop: |
| + return |
| + case <-t.C: |
| + a.sweepOnce() |
| + } |
| + } |
| + }() |
| +} |
| + |
| +// sweepOnce examines every awake instance once: idle ones hibernate; a lost |
| +// lease or dead child stands down immediately regardless of activity. It then |
| +// checks the due-time index and wakes any sleeping host whose scheduled |
| +// delivery has come due. |
| +func (a *Service) sweepOnce() { |
| + now := a.now() |
| + // The due query runs before the lock — it's a registry read, and nothing |
| + // under a.mu should wait on SQLite. |
| + dueRoutes, err := a.due.DueInstances(now.Unix()) |
| + if err != nil { |
| + log.Printf("activator: due-time sweep: %v", err) |
| + } |
| + a.mu.Lock() |
| + cycled := 0 // build-convergence cycles started this tick (rate-limited) |
| + for _, e := range a.entries { |
| + if e.state != actAwake { |
| + continue |
| + } |
| + if err := e.rep.Err(); err != nil { |
| + log.Printf("activator: LEASE LOST for %s (%v) — standing down", e.host, err) |
| + a.beginStopLocked(e) |
| + continue |
| + } |
| + if e.proc.Exited() { |
| + log.Printf("activator: instance %s exited unexpectedly — recycling", e.host) |
| + a.beginStopLocked(e) |
| + continue |
| + } |
| + if e.inflight == 0 && now.Sub(e.lastActive) >= a.cfg.Idle { |
| + log.Printf("activator: %s idle %s — hibernating", e.host, now.Sub(e.lastActive).Truncate(time.Second)) |
| + a.beginStopLocked(e) |
| + continue |
| + } |
| + // Converge onto a shipped build. A running child never re-reads |
| + // live/<host>, so once a ship repoints the symlink this awake child is |
| + // serving a stale binary. Idle hosts converge for free on their next |
| + // wake; this cycle is for the always-awake ones — notably every |
| + // sidecar host, which never sleeps — that otherwise serve the old |
| + // build indefinitely. Gracefully cycle: beginStopLocked drains via |
| + // SIGTERM, takes the final checkpoint, releases the lease, and leaves |
| + // the host asleep; a member request (or the due-time index) then wakes |
| + // it onto the new binary — the same clean cycle `systemctl restart` |
| + // gives a unit-managed host. Unlike idle hibernation this can fire |
| + // with a request in flight; the stop still drains HTTP gracefully and |
| + // reconnecting clients coalesce onto the fresh wake, so a long upload |
| + // past the drain is cut exactly as a restart would cut it. A pinned |
| + // canary never trips this: the updater leaves its symlink alone, so |
| + // spawnVersion still matches. |
| + cur, readErr := a.liveVer(e.host) |
| + cycle, next := ConvergeDecision(cur, readErr, e.spawnVersion, e.staleSince, now, ConvergeGrace) |
| + e.staleSince = next |
| + if cycle && cycled < maxConvergeCyclesPerSweep { |
| + log.Printf("activator: %s serving %s but live symlink is now %s — cycling onto the new build", e.host, e.spawnVersion, cur) |
| + a.beginStopLocked(e) |
| + cycled++ |
| + continue |
| + } |
| + // Sidecar supervision — Restart=always, sweep-driven, no supervisor |
| + // goroutine: a sidecar that died (or failed to spawn at wake) while |
| + // its instance stays awake is respawned with doubling, capped backoff; |
| + // a run past sidecarHealthyRun resets it. Spawned under a.mu |
| + // deliberately: an exec is quick, and the lock is what makes a restart |
| + // impossible to race with a stop (sidecar fields only change in the |
| + // awake state, and every stop flips the state under this same lock |
| + // first). |
| + // |
| + // A sidecar env staged MID-FLIGHT (attached to an awake child — the |
| + // opt-in path) is ADOPTED here rather than waiting for the next wake, |
| + // so opting in takes effect in seconds. Safe against double-running: |
| + // only hibernating hosts are activator children and their systemd |
| + // sidecar units stay disabled — the staged sidecar has exactly one |
| + // possible runner. |
| + if !e.sidecarWanted && a.backend.HasSidecar(e.host) { |
| + e.sidecarWanted = true |
| + log.Printf("activator: sidecar staged for awake %s — adopting", e.host) |
| + } |
| + if e.sidecarWanted && (e.sidecar == nil || e.sidecar.Exited()) && !now.Before(e.sidecarRetryAt) { |
| + if e.sidecar != nil && now.Sub(e.sidecarStarted) >= sidecarHealthyRun { |
| + e.sidecarBackoff = 0 |
| + } |
| + if e.sidecarBackoff == 0 { |
| + e.sidecarBackoff = sidecarRestartBase |
| + } else { |
| + e.sidecarBackoff = min(e.sidecarBackoff*2, sidecarRestartMax) |
| + } |
| + e.sidecarRetryAt = now.Add(e.sidecarBackoff) |
| + sc, err := a.backend.StartSidecar(context.Background(), e.route) |
| + if err != nil { |
| + log.Printf("activator: SIDECAR restart for %s failed: %v (next attempt in %s)", e.host, err, e.sidecarBackoff) |
| + e.sidecar = nil |
| + continue |
| + } |
| + log.Printf("activator: sidecar for %s was down — restarted (backoff now %s)", e.host, e.sidecarBackoff) |
| + e.sidecar = sc |
| + e.sidecarStarted = now |
| + } |
| + } |
| + // Due-time wakes: a request-less wake through the same singleflight state |
| + // machine, so a client request arriving mid-wake just coalesces. Only hosts |
| + // with NO live entry are woken — an awake host is already serving its |
| + // schedule; a waking one is on its way; a stopping one will re-record its |
| + // next_due when the stop completes and be picked up on a later tick. The |
| + // registry's stale past value while the host is up is harmless for the same |
| + // reason. A failed due wake (e.g. a lease still held by a crashed |
| + // incarnation) removes the entry, and the index — only rewritten at the |
| + // next successful sleep — naturally retries it every tick until it works. |
| + for _, rt := range dueRoutes { |
| + // Only hibernating, ready hosts: the index exists to wake sleeping |
| + // instances, and a systemd-managed host is always on — spawning a |
| + // child for it would fight its unit over the socket. This also makes |
| + // a leftover next_due inert on a host flipped back to systemd (the |
| + // flip-off's release re-records one after the flag is already clear). |
| + // The registry's DueInstances is deliberately broader (no hibernate |
| + // filter), so the gate lives here, with the machine it protects. |
| + if !rt.Hibernate || rt.Status != "ready" { |
| + continue |
| + } |
| + if _, live := a.entries[rt.Host]; live { |
| + continue |
| + } |
| + // mesh seam: in the multi-box mesh only the HRW owner due-wakes a host — |
| + // otherwise every box sweeping a due entry would race a wake per tick |
| + // (the lease would let exactly one win, but loudly, forever). Eleven: |
| + // `if a.mesh != nil && !a.mesh.owns(rt.Host) { continue }`. |
| + log.Printf("activator: %s has a scheduled delivery due — waking it", rt.Host) |
| + e := &actEntry{host: rt.Host, route: rt, state: actWaking, ready: make(chan struct{})} |
| + a.entries[rt.Host] = e |
| + go a.wake(e) |
| + } |
| + a.mu.Unlock() |
| +} |
| + |
| +// RetireExcept stops (final checkpoint + lease release) every live entry whose |
| +// host is not in keep — the reload-time reconcile behind flipping a host's |
| +// hibernate flag off (and, as a bonus, route removal while awake). The flip |
| +// clears the route's flag and reloads the edge; the map swap stops routing |
| +// requests through the activator, and this puts the child down and gives the |
| +// S3 lease back so the host's systemd unit can bind the socket. It reuses the |
| +// ordinary stop path — the narrowest hook that exists — so the release still |
| +// records next_due and checkpoints to S3 like any sleep. In-flight requests on |
| +// a retired child are cut off as a deploy restart would cut them — the flip is |
| +// a deliberate operator action. |
| +func (a *Service) RetireExcept(keep map[string]bool) { |
| + a.mu.Lock() |
| + var waking []*actEntry |
| + for _, e := range a.entries { |
| + if keep[e.host] { |
| + continue |
| + } |
| + switch e.state { |
| + case actAwake: |
| + log.Printf("activator: %s is no longer hibernate-managed — releasing", e.host) |
| + a.beginStopLocked(e) |
| + case actWaking: |
| + // A wake can't be stopped mid-flight; finish the retire once it |
| + // resolves (below, off-lock). |
| + waking = append(waking, e) |
| + case actStopping: |
| + // Already on its way out. |
| + } |
| + } |
| + a.mu.Unlock() |
| + for _, e := range waking { |
| + go func(e *actEntry) { |
| + <-e.ready |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + // Only if THIS entry is still the live one and its wake succeeded — |
| + // a failed wake already removed itself, and a fresh entry for the |
| + // same host means the route was re-flagged in the meantime. |
| + if a.entries[e.host] == e && e.state == actAwake { |
| + log.Printf("activator: %s is no longer hibernate-managed — releasing (wake finished first)", e.host) |
| + a.beginStopLocked(e) |
| + } |
| + }(e) |
| + } |
| +} |
| + |
| +// Shutdown checkpoints and stops every child — the agent going down takes its |
| +// instances with it (they're its children; leaving them orphaned would strand |
| +// the leases until TTL expiry and leave the sockets bound against the next |
| +// incarnation's spawns). Blocks until all stops complete or ctx expires. |
| +func (a *Service) Shutdown(ctx context.Context) { |
| + // mesh seam — drain step 1, order-pinned: Eleven left the mesh membership |
| + // FIRST — the heartbeat is deleted before any checkpoint or lease release |
| + // below, so peers stop proxying here immediately and their wakes succeed |
| + // the moment each lease releases. Deploys, box failure drills, and |
| + // scale-down all ride this same path. (`a.mesh.stop(ctx)` went here.) |
| + if a.sweepStop != nil { |
| + close(a.sweepStop) |
| + <-a.sweepDone |
| + } |
| + a.mu.Lock() |
| + var waits []chan struct{} |
| + for _, e := range a.entries { |
| + switch e.state { |
| + case actAwake: |
| + a.beginStopLocked(e) |
| + waits = append(waits, e.stopped) |
| + case actStopping: |
| + waits = append(waits, e.stopped) |
| + case actWaking: |
| + // A mid-flight wake resolves on its own; its instance will be |
| + // orphaned only if it wins the race with process exit — acceptable |
| + // for the rare shutdown-during-wake, and the lease TTL still |
| + // bounds the damage. |
| + } |
| + } |
| + a.mu.Unlock() |
| + for _, w := range waits { |
| + select { |
| + case <-w: |
| + case <-ctx.Done(): |
| + return |
| + } |
| + } |
| +} |
| + |
| +// ServeHTTP answers one proxied request for a hibernating instance: wake (or |
| +// join a wake), proxy, stamp activity. Lease contention and wake timeouts are |
| +// 503s — "sleeping hosts are slow to wake", never a 5xx cascade of retries. |
| +// It reports whether it handled the request (wrote the response or proxied |
| +// it); false means "not activator-managed — proxy straight", so the edge can |
| +// call it unconditionally. |
| +func (a *Service) ServeHTTP(w http.ResponseWriter, r *http.Request, rt registry.Route, proxy http.Handler) bool { |
| + // Per-host gating: only a hibernate-flagged instance is activator-managed; |
| + // unflagged instances and sibling services are the edge's to proxy |
| + // straight — the deploy-safety property that lets the bucket env ship |
| + // fleet-wide as a no-op before any host is flagged. |
| + if rt.Kind != "instance" || !rt.Hibernate { |
| + return false |
| + } |
| + |
| + // Sidecar-marked traffic (SidecarHeader — the sidecar stamps every REST |
| + // call and its WebSocket dial) is activity-invisible: proxied while the |
| + // instance is awake, refused while it isn't, never a wake. The co-spawned |
| + // sidecar dials its instance's public URL through this very edge (the box |
| + // pins its hosted names to loopback), so without this its keepalives |
| + // would read as member activity — the instance could never idle — and |
| + // after a sleep its reconnect loop would be the wake source: ping-pong |
| + // forever. Spoofing the marker is harmless by construction: it only |
| + // DOWNGRADES the sender's own requests (no activity credit; 503 while |
| + // asleep). It cannot suppress other clients' activity stamps and cannot |
| + // wake a sleeping host, so an outsider sending it gains nothing. |
| + if r.Header.Get(SidecarHeader) != "" { |
| + // The marker is an edge-side signal only (activity exclusion, above); |
| + // strip it so nothing downstream can ever grow a meaning an outsider |
| + // could spoof. |
| + r.Header.Del(SidecarHeader) |
| + if err := a.BeginSidecarRequest(r.Context(), rt); err != nil { |
| + if errors.Is(err, context.Canceled) { |
| + return true // the sidecar hung up while queued on a wake |
| + } |
| + w.Header().Set("Retry-After", "5") |
| + http.Error(w, "instance is asleep; sidecar requests do not wake it", http.StatusServiceUnavailable) |
| + return true |
| + } |
| + proxy.ServeHTTP(w, r) |
| + return true |
| + } |
| + |
| + // mesh seam: Eleven proxied a member request for a host with no live |
| + // local entry and an HRW owner on another live box to that peer here (one |
| + // hop max, guarded by a forwarded marker); a host with ANY live local |
| + // entry stayed local — placement converges at the next sleep boundary, |
| + // since its state is already in S3. Single-box v1 always wakes locally. |
| + |
| + release, err := a.beginRequest(r.Context(), rt) |
| + if err != nil { |
| + var held *errLeaseHeld |
| + switch { |
| + case errors.As(err, &held): |
| + retry := int(held.retryAfter/time.Second) + 1 |
| + w.Header().Set("Retry-After", strconv.Itoa(retry)) |
| + http.Error(w, "instance is waking elsewhere; retry shortly", http.StatusServiceUnavailable) |
| + case errors.Is(err, errWakeTimeout): |
| + w.Header().Set("Retry-After", "5") |
| + http.Error(w, "instance is waking; retry shortly", http.StatusServiceUnavailable) |
| + case errors.Is(err, context.Canceled): |
| + // The client hung up while queued; nothing to write. |
| + default: |
| + log.Printf("activator: wake %s: %v", rt.Host, err) |
| + http.Error(w, "instance failed to wake", http.StatusServiceUnavailable) |
| + } |
| + return true |
| + } |
| + defer release() |
| + proxy.ServeHTTP(w, r) |
| + return true |
| +} |
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go| new file mode 100644 |
| index 000000000..c3970f40c |
| --- /dev/null |
| +++ b/internal/activator/activator_test.go |
| @@ -0,0 +1,1841 @@ |
| +package activator |
| + |
| +// Ported from lchat activator_test.go. |
| +// |
| +// Unit tests for the activator state machine (activator.go) with a fake |
| +// backend — wake coalescing, idle→sleep, lease-lost stand-down, contention |
| +// 503s. No AWS, no processes. The router-level tests use a small in-package |
| +// test router that mimics the edge's dispatch (internal/edge) — deliberately |
| +// NOT imported: the activator satisfies edge.Activator structurally, and the |
| +// dependency must point edge→activator only. |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/http/httputil" |
| + "os" |
| + "path/filepath" |
| + "sync" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// --- fakes ------------------------------------------------------------------- |
| + |
| +type fakeRep struct { |
| + mu sync.Mutex |
| + err error |
| + closed bool |
| +} |
| + |
| +func (r *fakeRep) Err() error { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + return r.err |
| +} |
| +func (r *fakeRep) setErr(err error) { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + r.err = err |
| +} |
| +func (r *fakeRep) Close(context.Context) error { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + r.closed = true |
| + return nil |
| +} |
| +func (r *fakeRep) isClosed() bool { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + return r.closed |
| +} |
| + |
| +type fakeProc struct { |
| + mu sync.Mutex |
| + stopped bool |
| + dead bool |
| + // stopGate, when non-nil, blocks Stop() until closed — holds an entry in |
| + // the stopping state so a test can poke at it. |
| + stopGate chan struct{} |
| + // label + rec feed the backend's order log ("instance" / "sidecar"), so |
| + // the co-spawn/co-stop tests can assert interleavings. |
| + label string |
| + rec *fakeBackend |
| +} |
| + |
| +func (p *fakeProc) Stop(context.Context) error { |
| + p.mu.Lock() |
| + gate := p.stopGate |
| + p.mu.Unlock() |
| + if gate != nil { |
| + <-gate |
| + } |
| + p.mu.Lock() |
| + p.stopped = true |
| + p.mu.Unlock() |
| + if p.rec != nil { |
| + p.rec.record("stop:" + p.label) |
| + } |
| + return nil |
| +} |
| +func (p *fakeProc) Exited() bool { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + return p.dead |
| +} |
| +func (p *fakeProc) isStopped() bool { |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + return p.stopped |
| +} |
| + |
| +type fakeLease struct { |
| + rep *fakeRep |
| + b *fakeBackend // records the sync interval the wake threads through |
| +} |
| + |
| +func (l *fakeLease) Replicate(_ context.Context, _ string, syncInterval time.Duration) (Rep, error) { |
| + if l.b != nil { |
| + l.b.mu.Lock() |
| + l.b.syncIntervals = append(l.b.syncIntervals, syncInterval) |
| + l.b.mu.Unlock() |
| + } |
| + return l.rep, nil |
| +} |
| +func (l *fakeLease) Release(context.Context) error { return nil } |
| + |
| +type fakeBackend struct { |
| + mu sync.Mutex |
| + acquires int |
| + restores int |
| + starts int |
| + acquireErr error |
| + startGate chan struct{} // when non-nil, Start() blocks until closed |
| + reps []*fakeRep |
| + procs []*fakeProc |
| + // syncIntervals records, per wake, the replication-class tick doWake |
| + // threads into Replicate() — the ladder's seam assertion. |
| + syncIntervals []time.Duration |
| + dueVal int64 // what the instance reports at the sleep-time next-due probe |
| + dueErr error // when non-nil, the probe fails (dead socket, missing token, …) |
| + |
| + // execVerFn models the live/<host> target the child execs at spawn — Start() |
| + // returns it as the spawnVersion, exactly as ExecBackend reads the symlink |
| + // just before Start(). Nil → "" (unknown), which disables convergence, so |
| + // tests that don't care are unaffected. Point it at the same source as the |
| + // sweep's liveVer seam to model a symlink faithfully (both read the current |
| + // target). |
| + execVerFn func() string |
| + |
| + // Sidecar co-spawn. sidecarStaged is HasSidecar's answer (false by |
| + // default, so every pre-co-spawn test doubles as the "host without a |
| + // sidecar spawns none" case); sidecarErr injects spawn failures. |
| + sidecarStaged bool |
| + sidecarErr error |
| + sidecarStarts int |
| + sidecars []*fakeProc |
| + // order interleaves backend calls and proc stops (start:instance, |
| + // start:sidecar, nextDue, stop:sidecar, stop:instance) for the ordering |
| + // assertions. |
| + order []string |
| +} |
| + |
| +func (b *fakeBackend) record(ev string) { |
| + b.mu.Lock() |
| + b.order = append(b.order, ev) |
| + b.mu.Unlock() |
| +} |
| + |
| +func (b *fakeBackend) events() []string { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + return append([]string(nil), b.order...) |
| +} |
| + |
| +func (b *fakeBackend) Acquire(context.Context, string) (Lease, error) { |
| + b.mu.Lock() |
| + b.acquires++ |
| + err := b.acquireErr |
| + rep := &fakeRep{} |
| + b.reps = append(b.reps, rep) |
| + b.mu.Unlock() |
| + if err != nil { |
| + return nil, err |
| + } |
| + return &fakeLease{rep: rep, b: b}, nil |
| +} |
| + |
| +func (b *fakeBackend) Restore(context.Context, string, string) error { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + b.restores++ |
| + return nil |
| +} |
| + |
| +func (b *fakeBackend) Start(context.Context, registry.Route) (Proc, string, error) { |
| + b.mu.Lock() |
| + b.starts++ |
| + b.order = append(b.order, "start:instance") |
| + gate := b.startGate |
| + fn := b.execVerFn |
| + proc := &fakeProc{label: "instance", rec: b} |
| + b.procs = append(b.procs, proc) |
| + b.mu.Unlock() |
| + if gate != nil { |
| + <-gate |
| + } |
| + ver := "" |
| + if fn != nil { |
| + ver = fn() |
| + } |
| + return proc, ver, nil |
| +} |
| + |
| +func (b *fakeBackend) NextDue(context.Context, registry.Route) (int64, error) { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + b.order = append(b.order, "nextDue") |
| + if b.dueErr != nil { |
| + return 0, b.dueErr |
| + } |
| + return b.dueVal, nil |
| +} |
| + |
| +func (b *fakeBackend) HasSidecar(string) bool { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + return b.sidecarStaged |
| +} |
| + |
| +func (b *fakeBackend) StartSidecar(context.Context, registry.Route) (Proc, error) { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + b.sidecarStarts++ |
| + b.order = append(b.order, "start:sidecar") |
| + if b.sidecarErr != nil { |
| + return nil, b.sidecarErr |
| + } |
| + p := &fakeProc{label: "sidecar", rec: b} |
| + b.sidecars = append(b.sidecars, p) |
| + return p, nil |
| +} |
| + |
| +func (b *fakeBackend) sidecarStartCount() int { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + return b.sidecarStarts |
| +} |
| + |
| +func (b *fakeBackend) lastSidecar() *fakeProc { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + return b.sidecars[len(b.sidecars)-1] |
| +} |
| + |
| +func (b *fakeBackend) setSidecarErr(err error) { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + b.sidecarErr = err |
| +} |
| + |
| +func (b *fakeBackend) setDue(v int64) { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + b.dueVal = v |
| +} |
| + |
| +func (b *fakeBackend) counts() (acquires, restores, starts int) { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + return b.acquires, b.restores, b.starts |
| +} |
| + |
| +func (b *fakeBackend) lastProc() *fakeProc { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + return b.procs[len(b.procs)-1] |
| +} |
| + |
| +func (b *fakeBackend) lastRep() *fakeRep { |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + return b.reps[len(b.reps)-1] |
| +} |
| + |
| +// fakeClock is the activator's `now` seam. |
| +type fakeClock struct { |
| + mu sync.Mutex |
| + t time.Time |
| +} |
| + |
| +func (c *fakeClock) now() time.Time { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + return c.t |
| +} |
| +func (c *fakeClock) advance(d time.Duration) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + c.t = c.t.Add(d) |
| +} |
| + |
| +// The registry IS the DueStore in production — assert the wiring compiles. |
| +var _ DueStore = (*registry.Registry)(nil) |
| + |
| +// testActivator wires the fakes to a REAL (temp-dir) registry as the due store, |
| +// so the due-time tests exercise the actual routes.next_due SQL, not a fake of it. |
| +func testActivator(t *testing.T, idle time.Duration) (*Service, *fakeBackend, *fakeClock, *registry.Registry) { |
| + t.Helper() |
| + b := &fakeBackend{} |
| + clk := &fakeClock{t: time.Unix(1_700_000_000, 0)} |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatalf("open registry: %v", err) |
| + } |
| + a := New(Config{Bucket: "test-bucket", Idle: idle}, b, reg) |
| + a.now = clk.now |
| + return a, b, clk, reg |
| +} |
| + |
| +func testRoute(host string) registry.Route { |
| + // Hibernate is set because these routes model activator-managed hosts — |
| + // the per-host gate (the due sweep's flag filter, ServeHTTP's gate) only |
| + // acts on flagged instances. |
| + return registry.Route{Host: host, Kind: "instance", Socket: "/tmp/nope.sock", |
| + DBPath: filepath.Join("/tmp", host+".db"), Hibernate: true} |
| +} |
| + |
| +// waitFor polls until a condition holds — the stop path runs on its own |
| +// goroutine, so tests observe its completion rather than assuming it. |
| +func waitFor(t *testing.T, what string, cond func() bool) { |
| + t.Helper() |
| + deadline := time.Now().Add(5 * time.Second) |
| + for time.Now().Before(deadline) { |
| + if cond() { |
| + return |
| + } |
| + time.Sleep(20 * time.Millisecond) |
| + } |
| + t.Fatalf("timed out waiting for %s", what) |
| +} |
| + |
| +func (a *Service) entryCount() int { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + return len(a.entries) |
| +} |
| + |
| +// --- the test router --------------------------------------------------------- |
| +// |
| +// A minimal stand-in for internal/edge's dispatch: routes with a socket get a |
| +// unix reverse proxy; hibernate-flagged instance routes go through the |
| +// activator's ServeHTTP hook first (the same shape as edge.Edge.ServeHTTP); |
| +// reload() re-lists the registry and calls RetireExcept with the managed set, |
| +// exactly as edge.Reload does. |
| + |
| +type testUpstream struct { |
| + proxy http.Handler |
| + route registry.Route |
| + hib bool |
| +} |
| + |
| +type testRouter struct { |
| + reg *registry.Registry |
| + act *Service |
| + mu sync.Mutex |
| + up map[string]testUpstream |
| +} |
| + |
| +func newTestRouter(reg *registry.Registry, act *Service) (*testRouter, error) { |
| + rt := &testRouter{reg: reg, act: act} |
| + if err := rt.reload(); err != nil { |
| + return nil, err |
| + } |
| + return rt, nil |
| +} |
| + |
| +func (rt *testRouter) reload() error { |
| + routes, err := rt.reg.List() |
| + if err != nil { |
| + return err |
| + } |
| + m := make(map[string]testUpstream) |
| + managed := make(map[string]bool) |
| + for _, t := range routes { |
| + hib := rt.act != nil && t.Kind == "instance" && t.Hibernate |
| + if hib { |
| + managed[t.Host] = true |
| + } |
| + m[t.Host] = testUpstream{proxy: testUnixProxy(t.Socket), route: t, hib: hib} |
| + } |
| + rt.mu.Lock() |
| + rt.up = m |
| + rt.mu.Unlock() |
| + if rt.act != nil { |
| + rt.act.RetireExcept(managed) |
| + } |
| + return nil |
| +} |
| + |
| +func (rt *testRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + host := r.Host |
| + if h, _, err := net.SplitHostPort(host); err == nil { |
| + host = h |
| + } |
| + rt.mu.Lock() |
| + up, ok := rt.up[host] |
| + rt.mu.Unlock() |
| + if !ok { |
| + http.Error(w, "unknown host", http.StatusNotFound) |
| + return |
| + } |
| + if up.hib && rt.act.ServeHTTP(w, r, up.route, up.proxy) { |
| + return |
| + } |
| + up.proxy.ServeHTTP(w, r) |
| +} |
| + |
| +func testUnixProxy(socket string) http.Handler { |
| + dialer := &net.Dialer{} |
| + return &httputil.ReverseProxy{ |
| + Rewrite: func(pr *httputil.ProxyRequest) { |
| + pr.Out.URL.Scheme = "http" |
| + pr.Out.URL.Host = "carlos" // placeholder; the unix dialer ignores it |
| + pr.Out.Host = pr.In.Host |
| + }, |
| + Transport: &http.Transport{ |
| + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return dialer.DialContext(ctx, "unix", socket) |
| + }, |
| + }, |
| + } |
| +} |
| + |
| +// proxyGet sends GET base+"/" with an overridden Host header and returns the |
| +// response body + status code. |
| +func proxyGet(t *testing.T, base, host string) (string, int) { |
| + t.Helper() |
| + req, err := http.NewRequest(http.MethodGet, base+"/", nil) |
| + if err != nil { |
| + t.Fatalf("new request: %v", err) |
| + } |
| + req.Host = host |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("request: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + b, _ := io.ReadAll(resp.Body) |
| + return string(b), resp.StatusCode |
| +} |
| + |
| +// tmpSock returns a short unix socket path (the sun_path limit is ~104 bytes, |
| +// so t.TempDir() can be too long) and schedules its removal. |
| +func tmpSock(t *testing.T) string { |
| + t.Helper() |
| + s := filepath.Join(os.TempDir(), fmt.Sprintf("carlos-act-%d-%d.sock", os.Getpid(), time.Now().UnixNano()%1_000_000)) |
| + os.Remove(s) |
| + t.Cleanup(func() { os.Remove(s) }) |
| + return s |
| +} |
| + |
| +// --- tests --------------------------------------------------------------------- |
| + |
| +// Many concurrent requests during a wake coalesce into exactly one wake: one |
| +// lease claim, one restore, one spawn. |
| +func TestActivatorWakeCoalesces(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Minute) |
| + b.startGate = make(chan struct{}) |
| + |
| + const n = 12 |
| + var ok atomic.Int32 |
| + var wg sync.WaitGroup |
| + for i := 0; i < n; i++ { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + release, err := a.beginRequest(context.Background(), testRoute("one.example.com")) |
| + if err != nil { |
| + t.Errorf("beginRequest: %v", err) |
| + return |
| + } |
| + ok.Add(1) |
| + release() |
| + }() |
| + } |
| + // Let the requests pile up on the gated wake, then open it. |
| + waitFor(t, "the wake to begin", func() bool { _, _, s := b.counts(); return s == 1 }) |
| + time.Sleep(50 * time.Millisecond) |
| + close(b.startGate) |
| + wg.Wait() |
| + |
| + if got := ok.Load(); got != n { |
| + t.Fatalf("%d/%d requests succeeded", got, n) |
| + } |
| + acquires, restores, starts := b.counts() |
| + if acquires != 1 || restores != 1 || starts != 1 { |
| + t.Fatalf("wake did not coalesce: acquires=%d restores=%d starts=%d (want 1 each)", acquires, restores, starts) |
| + } |
| +} |
| + |
| +// The wake threads the route's replication class into Replicate() as the |
| +// litestream sync interval — the per-plan durability ladder's seam. Unset |
| +// (every pre-ladder route) must thread 0, i.e. litestream's default: a deploy |
| +// of the ladder changes no host's durability by itself. |
| +func TestActivatorWakeThreadsReplicationClass(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Minute) |
| + |
| + for i, tc := range []struct { |
| + class string |
| + want time.Duration |
| + }{ |
| + {"", 0}, |
| + {replicationStream, 0}, |
| + {replicationBatched, 5 * time.Minute}, |
| + {replicationDaily, 24 * time.Hour}, |
| + } { |
| + rt := testRoute(fmt.Sprintf("ladder%d.example.com", i)) |
| + rt.Replication = tc.class |
| + release, err := a.beginRequest(context.Background(), rt) |
| + if err != nil { |
| + t.Fatalf("beginRequest(%q): %v", tc.class, err) |
| + } |
| + release() |
| + b.mu.Lock() |
| + got := b.syncIntervals[len(b.syncIntervals)-1] |
| + b.mu.Unlock() |
| + if got != tc.want { |
| + t.Errorf("class %q threaded sync interval %v, want %v", tc.class, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// After the idle window with nothing in flight, the sweeper hibernates the |
| +// instance: process stopped, replication closed (final checkpoint + lease |
| +// release), entry forgotten; the next request wakes it afresh. |
| +func TestActivatorIdleSleep(t *testing.T) { |
| + a, b, clk, _ := testActivator(t, 10*time.Second) |
| + |
| + release, err := a.beginRequest(context.Background(), testRoute("one.example.com")) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| + |
| + // Not yet idle: nothing happens. |
| + clk.advance(9 * time.Second) |
| + a.sweepOnce() |
| + if a.entryCount() != 1 { |
| + t.Fatal("instance hibernated before its idle window elapsed") |
| + } |
| + |
| + clk.advance(2 * time.Second) |
| + a.sweepOnce() |
| + waitFor(t, "hibernation", func() bool { return a.entryCount() == 0 }) |
| + if !b.lastProc().isStopped() { |
| + t.Fatal("idle instance's process was not stopped") |
| + } |
| + if !b.lastRep().isClosed() { |
| + t.Fatal("idle instance's replication was not closed (no final checkpoint/lease release)") |
| + } |
| + |
| + // A new request wakes it again — a second full wake. |
| + release, err = a.beginRequest(context.Background(), testRoute("one.example.com")) |
| + if err != nil { |
| + t.Fatalf("beginRequest after sleep: %v", err) |
| + } |
| + release() |
| + if _, _, starts := b.counts(); starts != 2 { |
| + t.Fatalf("starts=%d after sleep+rewake, want 2", starts) |
| + } |
| +} |
| + |
| +// An in-flight request (an open WebSocket, in production) holds the instance |
| +// awake past the idle window; only after it finishes does the idle clock run. |
| +func TestActivatorInflightKeepsAwake(t *testing.T) { |
| + a, b, clk, _ := testActivator(t, 10*time.Second) |
| + |
| + release, err := a.beginRequest(context.Background(), testRoute("one.example.com")) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + |
| + clk.advance(time.Hour) |
| + a.sweepOnce() |
| + if a.entryCount() != 1 || b.lastProc().isStopped() { |
| + t.Fatal("instance with an in-flight request was hibernated") |
| + } |
| + |
| + release() // stamps lastActive = now |
| + a.sweepOnce() |
| + if a.entryCount() != 1 { |
| + t.Fatal("instance hibernated immediately after its last request finished") |
| + } |
| + clk.advance(11 * time.Second) |
| + a.sweepOnce() |
| + waitFor(t, "hibernation", func() bool { return a.entryCount() == 0 }) |
| +} |
| + |
| +// A ship repoints live/<host> under an awake child. A running process never |
| +// re-reads its symlink, so idle-hibernate is the only thing that converges an |
| +// instance — and an always-awake host (every sidecar host) never idles. |
| +// The sweep closes that gap: it notices the drift and, after a grace window and |
| +// rate-limited to one per tick, gracefully cycles the child so its next wake |
| +// execs the new binary. |
| +func TestActivatorConvergesOnSymlinkRepoint(t *testing.T) { |
| + a, b, clk, _ := testActivator(t, time.Hour) // idle far away — isolate the convergence path |
| + var ver atomic.Value // the live/<host> symlink target; swapped to model a ship |
| + ver.Store("v1") |
| + // Model one symlink: the child execs whatever it points at now (execVerFn, |
| + // frozen into spawnVersion at wake), and the sweep reads the current target |
| + // (liveVer). A "ship" swaps ver; the running child keeps its frozen version. |
| + a.liveVer = func(string) (string, error) { return ver.Load().(string), nil } |
| + b.execVerFn = func() string { return ver.Load().(string) } |
| + |
| + release, err := a.beginRequest(context.Background(), testRoute("one.example.com")) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| + |
| + // Symlink unchanged: never cycles, however long it stays awake. |
| + clk.advance(3 * ConvergeGrace) |
| + a.sweepOnce() |
| + if a.entryCount() != 1 { |
| + t.Fatal("cycled an instance whose symlink never moved") |
| + } |
| + |
| + // Ship: repoint the symlink. The first sweep after only starts the grace |
| + // clock — it must not cycle yet, so a move-then-rollback never drops a live |
| + // connection. |
| + ver.Store("v2") |
| + a.sweepOnce() |
| + if a.entryCount() != 1 { |
| + t.Fatal("cycled inside the grace window — a transient repoint would drop a live connection") |
| + } |
| + |
| + // Past the grace: gracefully cycle (stop → final checkpoint → lease release → |
| + // asleep). The reconnecting client/sidecar would wake it onto v2. |
| + clk.advance(ConvergeGrace + time.Second) |
| + a.sweepOnce() |
| + waitFor(t, "convergence cycle", func() bool { return a.entryCount() == 0 }) |
| + if !b.lastProc().isStopped() { |
| + t.Fatal("stale instance's process was not stopped") |
| + } |
| + if !b.lastRep().isClosed() { |
| + t.Fatal("convergence cycle skipped the final checkpoint / lease release") |
| + } |
| + |
| + // Re-wake against the now-current symlink: spawnVersion tracks v2, so it |
| + // never cycles again — no thrash. |
| + release, err = a.beginRequest(context.Background(), testRoute("one.example.com")) |
| + if err != nil { |
| + t.Fatalf("re-wake after convergence: %v", err) |
| + } |
| + release() |
| + clk.advance(3 * ConvergeGrace) |
| + a.sweepOnce() |
| + if a.entryCount() != 1 { |
| + t.Fatal("cycled again after already converging onto the current build") |
| + } |
| +} |
| + |
| +// Two awake children go stale at once (a fleet-wide ship). One sweep cycles at |
| +// most one — a staggered convergence, never a simultaneous blip of every space. |
| +func TestActivatorConvergenceRateLimitsPerSweep(t *testing.T) { |
| + a, b, clk, _ := testActivator(t, time.Hour) |
| + var ver atomic.Value |
| + ver.Store("v1") |
| + a.liveVer = func(string) (string, error) { return ver.Load().(string), nil } |
| + b.execVerFn = func() string { return ver.Load().(string) } |
| + |
| + for _, h := range []string{"one.example.com", "two.example.com"} { |
| + release, err := a.beginRequest(context.Background(), testRoute(h)) |
| + if err != nil { |
| + t.Fatalf("beginRequest %s: %v", h, err) |
| + } |
| + release() |
| + } |
| + if a.entryCount() != 2 { |
| + t.Fatalf("want 2 awake instances, got %d", a.entryCount()) |
| + } |
| + |
| + ver.Store("v2") // ship under both |
| + a.sweepOnce() // starts the grace clock for both |
| + clk.advance(ConvergeGrace + time.Second) |
| + |
| + a.sweepOnce() // both are past grace, but only one may cycle this tick |
| + waitFor(t, "first convergence cycle", func() bool { return a.entryCount() == 1 }) |
| + // A moment later, the sweep takes the second. |
| + a.sweepOnce() |
| + waitFor(t, "second convergence cycle", func() bool { return a.entryCount() == 0 }) |
| +} |
| + |
| +// The feature's whole premise: converge a host that NEVER reaches inflight==0 |
| +// (an open WebSocket, or a sidecar's persistent connection), which |
| +// idle-hibernate can never touch. Unlike idle-hibernate, convergence fires with |
| +// a request in flight — the graceful stop drains it exactly as a restart would. |
| +func TestActivatorConvergesWithRequestInFlight(t *testing.T) { |
| + a, b, clk, _ := testActivator(t, time.Hour) |
| + var ver atomic.Value |
| + ver.Store("v1") |
| + a.liveVer = func(string) (string, error) { return ver.Load().(string), nil } |
| + b.execVerFn = func() string { return ver.Load().(string) } |
| + |
| + // Hold the request open for the whole test — inflight stays 1, so the idle |
| + // path (which needs inflight==0) can never converge this host. |
| + release, err := a.beginRequest(context.Background(), testRoute("one.example.com")) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + defer release() |
| + |
| + ver.Store("v2") // ship under the busy host |
| + a.sweepOnce() // grace starts; must not cycle a busy host mid-grace |
| + if a.entryCount() != 1 { |
| + t.Fatal("cycled inside the grace window") |
| + } |
| + clk.advance(ConvergeGrace + time.Second) |
| + a.sweepOnce() |
| + // Cycles despite the in-flight request — idle-hibernate never would. |
| + waitFor(t, "busy-host convergence", func() bool { return a.entryCount() == 0 }) |
| + if !b.lastProc().isStopped() { |
| + t.Fatal("busy stale instance's process was not stopped") |
| + } |
| +} |
| + |
| +// ConvergeDecision's state machine, exhaustively: the grace clock starts on |
| +// first drift, survives a rollback correctly, and a read error never clears |
| +// accrued grace (it means "unknown", not "converged"). |
| +func TestConvergeDecision(t *testing.T) { |
| + base := time.Unix(1_700_000_000, 0) |
| + set := base.Add(-40 * time.Second) // a grace clock that started 40s ago |
| + errBoom := errors.New("readlink boom") |
| + cases := []struct { |
| + name string |
| + cur string |
| + readErr error |
| + spawnVer string |
| + staleSince time.Time |
| + wantCycle bool |
| + wantStale time.Time |
| + }{ |
| + {"matches — clears grace", "v1", nil, "v1", set, false, time.Time{}}, |
| + {"empty cur — no data, clears", "", nil, "v1", set, false, time.Time{}}, |
| + {"empty spawnVer — unknown, clears", "v2", nil, "", set, false, time.Time{}}, |
| + {"first drift — starts grace, no cycle", "v2", nil, "v1", time.Time{}, false, base}, |
| + {"drift within grace — holds, no cycle", "v2", nil, "v1", base.Add(-30 * time.Second), false, base.Add(-30 * time.Second)}, |
| + {"drift past grace — cycles", "v2", nil, "v1", base.Add(-ConvergeGrace - time.Second), true, base.Add(-ConvergeGrace - time.Second)}, |
| + {"read error — preserves accrued grace", "", errBoom, "v1", set, false, set}, |
| + {"read error before any drift — stays zero", "", errBoom, "v1", time.Time{}, false, time.Time{}}, |
| + {"rollback to spawn — clears grace", "v1", nil, "v1", base.Add(-ConvergeGrace - time.Second), false, time.Time{}}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + cycle, next := ConvergeDecision(c.cur, c.readErr, c.spawnVer, c.staleSince, base, ConvergeGrace) |
| + if cycle != c.wantCycle { |
| + t.Errorf("cycle = %v, want %v", cycle, c.wantCycle) |
| + } |
| + if !next.Equal(c.wantStale) { |
| + t.Errorf("nextStale = %v, want %v", next, c.wantStale) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A lost lease (failed renewal) stands the instance down immediately — no |
| +// waiting for idle: someone else may own the S3 prefix now. |
| +func TestActivatorLeaseLostStandsDown(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Hour) |
| + |
| + release, err := a.beginRequest(context.Background(), testRoute("one.example.com")) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| + |
| + b.lastRep().setErr(errors.New("lease renewal failed")) |
| + a.sweepOnce() // idle window is an hour away; the lost lease alone must trigger the stop |
| + waitFor(t, "stand-down", func() bool { return a.entryCount() == 0 }) |
| + if !b.lastProc().isStopped() { |
| + t.Fatal("lease-lost instance's process was not stopped") |
| + } |
| + if !b.lastRep().isClosed() { |
| + t.Fatal("lease-lost instance's replication was not closed") |
| + } |
| +} |
| + |
| +// A dead child (crashed instance) is recycled by the next request: the stale |
| +// entry is stopped and a fresh wake happens in the same beginRequest call. |
| +func TestActivatorDeadChildRewakesOnRequest(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Hour) |
| + |
| + release, err := a.beginRequest(context.Background(), testRoute("one.example.com")) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| + |
| + p := b.lastProc() |
| + p.mu.Lock() |
| + p.dead = true |
| + p.mu.Unlock() |
| + |
| + release, err = a.beginRequest(context.Background(), testRoute("one.example.com")) |
| + if err != nil { |
| + t.Fatalf("beginRequest after crash: %v", err) |
| + } |
| + release() |
| + if _, _, starts := b.counts(); starts != 2 { |
| + t.Fatalf("starts=%d after crash+request, want 2 (recycle + rewake)", starts) |
| + } |
| +} |
| + |
| +// A request that arrives mid-stop waits for the stop, then wakes fresh. |
| +func TestActivatorRequestDuringStopRewakes(t *testing.T) { |
| + a, b, clk, _ := testActivator(t, 10*time.Second) |
| + |
| + release, err := a.beginRequest(context.Background(), testRoute("one.example.com")) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| + |
| + // Hold the stop open so the entry sits in stopping. |
| + gate := make(chan struct{}) |
| + p := b.lastProc() |
| + p.mu.Lock() |
| + p.stopGate = gate |
| + p.mu.Unlock() |
| + |
| + clk.advance(time.Minute) |
| + a.sweepOnce() |
| + |
| + got := make(chan error, 1) |
| + go func() { |
| + release, err := a.beginRequest(context.Background(), testRoute("one.example.com")) |
| + if err == nil { |
| + release() |
| + } |
| + got <- err |
| + }() |
| + time.Sleep(50 * time.Millisecond) // the request should now be parked on e.stopped |
| + close(gate) |
| + if err := <-got; err != nil { |
| + t.Fatalf("request during stop: %v", err) |
| + } |
| + if _, _, starts := b.counts(); starts != 2 { |
| + t.Fatalf("starts=%d, want 2 (sleep then rewake)", starts) |
| + } |
| +} |
| + |
| +// A wake that exceeds the bound turns queued requests away with errWakeTimeout |
| +// (503 at the edge) rather than piling connections up behind it. |
| +func TestActivatorWakeTimeout(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Minute) |
| + old := wakeWaitBound |
| + wakeWaitBound = 100 * time.Millisecond |
| + t.Cleanup(func() { wakeWaitBound = old }) |
| + |
| + gate := make(chan struct{}) |
| + b.startGate = gate |
| + t.Cleanup(func() { close(gate) }) // let the stuck wake finish at test end |
| + |
| + _, err := a.beginRequest(context.Background(), testRoute("one.example.com")) |
| + if !errors.Is(err, errWakeTimeout) { |
| + t.Fatalf("got %v, want errWakeTimeout", err) |
| + } |
| +} |
| + |
| +// Lease contention surfaces through the edge as 503 + Retry-After, and a |
| +// failed wake leaves no entry behind (the next request retries the claim). |
| +func TestActivatorLeaseContention503(t *testing.T) { |
| + a, b, _, reg := testActivator(t, time.Minute) |
| + b.acquireErr = &errLeaseHeld{holder: "old-incarnation", retryAfter: 7 * time.Second} |
| + |
| + if err := reg.Upsert(registry.Route{Host: "held.example.com", Kind: "instance", Socket: "/tmp/held.sock", Hibernate: true}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + rt, err := newTestRouter(reg, a) |
| + if err != nil { |
| + t.Fatalf("new router: %v", err) |
| + } |
| + front := httptest.NewServer(rt) |
| + t.Cleanup(front.Close) |
| + |
| + req, _ := http.NewRequest("GET", front.URL+"/", nil) |
| + req.Host = "held.example.com" |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("request: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusServiceUnavailable { |
| + t.Fatalf("status %d, want 503", resp.StatusCode) |
| + } |
| + if ra := resp.Header.Get("Retry-After"); ra != "8" { // 7s truncated + 1 |
| + t.Fatalf("Retry-After %q, want 8", ra) |
| + } |
| + if a.entryCount() != 0 { |
| + t.Fatal("failed wake left an entry behind") |
| + } |
| +} |
| + |
| +// Per-host gating (the opt-in): with the activator CONFIGURED, only a |
| +// hibernate-flagged instance goes through the wake path; an unflagged one is |
| +// proxied straight, exactly as if there were no activator at all — the |
| +// deploy-safety property that lets the bucket env ship fleet-wide before any |
| +// host is flagged. |
| +func TestRouterPerHostHibernateGating(t *testing.T) { |
| + a, b, _, reg := testActivator(t, time.Minute) |
| + |
| + // Two live backends playing the instances. The flagged host's listener |
| + // stands in for the child a wake would have spawned (the fake backend's |
| + // Start() spawns nothing). |
| + bind := func(name string) string { |
| + sock := tmpSock(t) |
| + ln, err := net.Listen("unix", sock) |
| + if err != nil { |
| + t.Fatalf("listen unix: %v", err) |
| + } |
| + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + io.WriteString(w, name) |
| + })} |
| + go srv.Serve(ln) |
| + t.Cleanup(func() { srv.Close() }) |
| + return sock |
| + } |
| + if err := reg.Upsert(registry.Route{Host: "hib.example.com", Kind: "instance", Socket: bind("hib"), Hibernate: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{Host: "plain.example.com", Kind: "instance", Socket: bind("plain")}); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + rt, err := newTestRouter(reg, a) |
| + if err != nil { |
| + t.Fatalf("new router: %v", err) |
| + } |
| + front := httptest.NewServer(rt) |
| + t.Cleanup(front.Close) |
| + |
| + // The unflagged host: proxied straight, the activator never touched. |
| + if body, code := proxyGet(t, front.URL, "plain.example.com"); code != 200 || body != "plain" { |
| + t.Fatalf("unflagged host: got %d %q, want 200 plain", code, body) |
| + } |
| + if acquires, _, starts := b.counts(); acquires != 0 || starts != 0 { |
| + t.Fatalf("unflagged instance touched the activator (acquires=%d starts=%d)", acquires, starts) |
| + } |
| + |
| + // The flagged host: served through a full wake — lease, restore, spawn. |
| + if body, code := proxyGet(t, front.URL, "hib.example.com"); code != 200 || body != "hib" { |
| + t.Fatalf("flagged host: got %d %q, want 200 hib", code, body) |
| + } |
| + if acquires, restores, starts := b.counts(); acquires != 1 || restores != 1 || starts != 1 { |
| + t.Fatalf("flagged host skipped the wake path: acquires=%d restores=%d starts=%d (want 1 each)", acquires, restores, starts) |
| + } |
| +} |
| + |
| +// The activator's own gate: handed an unflagged (or non-instance) route it |
| +// declines — returns false, writes nothing, touches no backend — so the edge |
| +// proxies straight. This is the bool half of the edge.Activator contract. |
| +func TestServeHTTPUnmanagedRouteReturnsFalse(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Minute) |
| + noop := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) |
| + |
| + rec := httptest.NewRecorder() |
| + req := httptest.NewRequest("GET", "http://plain.example.com/", nil) |
| + unflagged := registry.Route{Host: "plain.example.com", Kind: "instance", Socket: "/tmp/x.sock"} |
| + if a.ServeHTTP(rec, req, unflagged, noop) { |
| + t.Fatal("unflagged instance route was handled by the activator") |
| + } |
| + svc := registry.Route{Host: "svc.example.com", Kind: "service", Socket: "/tmp/s.sock", Hibernate: true} |
| + if a.ServeHTTP(rec, req, svc, noop) { |
| + t.Fatal("service route was handled by the activator") |
| + } |
| + if rec.Body.Len() != 0 { |
| + t.Fatalf("declined routes wrote a response: %q", rec.Body.String()) |
| + } |
| + if acquires, _, starts := b.counts(); acquires != 0 || starts != 0 { |
| + t.Fatalf("declined routes touched the backend (acquires=%d starts=%d)", acquires, starts) |
| + } |
| +} |
| + |
| +// A reload that finds an awake host no longer hibernate-flagged retires its |
| +// child — stopped, final-checkpointed (replication closed), entry forgotten — |
| +// the edge-process half of flipping hibernation off. And once the flag is |
| +// off, a recorded next_due no longer wakes it: the due index only acts on |
| +// hibernating hosts, so a systemd-managed instance can never be spawned |
| +// against its own unit. |
| +func TestActivatorReloadRetiresUnflaggedHost(t *testing.T) { |
| + a, b, clk, reg := testActivator(t, time.Hour) |
| + route := testRoute("flip.example.com") |
| + if err := reg.Upsert(route); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt, err := newTestRouter(reg, a) |
| + if err != nil { |
| + t.Fatalf("new router: %v", err) |
| + } |
| + |
| + release, err := a.beginRequest(context.Background(), route) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| + // The retire's sleep re-records next_due AFTER the flag is cleared; an |
| + // already-past value must stay inert under the flag filter. |
| + b.setDue(clk.now().Add(-5 * time.Second).Unix()) |
| + |
| + if err := reg.SetHibernate("flip.example.com", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := rt.reload(); err != nil { |
| + t.Fatalf("reload: %v", err) |
| + } |
| + waitFor(t, "retire", func() bool { return a.entryCount() == 0 }) |
| + if !b.lastProc().isStopped() { |
| + t.Fatal("retired child was not stopped") |
| + } |
| + if !b.lastRep().isClosed() { |
| + t.Fatal("retired child's replication was not closed (no final checkpoint/lease release)") |
| + } |
| + |
| + // The due sweeper must not resurrect a host that's back under systemd. |
| + a.sweepOnce() |
| + if _, _, starts := b.counts(); starts != 1 { |
| + t.Fatalf("starts=%d after retire, want 1 (no due wake for a systemd-managed host)", starts) |
| + } |
| +} |
| + |
| +// A retire that lands mid-wake can't stop the child yet — it finishes the job |
| +// once the wake resolves, so a flip-off during the small wake window still |
| +// releases cleanly. |
| +func TestActivatorRetireDuringWake(t *testing.T) { |
| + a, b, clk, reg := testActivator(t, time.Hour) |
| + route := testRoute("mid.example.com") |
| + if err := reg.Upsert(route); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt, err := newTestRouter(reg, a) |
| + if err != nil { |
| + t.Fatalf("new router: %v", err) |
| + } |
| + |
| + // Start a request-less (due-time) wake and hold it in the waking state. |
| + b.startGate = make(chan struct{}) |
| + if err := reg.SetNextDue("mid.example.com", clk.now().Add(-time.Second).Unix()); err != nil { |
| + t.Fatal(err) |
| + } |
| + a.sweepOnce() |
| + waitFor(t, "the wake to begin", func() bool { _, _, s := b.counts(); return s == 1 }) |
| + |
| + // Flip off mid-wake: the reload's retire must defer, not deadlock. |
| + if err := reg.SetHibernate("mid.example.com", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := rt.reload(); err != nil { |
| + t.Fatalf("reload: %v", err) |
| + } |
| + close(b.startGate) |
| + |
| + waitFor(t, "post-wake retire", func() bool { return a.entryCount() == 0 }) |
| + if !b.lastProc().isStopped() { |
| + t.Fatal("child woken mid-retire was not stopped") |
| + } |
| + if !b.lastRep().isClosed() { |
| + t.Fatal("child woken mid-retire left replication open (lease never released)") |
| + } |
| +} |
| + |
| +// With hibernation on, sibling services (kind "service") are untouched: the |
| +// edge proxies them directly, no wake, no activity tracking. |
| +func TestActivatorServiceRoutesBypass(t *testing.T) { |
| + sock := tmpSock(t) |
| + ln, err := net.Listen("unix", sock) |
| + if err != nil { |
| + t.Fatalf("listen unix: %v", err) |
| + } |
| + backend := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + io.WriteString(w, "service ok") |
| + })} |
| + go backend.Serve(ln) |
| + t.Cleanup(func() { backend.Close() }) |
| + |
| + a, b, _, _ := testActivator(t, time.Minute) |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatalf("open registry: %v", err) |
| + } |
| + if err := reg.Upsert(registry.Route{Host: "svc.example.com", Kind: "service", Socket: sock}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + rt, err := newTestRouter(reg, a) |
| + if err != nil { |
| + t.Fatalf("new router: %v", err) |
| + } |
| + front := httptest.NewServer(rt) |
| + t.Cleanup(front.Close) |
| + |
| + req, _ := http.NewRequest("GET", front.URL+"/", nil) |
| + req.Host = "svc.example.com" |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("request: %v", err) |
| + } |
| + body, _ := io.ReadAll(resp.Body) |
| + resp.Body.Close() |
| + if resp.StatusCode != 200 || string(body) != "service ok" { |
| + t.Fatalf("got %d %q, want 200 service ok", resp.StatusCode, body) |
| + } |
| + if acquires, _, _ := b.counts(); acquires != 0 { |
| + t.Fatal("a service route touched the activator") |
| + } |
| +} |
| + |
| +// Shutdown checkpoints and stops every awake child before the edge exits. |
| +func TestActivatorShutdownStopsChildren(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Hour) |
| + for _, h := range []string{"one.example.com", "two.example.com"} { |
| + release, err := a.beginRequest(context.Background(), testRoute(h)) |
| + if err != nil { |
| + t.Fatalf("beginRequest %s: %v", h, err) |
| + } |
| + release() |
| + } |
| + |
| + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| + defer cancel() |
| + a.Shutdown(ctx) |
| + |
| + if a.entryCount() != 0 { |
| + t.Fatal("shutdown left entries behind") |
| + } |
| + b.mu.Lock() |
| + defer b.mu.Unlock() |
| + for i, p := range b.procs { |
| + if !p.stopped { |
| + t.Fatalf("proc %d not stopped on shutdown", i) |
| + } |
| + } |
| + for i, r := range b.reps { |
| + if !r.closed { |
| + t.Fatalf("replication %d not closed on shutdown", i) |
| + } |
| + } |
| +} |
| + |
| +// --- due-time index -------------------------------------------------------------- |
| + |
| +// entryAwake reports whether host has a live entry in the awake state — how the |
| +// due tests observe an async due-time wake resolving. |
| +func (a *Service) entryAwake(host string) bool { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + e := a.entries[host] |
| + return e != nil && e.state == actAwake |
| +} |
| + |
| +// The full self-maintaining cycle: sleep records the instance's next due time |
| +// in the registry; the sweeper wakes the host (no request) when it arrives; the |
| +// following sleep re-records — and once nothing is scheduled, no more due wakes. |
| +func TestActivatorDueTimeWakeCycle(t *testing.T) { |
| + a, b, clk, reg := testActivator(t, 10*time.Second) |
| + route := testRoute("due.example.com") |
| + if err := reg.Upsert(route); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + |
| + // Wake by request; the instance has a delivery scheduled 30s out. |
| + dueAt := clk.now().Add(30 * time.Second).Unix() |
| + b.setDue(dueAt) |
| + release, err := a.beginRequest(context.Background(), route) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| + |
| + // Idle out: the sleep probes the instance and records its due time. |
| + clk.advance(11 * time.Second) |
| + a.sweepOnce() |
| + waitFor(t, "hibernation", func() bool { return a.entryCount() == 0 }) |
| + if rt, err := reg.Get(route.Host); err != nil || rt == nil || rt.NextDue != dueAt { |
| + t.Fatalf("registry next_due = %+v, %v; want %d", rt, err, dueAt) |
| + } |
| + |
| + // Before the due time: sweeps leave it asleep. |
| + a.sweepOnce() |
| + if _, _, starts := b.counts(); starts != 1 { |
| + t.Fatalf("woke before its due time (starts=%d)", starts) |
| + } |
| + |
| + // At the due time: the sweeper wakes it with no client request. |
| + clk.advance(20 * time.Second) // now = wake+31s ≥ due |
| + a.sweepOnce() |
| + waitFor(t, "due-time wake", func() bool { return a.entryAwake(route.Host) }) |
| + if _, _, starts := b.counts(); starts != 2 { |
| + t.Fatalf("starts=%d after due wake, want 2", starts) |
| + } |
| + |
| + // The woken instance stays up for the normal idle window, then sleeps and |
| + // re-records — nothing scheduled now, so the index clears and stays quiet. |
| + b.setDue(0) |
| + clk.advance(11 * time.Second) |
| + a.sweepOnce() |
| + waitFor(t, "re-hibernation", func() bool { return a.entryCount() == 0 }) |
| + if rt, _ := reg.Get(route.Host); rt.NextDue != 0 { |
| + t.Fatalf("next_due = %d after empty-schedule sleep, want 0", rt.NextDue) |
| + } |
| + clk.advance(time.Hour) |
| + a.sweepOnce() |
| + if _, _, starts := b.counts(); starts != 2 { |
| + t.Fatalf("starts=%d with nothing scheduled, want 2 (no phantom due wake)", starts) |
| + } |
| +} |
| + |
| +// A due time already in the past at sleep time (a delivery that raced the stop) |
| +// is recorded as-is: the sweeper wakes the host on its next tick — once — and |
| +// there is no ping-pong, because a woken instance can't sleep again until a |
| +// full idle window passes. |
| +func TestActivatorDuePastWakesOnceNoPingPong(t *testing.T) { |
| + a, b, clk, reg := testActivator(t, 10*time.Second) |
| + route := testRoute("late.example.com") |
| + if err := reg.Upsert(route); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + b.setDue(clk.now().Add(-5 * time.Second).Unix()) // overdue before it even sleeps |
| + |
| + release, err := a.beginRequest(context.Background(), route) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| + clk.advance(11 * time.Second) |
| + a.sweepOnce() |
| + waitFor(t, "hibernation", func() bool { return a.entryCount() == 0 }) |
| + |
| + // Next tick: woken for the overdue delivery. |
| + a.sweepOnce() |
| + waitFor(t, "overdue wake", func() bool { return a.entryAwake(route.Host) }) |
| + |
| + // No thrash: with the wake fresh (clock unmoved), repeated sweeps neither |
| + // re-sleep it nor double-wake it, even though next_due is still in the past. |
| + for i := 0; i < 5; i++ { |
| + a.sweepOnce() |
| + } |
| + if a.entryCount() != 1 || !a.entryAwake(route.Host) { |
| + t.Fatal("due-woken instance did not stay awake through its idle window") |
| + } |
| + if _, _, starts := b.counts(); starts != 2 { |
| + t.Fatalf("starts=%d, want 2 (request wake + one due wake)", starts) |
| + } |
| +} |
| + |
| +// A failed next-due probe (dead socket, missing admin token, …) records 0 and |
| +// the sleep proceeds — the instance degrades to "sleeps until a request", |
| +// never a crash and never a stuck stop. |
| +func TestActivatorDueProbeFailureRecordsZero(t *testing.T) { |
| + a, b, clk, reg := testActivator(t, 10*time.Second) |
| + route := testRoute("deaf.example.com") |
| + if err := reg.Upsert(route); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + b.dueErr = errors.New("dial unix: no such file") |
| + |
| + release, err := a.beginRequest(context.Background(), route) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| + clk.advance(11 * time.Second) |
| + a.sweepOnce() |
| + waitFor(t, "hibernation despite probe failure", func() bool { return a.entryCount() == 0 }) |
| + |
| + if rt, _ := reg.Get(route.Host); rt.NextDue != 0 { |
| + t.Fatalf("next_due = %d after failed probe, want 0", rt.NextDue) |
| + } |
| + clk.advance(time.Hour) |
| + a.sweepOnce() |
| + if _, _, starts := b.counts(); starts != 1 { |
| + t.Fatalf("starts=%d, want 1 (a failed probe must not invent a wake)", starts) |
| + } |
| +} |
| + |
| +// The index lives in the registry, so a due time recorded before an edge |
| +// restart still fires: a brand-new activator over the same registry — no |
| +// in-memory state carried over — wakes the host when its time arrives. |
| +func TestActivatorDueSurvivesRestart(t *testing.T) { |
| + a1, b1, clk, reg := testActivator(t, 10*time.Second) |
| + route := testRoute("reborn.example.com") |
| + if err := reg.Upsert(route); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + b1.setDue(clk.now().Add(30 * time.Second).Unix()) |
| + |
| + release, err := a1.beginRequest(context.Background(), route) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| + clk.advance(11 * time.Second) |
| + a1.sweepOnce() |
| + waitFor(t, "hibernation", func() bool { return a1.entryCount() == 0 }) |
| + |
| + // "Restart": a fresh activator, same registry. |
| + b2 := &fakeBackend{} |
| + a2 := New(Config{Bucket: "test-bucket", Idle: 10 * time.Second}, b2, reg) |
| + a2.now = clk.now |
| + |
| + clk.advance(25 * time.Second) // past the due time |
| + a2.sweepOnce() |
| + waitFor(t, "post-restart due wake", func() bool { return a2.entryAwake(route.Host) }) |
| + if _, _, starts := b2.counts(); starts != 1 { |
| + t.Fatalf("starts=%d on the new activator, want 1", starts) |
| + } |
| +} |
| + |
| +// --- sidecar co-spawn ---------------------------------------------------------- |
| + |
| +// indexOf: position of ev in the backend's order log (-1 = absent). |
| +func indexOf(events []string, ev string) int { |
| + for i, e := range events { |
| + if e == ev { |
| + return i |
| + } |
| + } |
| + return -1 |
| +} |
| + |
| +// A host with a sidecar staged gets it co-spawned on wake, strictly AFTER the |
| +// instance process is up — the sidecar dials the instance, so spawning it |
| +// first would just burn its reconnect backoff. (Every other test in this file |
| +// runs with sidecarStaged=false and asserts nothing sidecar-ish happens, which |
| +// is the "host without a sidecar behaves exactly as today" half.) |
| +func TestActivatorCoSpawnsSidecarAfterInstance(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Minute) |
| + b.sidecarStaged = true |
| + |
| + release, err := a.beginRequest(context.Background(), testRoute("bots.example.com")) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| + |
| + if got := b.sidecarStartCount(); got != 1 { |
| + t.Fatalf("sidecar starts = %d, want 1", got) |
| + } |
| + ev := b.events() |
| + if i, s := indexOf(ev, "start:instance"), indexOf(ev, "start:sidecar"); i == -1 || s == -1 || s < i { |
| + t.Fatalf("sidecar did not start strictly after the instance: %v", ev) |
| + } |
| +} |
| + |
| +// A sidecar that fails to spawn must not fail the wake — members > bots: the |
| +// host serves without its sidecar, and the sweeper keeps retrying with backoff |
| +// until a spawn sticks. |
| +func TestActivatorSidecarFailureDoesNotFailWake(t *testing.T) { |
| + a, b, clk, _ := testActivator(t, time.Hour) |
| + b.sidecarStaged = true |
| + b.setSidecarErr(errors.New("exec: live symlink gone")) |
| + route := testRoute("botless.example.com") |
| + |
| + release, err := a.beginRequest(context.Background(), route) |
| + if err != nil { |
| + t.Fatalf("beginRequest with a failing sidecar: %v", err) |
| + } |
| + release() |
| + if !a.entryAwake(route.Host) { |
| + t.Fatal("instance not awake after a sidecar-only failure") |
| + } |
| + if got := b.sidecarStartCount(); got != 1 { |
| + t.Fatalf("sidecar attempts = %d, want 1 (the wake's)", got) |
| + } |
| + |
| + // The sweeper retries: attempt 2 immediately (first retry), then gated by |
| + // the doubling backoff; once the fault clears, a retry lands the sidecar. |
| + a.sweepOnce() |
| + if got := b.sidecarStartCount(); got != 2 { |
| + t.Fatalf("sidecar attempts after first sweep = %d, want 2", got) |
| + } |
| + a.sweepOnce() // still inside the 2s gate — no attempt |
| + if got := b.sidecarStartCount(); got != 2 { |
| + t.Fatalf("backoff gate ignored: attempts = %d, want 2", got) |
| + } |
| + b.setSidecarErr(nil) |
| + clk.advance(3 * time.Second) |
| + a.sweepOnce() |
| + if got := b.sidecarStartCount(); got != 3 { |
| + t.Fatalf("sidecar attempts after fault cleared = %d, want 3", got) |
| + } |
| + if b.lastSidecar().isStopped() || b.lastSidecar().Exited() { |
| + t.Fatal("recovered sidecar isn't running") |
| + } |
| +} |
| + |
| +// Co-stop ordering at sleep: the sidecar is stopped FIRST — before the |
| +// next-due probe (so bots can't shift the schedule being recorded) and before |
| +// the instance's own stop (so the sidecar never outlives its instance). |
| +func TestActivatorCoStopOrdering(t *testing.T) { |
| + a, b, clk, _ := testActivator(t, 10*time.Second) |
| + b.sidecarStaged = true |
| + |
| + release, err := a.beginRequest(context.Background(), testRoute("bots.example.com")) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| + |
| + clk.advance(11 * time.Second) |
| + a.sweepOnce() |
| + waitFor(t, "hibernation", func() bool { return a.entryCount() == 0 }) |
| + |
| + if !b.lastSidecar().isStopped() { |
| + t.Fatal("sidecar not stopped at sleep") |
| + } |
| + ev := b.events() |
| + sc, due, inst := indexOf(ev, "stop:sidecar"), indexOf(ev, "nextDue"), indexOf(ev, "stop:instance") |
| + if sc == -1 || due == -1 || inst == -1 || !(sc < due && due < inst) { |
| + t.Fatalf("co-stop order wrong: %v (want stop:sidecar < nextDue < stop:instance)", ev) |
| + } |
| +} |
| + |
| +// Orphan prevention: whatever takes the instance down — here a crashed child, |
| +// but a lost lease and a retire ride the identical stop path — takes the |
| +// sidecar with it. An orphaned sidecar would reconnect-loop against the edge |
| +// forever (503s at best; a wake source if the marker rule ever broke). |
| +func TestActivatorInstanceDeathStopsSidecar(t *testing.T) { |
| + a, b, _, _ := testActivator(t, time.Hour) |
| + b.sidecarStaged = true |
| + |
| + release, err := a.beginRequest(context.Background(), testRoute("bots.example.com")) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| + |
| + p := b.lastProc() |
| + p.mu.Lock() |
| + p.dead = true |
| + p.mu.Unlock() |
| + a.sweepOnce() |
| + waitFor(t, "stand-down", func() bool { return a.entryCount() == 0 }) |
| + if !b.lastSidecar().isStopped() { |
| + t.Fatal("instance died but its sidecar was left running (orphan)") |
| + } |
| +} |
| + |
| +// Sidecar death while the instance stays awake: restarted with doubling, |
| +// capped backoff — Restart=always semantics, sweep-driven — and a run that |
| +// held for sidecarHealthyRun earns a fresh backoff. |
| +func TestActivatorSidecarRestartBackoff(t *testing.T) { |
| + a, b, clk, _ := testActivator(t, time.Hour) |
| + b.sidecarStaged = true |
| + |
| + release, err := a.beginRequest(context.Background(), testRoute("bots.example.com")) |
| + if err != nil { |
| + t.Fatalf("beginRequest: %v", err) |
| + } |
| + release() |
| + kill := func() { |
| + sc := b.lastSidecar() |
| + sc.mu.Lock() |
| + sc.dead = true |
| + sc.mu.Unlock() |
| + } |
| + |
| + // T0: first crash — restarted on the next sweep (attempt 2), gate 2s. |
| + kill() |
| + a.sweepOnce() |
| + if got := b.sidecarStartCount(); got != 2 { |
| + t.Fatalf("first restart: attempts = %d, want 2", got) |
| + } |
| + // Crash-loop: still inside the 2s gate — no restart. |
| + kill() |
| + a.sweepOnce() |
| + if got := b.sidecarStartCount(); got != 2 { |
| + t.Fatalf("gate ignored: attempts = %d, want 2", got) |
| + } |
| + // T3: past the 2s gate — attempt 3, gate doubles to 4s. |
| + clk.advance(3 * time.Second) |
| + a.sweepOnce() |
| + if got := b.sidecarStartCount(); got != 3 { |
| + t.Fatalf("second restart: attempts = %d, want 3", got) |
| + } |
| + // T6: 3s later is inside the 4s gate — the doubling is real. |
| + kill() |
| + clk.advance(3 * time.Second) |
| + a.sweepOnce() |
| + if got := b.sidecarStartCount(); got != 3 { |
| + t.Fatalf("doubled gate ignored: attempts = %d, want 3", got) |
| + } |
| + // T8: past it — attempt 4 (gate now 8s). |
| + clk.advance(2 * time.Second) |
| + a.sweepOnce() |
| + if got := b.sidecarStartCount(); got != 4 { |
| + t.Fatalf("third restart: attempts = %d, want 4", got) |
| + } |
| + // A healthy run (≥ sidecarHealthyRun) resets the ladder: the next crash |
| + // restarts immediately and its gate is back to the 2s base… |
| + clk.advance(2 * time.Minute) |
| + kill() |
| + a.sweepOnce() |
| + if got := b.sidecarStartCount(); got != 5 { |
| + t.Fatalf("post-healthy restart: attempts = %d, want 5", got) |
| + } |
| + // …which a 3s advance clears (16s — the unreset ladder — would not). |
| + kill() |
| + clk.advance(3 * time.Second) |
| + a.sweepOnce() |
| + if got := b.sidecarStartCount(); got != 6 { |
| + t.Fatalf("backoff not reset after a healthy run: attempts = %d, want 6", got) |
| + } |
| +} |
| + |
| +// The activity question: the co-spawned sidecar dials its instance's public |
| +// URL through this very edge (the box pins its hosted names to loopback), so |
| +// without the marker its keepalives would be member activity and the pair |
| +// could never sleep. Full front-door check: marked requests are served while |
| +// awake — with the marker stripped before the backend — but stamp nothing |
| +// (the host hibernates on schedule beneath them), and once asleep they get |
| +// 503 and wake nothing. An outsider spoofing the marker gets exactly that |
| +// same treatment: it can't wake the host and can't suppress anyone else's |
| +// stamps (stamps are per-request), so the marker is not a DoS lever in either |
| +// direction. |
| +func TestActivatorSidecarRequestsInvisibleToActivity(t *testing.T) { |
| + a, b, clk, reg := testActivator(t, 10*time.Second) |
| + |
| + // A live backend listener stands in for the child a wake would have |
| + // spawned (the fake backend's Start() spawns nothing), recording whether |
| + // the sidecar marker ever leaks through the proxy. |
| + sock := tmpSock(t) |
| + ln, err := net.Listen("unix", sock) |
| + if err != nil { |
| + t.Fatalf("listen unix: %v", err) |
| + } |
| + var leaked atomic.Bool |
| + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Header.Get(SidecarHeader) != "" { |
| + leaked.Store(true) |
| + } |
| + io.WriteString(w, "ok") |
| + })} |
| + go srv.Serve(ln) |
| + t.Cleanup(func() { srv.Close() }) |
| + |
| + const host = "bots.example.com" |
| + if err := reg.Upsert(registry.Route{Host: host, Kind: "instance", Socket: sock, Hibernate: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt, err := newTestRouter(reg, a) |
| + if err != nil { |
| + t.Fatalf("new router: %v", err) |
| + } |
| + front := httptest.NewServer(rt) |
| + t.Cleanup(front.Close) |
| + |
| + markedGet := func() int { |
| + req, _ := http.NewRequest("GET", front.URL+"/", nil) |
| + req.Host = host |
| + req.Header.Set(SidecarHeader, "1") |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("marked request: %v", err) |
| + } |
| + defer resp.Body.Close() |
| + io.Copy(io.Discard, resp.Body) |
| + return resp.StatusCode |
| + } |
| + |
| + // A member request wakes the host. |
| + if body, code := proxyGet(t, front.URL, host); code != 200 || body != "ok" { |
| + t.Fatalf("member wake: got %d %q", code, body) |
| + } |
| + // Marked requests are served while awake… |
| + if code := markedGet(); code != 200 { |
| + t.Fatalf("marked request while awake: %d, want 200", code) |
| + } |
| + // …but keep stamping nothing: sidecar traffic riding through the whole |
| + // idle window must not delay the sleep. |
| + clk.advance(6 * time.Second) |
| + if code := markedGet(); code != 200 { |
| + t.Fatalf("marked request mid-window: %d, want 200", code) |
| + } |
| + clk.advance(5 * time.Second) // 11s since the member request |
| + a.sweepOnce() |
| + waitFor(t, "hibernation beneath sidecar traffic", func() bool { return a.entryCount() == 0 }) |
| + |
| + // Asleep: marked requests are refused and wake NOTHING — this is also the |
| + // outsider-spoof case, and it's harmless. |
| + if code := markedGet(); code != 503 { |
| + t.Fatalf("marked request while asleep: %d, want 503", code) |
| + } |
| + if a.entryCount() != 0 { |
| + t.Fatal("a marked request created an entry (a wake) for a sleeping host") |
| + } |
| + if _, _, starts := b.counts(); starts != 1 { |
| + t.Fatalf("starts = %d after marked requests, want 1 (no sidecar-triggered wake)", starts) |
| + } |
| + // An unmarked request still wakes it, and the marker never reached the |
| + // backend on any request. |
| + if _, code := proxyGet(t, front.URL, host); code != 200 { |
| + t.Fatalf("member re-wake: %d, want 200", code) |
| + } |
| + if _, _, starts := b.counts(); starts != 2 { |
| + t.Fatalf("starts = %d after member re-wake, want 2", starts) |
| + } |
| + if leaked.Load() { |
| + t.Fatal("the sidecar marker leaked through the proxy to the backend") |
| + } |
| +} |
| + |
| +func TestConfigFromEnv(t *testing.T) { |
| + t.Setenv("CARLOS_HIBERNATE_BUCKET", "") |
| + if cfg, err := ConfigFromEnv(); err != nil || cfg != nil { |
| + t.Fatalf("unset bucket: got %+v, %v; want nil, nil (feature off)", cfg, err) |
| + } |
| + |
| + t.Setenv("CARLOS_HIBERNATE_BUCKET", "b") |
| + t.Setenv("CARLOS_HIBERNATE_REGION", "eu-west-1") |
| + t.Setenv("CARLOS_HIBERNATE_PREFIX", "/instances/") |
| + t.Setenv("CARLOS_HIBERNATE_IDLE", "90s") |
| + cfg, err := ConfigFromEnv() |
| + if err != nil { |
| + t.Fatalf("config: %v", err) |
| + } |
| + if cfg.Bucket != "b" || cfg.Region != "eu-west-1" || cfg.Prefix != "instances" || cfg.Idle != 90*time.Second { |
| + t.Fatalf("got %+v", cfg) |
| + } |
| + if got := cfg.hibernatePrefix("x.example.com"); got != "instances/x.example.com" { |
| + t.Fatalf("prefix: %q", got) |
| + } |
| + |
| + t.Setenv("CARLOS_HIBERNATE_IDLE", "") |
| + cfg, err = ConfigFromEnv() |
| + if err != nil || cfg.Idle != 15*time.Minute { |
| + t.Fatalf("default idle: %+v, %v", cfg, err) |
| + } |
| + |
| + t.Setenv("CARLOS_HIBERNATE_IDLE", "banana") |
| + if _, err := ConfigFromEnv(); err == nil { |
| + t.Fatal("bad idle duration should error, not default silently") |
| + } |
| +} |
| + |
| +// --- ExecBackend ------------------------------------------------------------- |
| + |
| +func testExecBackend(t *testing.T) *ExecBackend { |
| + t.Helper() |
| + return &ExecBackend{ |
| + Cfg: Config{Bucket: "test-bucket", Idle: time.Minute}, |
| + OptDir: t.TempDir(), |
| + DataDir: t.TempDir(), |
| + } |
| +} |
| + |
| +// stageLive materializes <OptDir>/live/<host> → <OptDir>/versions/<ver>/app, |
| +// the layout the adopt pass maintains in production. |
| +func stageLive(t *testing.T, b *ExecBackend, host, ver string) { |
| + t.Helper() |
| + vdir := filepath.Join(b.OptDir, "versions", ver) |
| + if err := os.MkdirAll(vdir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + bin := filepath.Join(vdir, "app") |
| + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(filepath.Join(b.OptDir, "live"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + os.Remove(b.liveLink(host)) |
| + if err := os.Symlink(bin, b.liveLink(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// Already-materialized: the common case for every existing instance — the |
| +// Materialize hook is never invoked. |
| +func TestExecBackendLiveLinkAlreadyMaterialized(t *testing.T) { |
| + b := testExecBackend(t) |
| + stageLive(t, b, "existing.example.com", "v1") |
| + called := false |
| + b.Materialize = func(context.Context, string) error { called = true; return nil } |
| + if err := b.ensureLiveLink(context.Background(), "existing.example.com"); err != nil { |
| + t.Fatalf("ensureLiveLink: %v", err) |
| + } |
| + if called { |
| + t.Error("Materialize was called for an already-materialized host") |
| + } |
| +} |
| + |
| +// Missing symlink + Materialize configured: routed through the hook, and the |
| +// hook's result is honored. |
| +func TestExecBackendLiveLinkUsesMaterializeWhenConfigured(t *testing.T) { |
| + b := testExecBackend(t) |
| + var gotHost string |
| + b.Materialize = func(_ context.Context, host string) error { |
| + gotHost = host |
| + stageLive(t, b, host, "v1") |
| + return nil |
| + } |
| + if err := b.ensureLiveLink(context.Background(), "seeded.example.com"); err != nil { |
| + t.Fatalf("ensureLiveLink: %v", err) |
| + } |
| + if gotHost != "seeded.example.com" { |
| + t.Fatalf("Materialize called with %q, want seeded.example.com", gotHost) |
| + } |
| + if _, err := os.Stat(b.liveLink("seeded.example.com")); err != nil { |
| + t.Fatalf("live symlink was not created via Materialize: %v", err) |
| + } |
| +} |
| + |
| +// A Materialize failure must propagate — the exact live symptom found |
| +// 2026-07-13 in Eleven (a 503 "instance failed to wake"), never silently |
| +// swallowed into "looks fine, wakes anyway." |
| +func TestExecBackendLiveLinkPropagatesMaterializeError(t *testing.T) { |
| + b := testExecBackend(t) |
| + wantErr := fmt.Errorf("controld materialize seeded.example.com: HTTP 502") |
| + b.Materialize = func(context.Context, string) error { return wantErr } |
| + err := b.ensureLiveLink(context.Background(), "seeded.example.com") |
| + if err == nil { |
| + t.Fatal("expected an error, got nil") |
| + } |
| + if !errors.Is(err, wantErr) { |
| + t.Fatalf("got %v, want it to wrap %v", err, wantErr) |
| + } |
| + if _, statErr := os.Stat(b.liveLink("seeded.example.com")); statErr == nil { |
| + t.Fatal("live symlink should not exist after a failed Materialize") |
| + } |
| +} |
| + |
| +// No hook and no symlink: an error, loudly. (Eleven self-staged its own |
| +// binary here — instance and control binary were one program; a carlos app is |
| +// a separate binary from the release store, so there is nothing to invent.) |
| +func TestExecBackendLiveLinkMissingWithoutMaterializeFails(t *testing.T) { |
| + b := testExecBackend(t) |
| + if err := b.ensureLiveLink(context.Background(), "ghost.example.com"); err == nil { |
| + t.Fatal("expected an error for a missing live symlink with no Materialize hook") |
| + } |
| +} |
| + |
| +func TestExecBackendLiveVersion(t *testing.T) { |
| + b := testExecBackend(t) |
| + stageLive(t, b, "v.example.com", "abc1234") |
| + ver, err := b.LiveVersion("v.example.com") |
| + if err != nil || ver != "abc1234" { |
| + t.Fatalf("LiveVersion = %q, %v; want abc1234", ver, err) |
| + } |
| + // No symlink at all: "" with no error — version management not set up yet. |
| + ver, err = b.LiveVersion("none.example.com") |
| + if err != nil || ver != "" { |
| + t.Fatalf("LiveVersion(no link) = %q, %v; want \"\", nil", ver, err) |
| + } |
| +} |
| + |
| +// childEnv strips every CARLOS_* var from the inherited environment (the |
| +// edge's own config must never leak into a child) and then layers the env |
| +// file on top — including that file's own CARLOS_* entries. |
| +func TestChildEnvStripsCarlosVars(t *testing.T) { |
| + t.Setenv("CARLOS_HIBERNATE_BUCKET", "leak-me-not") |
| + t.Setenv("CARLOS_TEST_HARMLESS", "also-me") |
| + t.Setenv("UNRELATED_VAR", "keep") |
| + env := childEnv(map[string]string{"CARLOS_ADMIN_TOKEN": "tok", "FOO": "bar"}) |
| + got := map[string]string{} |
| + for _, kv := range env { |
| + if k, v, ok := splitEnv(kv); ok { |
| + got[k] = v |
| + } |
| + } |
| + if _, leaked := got["CARLOS_HIBERNATE_BUCKET"]; leaked { |
| + t.Error("inherited CARLOS_HIBERNATE_BUCKET leaked into the child env") |
| + } |
| + if _, leaked := got["CARLOS_TEST_HARMLESS"]; leaked { |
| + t.Error("inherited CARLOS_TEST_HARMLESS leaked into the child env") |
| + } |
| + if got["UNRELATED_VAR"] != "keep" { |
| + t.Error("non-CARLOS inherited var was stripped") |
| + } |
| + if got["CARLOS_ADMIN_TOKEN"] != "tok" || got["FOO"] != "bar" { |
| + t.Errorf("env file vars not layered: %v", got) |
| + } |
| +} |
| + |
| +func splitEnv(kv string) (k, v string, ok bool) { |
| + for i := 0; i < len(kv); i++ { |
| + if kv[i] == '=' { |
| + return kv[:i], kv[i+1:], true |
| + } |
| + } |
| + return "", "", false |
| +} |
| + |
| +// The sleep-time next-due probe: GET /api/next-due over the instance's unix |
| +// socket, authorized with the CARLOS_ADMIN_TOKEN from its env file. |
| +func TestExecBackendNextDue(t *testing.T) { |
| + b := testExecBackend(t) |
| + sock := tmpSock(t) |
| + ln, err := net.Listen("unix", sock) |
| + if err != nil { |
| + t.Fatalf("listen unix: %v", err) |
| + } |
| + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/api/next-due" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if r.Header.Get("Authorization") != "Bearer tok" { |
| + http.Error(w, "forbidden", http.StatusForbidden) |
| + return |
| + } |
| + io.WriteString(w, `{"due": 42}`) |
| + })} |
| + go srv.Serve(ln) |
| + t.Cleanup(func() { srv.Close() }) |
| + |
| + host := "due.example.com" |
| + rt := registry.Route{Host: host, Kind: "instance", Socket: sock, Hibernate: true} |
| + |
| + // No env file at all: the probe fails (the state machine records 0). |
| + if _, err := b.NextDue(context.Background(), rt); err == nil { |
| + t.Fatal("NextDue with no env file should error") |
| + } |
| + // Env file without a token: same. |
| + envPath := filepath.Join(b.DataDir, host+".env") |
| + if err := os.WriteFile(envPath, []byte("# no token here\nFOO=bar\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := b.NextDue(context.Background(), rt); err == nil { |
| + t.Fatal("NextDue without CARLOS_ADMIN_TOKEN should error") |
| + } |
| + // With the token: the instance's answer comes back. |
| + if err := os.WriteFile(envPath, []byte("CARLOS_ADMIN_TOKEN=tok\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + due, err := b.NextDue(context.Background(), rt) |
| + if err != nil || due != 42 { |
| + t.Fatalf("NextDue = %d, %v; want 42", due, err) |
| + } |
| +} |
| + |
| +// HasSidecar is derived from the filesystem — the /data/<host>-sidecar.env |
| +// convention — no registry column to drift from what's actually staged. |
| +func TestExecBackendHasSidecar(t *testing.T) { |
| + b := testExecBackend(t) |
| + if b.HasSidecar("plain.example.com") { |
| + t.Fatal("HasSidecar true with no env file staged") |
| + } |
| + if err := os.WriteFile(filepath.Join(b.DataDir, "bots.example.com-sidecar.env"), []byte("CARLOS_BASE=x\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !b.HasSidecar("bots.example.com") { |
| + t.Fatal("HasSidecar false with the sidecar env file staged") |
| + } |
| +} |
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| new file mode 100644 |
| index 000000000..37df59d7e |
| --- /dev/null |
| +++ b/internal/activator/backend_exec.go |
| @@ -0,0 +1,435 @@ |
| +// Ported from lchat activator.go (the s3Backend half) and the exec/env/health |
| +// helpers it leaned on from lchat control.go and versions.go. |
| +package activator |
| + |
| +import ( |
| + "bufio" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net" |
| + "net/http" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "syscall" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// spawnHealthTimeout bounds how long a spawned instance may take to bind its |
| +// socket and answer. A var so tests can shrink the failure path. |
| +var spawnHealthTimeout = 30 * time.Second |
| + |
| +// ExecBackend is the production Backend: leases + restore via the lease |
| +// package's litestream primitives, processes via exec of the same versioned |
| +// binary a systemd unit would run — `<OptDir>/live/<host>` with the |
| +// `<DataDir>/<host>.env` EnvironmentFile. |
| +type ExecBackend struct { |
| + Cfg Config |
| + // OptDir is the on-box install root: the version store and the |
| + // per-instance "live" symlinks live under it (/opt/carlos in production; |
| + // a struct field, never hardcoded, so tests point it at a temp dir). |
| + OptDir string |
| + // DataDir holds each instance's DB and env files (<host>.db, <host>.env, |
| + // <host>-sidecar.env) — /data in production. |
| + DataDir string |
| + // Materialize, when non-nil, stages a binary and points the host's live |
| + // symlink at it via an external mechanism instead of the backend doing |
| + // filesystem work itself — Eleven needed this because its router ran |
| + // sandboxed (ProtectSystem=strict) and couldn't write to /opt itself. |
| + // With it nil, a missing live symlink is an error: Eleven fell back to |
| + // staging its OWN binary (instance and control binary were one program), |
| + // but a carlos app is a separate binary from the release store — there is |
| + // nothing sensible to invent here, and the adopt pass owns the symlinks. |
| + Materialize func(ctx context.Context, host string) error |
| +} |
| + |
| +var _ Backend = (*ExecBackend)(nil) |
| + |
| +func (b *ExecBackend) liveDir() string { return filepath.Join(b.OptDir, "live") } |
| +func (b *ExecBackend) liveLink(host string) string { return filepath.Join(b.liveDir(), host) } |
| +func (b *ExecBackend) defaultDBPath(host string) string { |
| + return filepath.Join(b.DataDir, host+".db") |
| +} |
| + |
| +// LiveVersion reports the version host's live symlink currently points at, or |
| +// "" (no error) if the host hasn't been put under version management yet. |
| +// (target = <OptDir>/versions/<ver>/<app> → the parent dir's name is <ver>.) |
| +// It doubles as the Service's convergence-sweep seam — New picks it up via an |
| +// optional interface. |
| +func (b *ExecBackend) LiveVersion(host string) (string, error) { |
| + target, err := os.Readlink(b.liveLink(host)) |
| + if err != nil { |
| + if os.IsNotExist(err) { |
| + return "", nil |
| + } |
| + return "", err |
| + } |
| + return filepath.Base(filepath.Dir(target)), nil |
| +} |
| + |
| +// Acquire claims the per-host single-writer lease. A contested lease is |
| +// re-surfaced as errLeaseHeld (never a litestream type — the lease package's |
| +// *HeldError is this package's only view of it) so the edge can 503 with a |
| +// Retry-After that tracks the holder's expiry. |
| +func (b *ExecBackend) Acquire(ctx context.Context, host string) (Lease, error) { |
| + held, err := lease.Acquire(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host), b.Cfg.Region) |
| + if err != nil { |
| + var he *lease.HeldError |
| + if errors.As(err, &he) { |
| + retry := time.Until(he.ExpiresAt) |
| + if retry < time.Second { |
| + retry = time.Second |
| + } |
| + return nil, &errLeaseHeld{holder: he.Owner, retryAfter: retry} |
| + } |
| + return nil, err |
| + } |
| + return &execLease{held: held, defaultDB: b.defaultDBPath(host)}, nil |
| +} |
| + |
| +// execLease adapts *lease.Held to the Lease seam. |
| +type execLease struct { |
| + held *lease.Held |
| + defaultDB string // used when the route carries no DBPath |
| +} |
| + |
| +func (l *execLease) Replicate(ctx context.Context, dbPath string, syncInterval time.Duration) (Rep, error) { |
| + if dbPath == "" { |
| + dbPath = l.defaultDB |
| + } |
| + rep, err := l.held.Replicate(ctx, dbPath, syncInterval) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return rep, nil |
| +} |
| + |
| +func (l *execLease) Release(ctx context.Context) error { return l.held.Release(ctx) } |
| + |
| +func (b *ExecBackend) Restore(ctx context.Context, host, dbPath string) error { |
| + if dbPath == "" { |
| + dbPath = b.defaultDBPath(host) |
| + } |
| + if _, err := os.Stat(dbPath); err == nil { |
| + return nil // warm cache — see doWake |
| + } else if !os.IsNotExist(err) { |
| + return err |
| + } |
| + // The DB is gone, so any leftover litestream sidecar state (meta dirs, |
| + // WAL/SHM) describes a database that no longer exists — litestream's own |
| + // recovery guidance is to delete it. Clear it so the restored copy starts |
| + // a clean lineage. Two meta dirs: litestream's default suffix ("-litestream", |
| + // its MetaDirSuffix — spelled out because this package never imports |
| + // litestream) as any box-wide backup replicator would use, and the lease |
| + // package's activator-scoped ".activator-<db>-litestream". |
| + for _, p := range []string{dbPath + "-wal", dbPath + "-shm"} { |
| + _ = os.Remove(p) |
| + } |
| + _ = os.RemoveAll(dbPath + "-litestream") |
| + _ = os.RemoveAll(filepath.Join(filepath.Dir(dbPath), ".activator-"+filepath.Base(dbPath)+"-litestream")) |
| + |
| + restored, err := lease.Restore(ctx, dbPath, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host), b.Cfg.Region) |
| + if err != nil { |
| + return err |
| + } |
| + if restored { |
| + log.Printf("activator: restored %s from s3://%s/%s", host, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)) |
| + } else { |
| + // Never replicated: a brand-new instance boots fresh and creates its |
| + // own DB — the register-only create path (provisioning as a metadata |
| + // write) landing on its first wake. |
| + log.Printf("activator: no replica for %s in s3://%s/%s — booting fresh", host, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)) |
| + } |
| + return nil |
| +} |
| + |
| +// NextDue asks the (still-running) instance for its earliest pending scheduled |
| +// delivery over its socket. The endpoint is admin-gated like every instance |
| +// data endpoint, so present the instance's own admin token — read from the |
| +// same env file the spawn path hands the child (the activator holds it either |
| +// way). |
| +func (b *ExecBackend) NextDue(ctx context.Context, rt registry.Route) (int64, error) { |
| + fileEnv, err := readEnvFile(filepath.Join(b.DataDir, rt.Host+".env")) |
| + if err != nil { |
| + return 0, fmt.Errorf("read env file: %w", err) |
| + } |
| + token := fileEnv["CARLOS_ADMIN_TOKEN"] |
| + if token == "" { |
| + return 0, errors.New("no CARLOS_ADMIN_TOKEN in env file") |
| + } |
| + req, err := http.NewRequestWithContext(ctx, "GET", "http://carlos/api/next-due", nil) |
| + if err != nil { |
| + return 0, err |
| + } |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + resp, err := socketClient(rt.Socket).Do(req) |
| + if err != nil { |
| + return 0, err |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + return 0, fmt.Errorf("GET /api/next-due: HTTP %d", resp.StatusCode) |
| + } |
| + var body struct { |
| + Due int64 `json:"due"` |
| + } |
| + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<12)).Decode(&body); err != nil { |
| + return 0, fmt.Errorf("decode /api/next-due: %w", err) |
| + } |
| + return body.Due, nil |
| +} |
| + |
| +// ensureLiveLink verifies host's live symlink (<OptDir>/live/<host> → a staged |
| +// version) exists before the exec, routing a missing one through the |
| +// Materialize hook when configured. A materialize failure (e.g. the staging |
| +// mechanism unreachable) must propagate — the exact live symptom found |
| +// 2026-07-13 in Eleven (a 503 "instance failed to wake"), never silently |
| +// swallowed into "looks fine, wakes anyway." |
| +func (b *ExecBackend) ensureLiveLink(ctx context.Context, host string) error { |
| + bin := b.liveLink(host) |
| + if _, err := os.Stat(bin); err == nil { |
| + return nil |
| + } |
| + if b.Materialize != nil { |
| + if err := b.Materialize(ctx, host); err != nil { |
| + return fmt.Errorf("materialize live symlink for %s: %w", host, err) |
| + } |
| + return nil |
| + } |
| + return fmt.Errorf("no live binary for %s (%s missing and no Materialize hook)", host, bin) |
| +} |
| + |
| +func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, string, error) { |
| + host := rt.Host |
| + dbPath := rt.DBPath |
| + if dbPath == "" { |
| + dbPath = b.defaultDBPath(host) |
| + } |
| + |
| + // Exec exactly what a systemd unit would: the per-instance live symlink |
| + // (<OptDir>/live/<host> → a staged version), same --socket/--db flags, |
| + // same EnvironmentFile. |
| + bin := b.liveLink(host) |
| + if err := b.ensureLiveLink(ctx, host); err != nil { |
| + return nil, "", err |
| + } |
| + |
| + // The env file is optional, like the unit's "-" prefix. |
| + fileEnv, err := readEnvFile(filepath.Join(b.DataDir, host+".env")) |
| + if err != nil && !os.IsNotExist(err) { |
| + return nil, "", fmt.Errorf("read env file: %w", err) |
| + } |
| + |
| + cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath) |
| + cmd.Env = childEnv(fileEnv) |
| + // The child logs through the agent's own stderr/stdout (journald under |
| + // systemd); there's no per-instance unit to own a log stream anymore. |
| + cmd.Stdout = os.Stdout |
| + cmd.Stderr = os.Stderr |
| + // Record the build we're about to exec — read the symlink immediately before |
| + // Start() so it names the version the kernel will resolve. Reading it here, |
| + // rather than back in wake() after the health wait, closes a real window: a |
| + // concurrent ship's repoint during the up-to-30s wait would otherwise make |
| + // us record the NEW target while this child runs the OLD binary, and the |
| + // sweep would then think it's already converged and never cycle it. The |
| + // residual sub-exec race (a repoint between this read and Start) is in the |
| + // safe direction — we'd record the older version, so the sweep cycles once |
| + // too often rather than never. Empty on a read error: the sweep won't cycle. |
| + spawnVer, _ := b.LiveVersion(host) |
| + if err := cmd.Start(); err != nil { |
| + return nil, "", err |
| + } |
| + |
| + p := &childProc{cmd: cmd, done: make(chan struct{})} |
| + go func() { |
| + p.err = cmd.Wait() |
| + close(p.done) |
| + }() |
| + |
| + if err := waitHealthyCtx(ctx, rt.Socket, spawnHealthTimeout); err != nil { |
| + stopCtx, cancel := context.WithTimeout(context.Background(), actStopTimeout) |
| + defer cancel() |
| + _ = p.Stop(stopCtx) |
| + return nil, "", err |
| + } |
| + return p, spawnVer, nil |
| +} |
| + |
| +// childEnv approximates a systemd unit's environment from inside the agent: |
| +// inherit the process env but strip every CARLOS_* var first, so the agent's |
| +// own config (hibernation, registry, tokens) can never leak into a child; |
| +// then layer the child's EnvironmentFile on top. A nil fileEnv (no env file, |
| +// like the unit's "-" prefix) yields just the cleaned inherit. |
| +func childEnv(fileEnv map[string]string) []string { |
| + env := make([]string, 0, 32) |
| + for _, kv := range os.Environ() { |
| + if strings.HasPrefix(kv, "CARLOS_") { |
| + continue |
| + } |
| + env = append(env, kv) |
| + } |
| + for k, v := range fileEnv { |
| + env = append(env, k+"="+v) |
| + } |
| + return env |
| +} |
| + |
| +// sidecarEnvPath: the host's sidecar EnvironmentFile, |
| +// <DataDir>/<host>-sidecar.env (Eleven's <host>-bot.env, renamed). |
| +func (b *ExecBackend) sidecarEnvPath(host string) string { |
| + return filepath.Join(b.DataDir, host+"-sidecar.env") |
| +} |
| + |
| +// HasSidecar: a host has a sidecar iff its sidecar EnvironmentFile exists. |
| +// Derived from the filesystem on purpose, the way systemd units would do it: |
| +// no registry column to drift from what's actually staged. |
| +func (b *ExecBackend) HasSidecar(host string) bool { |
| + _, err := os.Stat(b.sidecarEnvPath(host)) |
| + return err == nil |
| +} |
| + |
| +// StartSidecar execs the host's live symlink with `sidecar run` under the |
| +// sidecar's EnvironmentFile, CARLOS_*-clean like the instance spawn. No |
| +// health wait — the sidecar is a pure outbound client with no socket to |
| +// probe; its own connect/backoff loop absorbs a slow instance. The live |
| +// symlink exists by now: the instance spawn checked it. The ctx is unused (an |
| +// exec doesn't block); it's here for the backend seam's symmetry. |
| +func (b *ExecBackend) StartSidecar(_ context.Context, rt registry.Route) (Proc, error) { |
| + fileEnv, err := readEnvFile(b.sidecarEnvPath(rt.Host)) |
| + if err != nil { |
| + return nil, fmt.Errorf("read sidecar env: %w", err) |
| + } |
| + cmd := exec.Command(b.liveLink(rt.Host), "sidecar", "run") |
| + cmd.Env = childEnv(fileEnv) |
| + // Like the instance child: logs through the agent's own streams (journald |
| + // under systemd) — no per-host unit owns a log stream anymore. |
| + cmd.Stdout = os.Stdout |
| + cmd.Stderr = os.Stderr |
| + if err := cmd.Start(); err != nil { |
| + return nil, err |
| + } |
| + p := &childProc{cmd: cmd, done: make(chan struct{})} |
| + go func() { |
| + p.err = cmd.Wait() |
| + close(p.done) |
| + }() |
| + return p, nil |
| +} |
| + |
| +// socketClient returns an HTTP client that dials an instance's unix socket |
| +// (request URLs use the placeholder host http://carlos). |
| +func socketClient(socket string) *http.Client { |
| + return &http.Client{ |
| + Timeout: 3 * time.Second, |
| + Transport: &http.Transport{ |
| + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return (&net.Dialer{}).DialContext(ctx, "unix", socket) |
| + }, |
| + }, |
| + } |
| +} |
| + |
| +// waitHealthy blocks until the instance answers an HTTP request on its socket, |
| +// or the timeout elapses. It retries, so it's robust to the restart window |
| +// (socket briefly absent, or a stale socket left by an abruptly-killed old |
| +// process) — and a broken binary that never becomes healthy fails the wake. |
| +func waitHealthy(socket string, timeout time.Duration) error { |
| + deadline := time.Now().Add(timeout) |
| + client := socketClient(socket) |
| + var last error |
| + for time.Now().Before(deadline) { |
| + resp, err := client.Get("http://carlos/") |
| + if err == nil { |
| + resp.Body.Close() |
| + if resp.StatusCode < 500 { |
| + return nil |
| + } |
| + last = fmt.Errorf("unhealthy: HTTP %d", resp.StatusCode) |
| + } else { |
| + last = err |
| + } |
| + time.Sleep(150 * time.Millisecond) |
| + } |
| + return fmt.Errorf("instance not healthy within %s: %v", timeout, last) |
| +} |
| + |
| +// waitHealthyCtx is waitHealthy bounded by ctx as well as its own timeout, so |
| +// an aborted wake doesn't keep probing a socket nobody wants anymore. |
| +func waitHealthyCtx(ctx context.Context, socket string, timeout time.Duration) error { |
| + done := make(chan error, 1) |
| + go func() { done <- waitHealthy(socket, timeout) }() |
| + select { |
| + case err := <-done: |
| + return err |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + } |
| +} |
| + |
| +// readEnvFile parses a KEY=value EnvironmentFile (blank lines and #comments |
| +// ignored). |
| +func readEnvFile(path string) (map[string]string, error) { |
| + f, err := os.Open(path) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer f.Close() |
| + m := map[string]string{} |
| + sc := bufio.NewScanner(f) |
| + for sc.Scan() { |
| + line := strings.TrimSpace(sc.Text()) |
| + if line == "" || strings.HasPrefix(line, "#") { |
| + continue |
| + } |
| + if k, v, ok := strings.Cut(line, "="); ok { |
| + m[strings.TrimSpace(k)] = strings.TrimSpace(v) |
| + } |
| + } |
| + return m, sc.Err() |
| +} |
| + |
| +// childProc is a spawned child: SIGTERM starts the instance's own graceful |
| +// drain; SIGKILL only if it overstays. |
| +type childProc struct { |
| + cmd *exec.Cmd |
| + done chan struct{} |
| + err error |
| +} |
| + |
| +func (p *childProc) Exited() bool { |
| + select { |
| + case <-p.done: |
| + return true |
| + default: |
| + return false |
| + } |
| +} |
| + |
| +func (p *childProc) Stop(ctx context.Context) error { |
| + if p.Exited() { |
| + return nil |
| + } |
| + if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil { |
| + if errors.Is(err, os.ErrProcessDone) { // lost the race with its own exit |
| + <-p.done |
| + return nil |
| + } |
| + return err |
| + } |
| + select { |
| + case <-p.done: |
| + return nil |
| + case <-ctx.Done(): |
| + _ = p.cmd.Process.Kill() |
| + <-p.done |
| + return fmt.Errorf("instance did not exit on SIGTERM; killed") |
| + } |
| +} |
diff --git a/internal/ops/litestream.go b/internal/ops/litestream.go| new file mode 100644 |
| index 000000000..590ef6825 |
| --- /dev/null |
| +++ b/internal/ops/litestream.go |
| @@ -0,0 +1,210 @@ |
| +// litestream — off-box replication of the fleet's SQLite files, and the daily |
| +// proof that the replicas restore. Two verbs, both run ON the box (titogo's |
| +// operator-side SSH orchestration deliberately did not come along — units and |
| +// credentials are provisioned by bootstrap; this is only the box-side half): |
| +// |
| +// carlos ops litestream (re)generate /etc/litestream.yml from the |
| +// registry + data dir; prints "changed" or |
| +// "unchanged" so the calling timer/unit knows |
| +// whether a replicator restart is due |
| +// carlos ops restore-verify restore every configured database from S3 and |
| +// integrity-check it |
| +// |
| +// The config is always generated, never hand-maintained: `carlos add`/`remove` |
| +// change the database set, and re-running the verb is the one way the |
| +// replication set follows. Credentials live only in the unit's EnvironmentFile |
| +// (0600) — this code never sees or prints them. |
| +// |
| +// Ported from titogo internal/ops/litestream.go (config generator, round-trip |
| +// parser, restore-verify), plus Eleven deploy/fleet-backup.sh's hibernation |
| +// exclusion. |
| +package ops |
| + |
| +import ( |
| + "bytes" |
| + "database/sql" |
| + "fmt" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + |
| + _ "modernc.org/sqlite" |
| +) |
| + |
| +// litestreamVersion is the pinned release provisioned on the box when the |
| +// binary is missing. Bump deliberately; the restore path is the thing a |
| +// version change can silently break, and restore-verify is what catches it. |
| +const litestreamVersion = "0.5.15" |
| + |
| +// retryDelay is the pause before restore-verify's one retry; a var so tests |
| +// can shrink it. |
| +var retryDelay = 10 * time.Second |
| + |
| +// LitestreamConfig renders the generated /etc/litestream.yml. Deliberately |
| +// minimal — path + replica URL + region only — so it can't rot against |
| +// litestream's config surface; tuning belongs in flags on the unit, not here. |
| +// Paths are sorted so re-runs with an unchanged fleet produce a byte-identical |
| +// file (the `litestream` verb's changed/unchanged report depends on it). |
| +func LitestreamConfig(dbPaths []string, bucket, prefix, region string) string { |
| + sorted := append([]string(nil), dbPaths...) |
| + sort.Strings(sorted) |
| + var b strings.Builder |
| + b.WriteString("# GENERATED by `carlos ops litestream` — do not hand-edit; re-run the verb\n") |
| + b.WriteString("# after `carlos add`/`remove` so the replication set follows the fleet.\n") |
| + b.WriteString("dbs:\n") |
| + for _, p := range sorted { |
| + fmt.Fprintf(&b, " - path: %s\n", p) |
| + b.WriteString(" replicas:\n") |
| + fmt.Fprintf(&b, " - url: s3://%s/%s/%s\n", bucket, prefix, filepath.Base(p)) |
| + if region != "" { |
| + fmt.Fprintf(&b, " region: %s\n", region) |
| + } |
| + } |
| + return b.String() |
| +} |
| + |
| +// ParseLitestreamDBs recovers the database paths from a generated config. |
| +// It reads only the exact shape LitestreamConfig writes (the round-trip is |
| +// pinned by TestConfigRoundTrip) — it is not a YAML parser. |
| +func ParseLitestreamDBs(cfg string) []string { |
| + var dbs []string |
| + for _, line := range strings.Split(cfg, "\n") { |
| + if p, ok := strings.CutPrefix(line, " - path: "); ok { |
| + dbs = append(dbs, strings.TrimSpace(p)) |
| + } |
| + } |
| + return dbs |
| +} |
| + |
| +// FleetDBs lists the non-empty *.db files under dataDir that this replicator |
| +// owns. Hibernating routes' databases (matched by DBPath) are excluded: |
| +// the activator's embedded Litestream owns their replication (to the |
| +// instances bucket, under the lease) — a second replicator on the same WAL |
| +// would fight it for checkpointing and corrupt the replica. (Eleven's |
| +// fleet-backup.sh lesson, carried verbatim.) |
| +func FleetDBs(reg *registry.Registry, dataDir string) ([]string, error) { |
| + routes, err := reg.List() |
| + if err != nil { |
| + return nil, err |
| + } |
| + hibernating := map[string]bool{} |
| + for _, r := range routes { |
| + if r.Hibernate && r.DBPath != "" { |
| + hibernating[filepath.Clean(r.DBPath)] = true |
| + } |
| + } |
| + |
| + matches, err := filepath.Glob(filepath.Join(dataDir, "*.db")) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var dbs []string |
| + for _, p := range matches { |
| + fi, err := os.Stat(p) |
| + if err != nil || !fi.Mode().IsRegular() || fi.Size() == 0 { |
| + continue // gone, odd, or an empty placeholder — nothing to replicate |
| + } |
| + if hibernating[filepath.Clean(p)] { |
| + continue |
| + } |
| + dbs = append(dbs, p) |
| + } |
| + return dbs, nil |
| +} |
| + |
| +// RestoreVerify restores every database in the generated config from S3 into |
| +// tmpDir and proves each opens clean. Returns an error on any failure so the |
| +// caller (a oneshot unit) can exit non-zero where the fleet sweeps see it. |
| +func RestoreVerify(litestreamBin, cfgPath, tmpDir string) error { |
| + raw, err := os.ReadFile(cfgPath) |
| + if err != nil { |
| + return fmt.Errorf("restore-verify: %v", err) |
| + } |
| + dbs := ParseLitestreamDBs(string(raw)) |
| + if len(dbs) == 0 { |
| + return fmt.Errorf("restore-verify: no databases in %s — regenerate it: carlos ops litestream", cfgPath) |
| + } |
| + |
| + failures := 0 |
| + for _, db := range dbs { |
| + if err := restoreAndCheck(litestreamBin, cfgPath, db, tmpDir); err != nil { |
| + // One retry after a pause: a database created moments ago may not |
| + // have finished its first replication pass yet. |
| + time.Sleep(retryDelay) |
| + err = restoreAndCheck(litestreamBin, cfgPath, db, tmpDir) |
| + if err != nil { |
| + failures++ |
| + fmt.Printf("FAIL %s: %v\n", db, err) |
| + continue |
| + } |
| + } |
| + fmt.Printf("ok %s\n", db) |
| + } |
| + if failures > 0 { |
| + return fmt.Errorf("restore-verify: %d of %d replicas failed to restore clean", failures, len(dbs)) |
| + } |
| + fmt.Printf("restore-verify: all %d replicas restore clean\n", len(dbs)) |
| + return nil |
| +} |
| + |
| +// restoreAndCheck pulls one database back from its replica and runs the same |
| +// checks a disaster recovery would lean on: the file opens, integrity_check |
| +// says ok, and the schema is non-empty. |
| +func restoreAndCheck(lsBin, cfgPath, dbPath, tmpDir string) error { |
| + out := filepath.Join(tmpDir, filepath.Base(dbPath)) |
| + _ = os.Remove(out) // a retry must not trip over the failed attempt's file |
| + cmd := exec.Command(lsBin, "restore", "-config", cfgPath, "-o", out, dbPath) |
| + if b, err := cmd.CombinedOutput(); err != nil { |
| + return fmt.Errorf("litestream restore: %v: %s", err, strings.TrimSpace(string(b))) |
| + } |
| + return CheckRestoredDB(out) |
| +} |
| + |
| +// CheckRestoredDB opens a restored file read-only and proves it is a sane |
| +// SQLite database: integrity_check == ok and at least one table present. |
| +func CheckRestoredDB(path string) error { |
| + db, err := sql.Open("sqlite", "file:"+path+"?mode=ro") |
| + if err != nil { |
| + return err |
| + } |
| + defer db.Close() |
| + db.SetMaxOpenConns(1) |
| + // busy_timeout first, always (Global Constraints) — and on a garbage file |
| + // this first statement is what fails loudly instead of a later query. |
| + if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil { |
| + return fmt.Errorf("open: %v", err) |
| + } |
| + var res string |
| + if err := db.QueryRow("PRAGMA integrity_check").Scan(&res); err != nil { |
| + return fmt.Errorf("integrity_check: %v", err) |
| + } |
| + if res != "ok" { |
| + return fmt.Errorf("integrity_check: %s", res) |
| + } |
| + var tables int |
| + if err := db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table'").Scan(&tables); err != nil { |
| + return fmt.Errorf("schema count: %v", err) |
| + } |
| + if tables == 0 { |
| + return fmt.Errorf("restored database has no tables") |
| + } |
| + return nil |
| +} |
| + |
| +// writeIfChanged writes content to path only when it differs, and reports |
| +// whether it wrote. The byte-identical no-op is what lets a timer re-run the |
| +// generator freely without a restart-worthy mtime/content churn. |
| +func writeIfChanged(path, content string) (bool, error) { |
| + if old, err := os.ReadFile(path); err == nil && bytes.Equal(old, []byte(content)) { |
| + return false, nil |
| + } |
| + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { |
| + return false, err |
| + } |
| + return true, nil |
| +} |
diff --git a/internal/ops/ops.go b/internal/ops/ops.go| new file mode 100644 |
| index 000000000..bf7d6668e |
| --- /dev/null |
| +++ b/internal/ops/ops.go |
| @@ -0,0 +1,139 @@ |
| +// Package ops is the box-side operations surface — verbs a systemd timer (or |
| +// an operator's shell on the box) runs to keep replication honest. Inspired by |
| +// titogo internal/ops: every verb encodes knowledge that otherwise lives only |
| +// in prose, and prose drifts silently where a command that runs drifts loudly. |
| +package ops |
| + |
| +import ( |
| + "flag" |
| + "fmt" |
| + "log" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +const ( |
| + defaultConfigPath = "/etc/litestream.yml" |
| + defaultDataDir = "/data" |
| +) |
| + |
| +// Run dispatches `carlos ops <verb>`. |
| +func Run(args []string) { |
| + verb := "" |
| + if len(args) > 0 { |
| + verb = args[0] |
| + args = args[1:] |
| + } |
| + switch verb { |
| + case "litestream": |
| + runLitestream(args) |
| + case "restore-verify": |
| + runRestoreVerify(args) |
| + case "", "-h", "--help", "help": |
| + usage() |
| + if verb == "" { |
| + os.Exit(2) |
| + } |
| + default: |
| + fmt.Fprintf(os.Stderr, "ops: unknown verb %q\n\n", verb) |
| + usage() |
| + os.Exit(2) |
| + } |
| +} |
| + |
| +func usage() { |
| + fmt.Fprintf(os.Stderr, `carlos ops — box-side operations verbs |
| + |
| + carlos ops litestream (re)generate the fleet litestream config from the |
| + registry + data dir; prints "changed"/"unchanged" |
| + so the caller knows whether to restart replication |
| + carlos ops restore-verify restore every configured replica from S3 and |
| + integrity-check each (run by the daily timer) |
| + |
| +No verb for your task? Extend ops in a small PR — never a hand-run sequence |
| +reconstructed from docs. |
| +`) |
| +} |
| + |
| +// dataDir resolves the data directory: flag wins, then CARLOS_DATA_DIR, then |
| +// the fleet default. |
| +func dataDir(flagVal string) string { |
| + if flagVal != "" { |
| + return flagVal |
| + } |
| + if env := os.Getenv("CARLOS_DATA_DIR"); env != "" { |
| + return env |
| + } |
| + return defaultDataDir |
| +} |
| + |
| +// runLitestream — regenerate the fleet replication config. Idempotent: run it |
| +// after every `carlos add`/`remove` (or on a timer; a no-op re-run is cheap |
| +// and prints "unchanged"). Databases of hibernating routes are excluded — see |
| +// FleetDBs for Eleven's two-replicators lesson. |
| +func runLitestream(args []string) { |
| + fs := flag.NewFlagSet("ops litestream", flag.ExitOnError) |
| + regPath := fs.String("registry", "", "registry database (default <data>/registry.db)") |
| + data := fs.String("data", "", "data directory holding the fleet's *.db files (default $CARLOS_DATA_DIR or /data)") |
| + bucket := fs.String("bucket", "", "S3 bucket receiving the replicas (required)") |
| + prefix := fs.String("prefix", "", "key prefix inside the bucket for this fleet (required)") |
| + region := fs.String("region", "", "S3 region for the replica stanza") |
| + out := fs.String("out", defaultConfigPath, "config file to write") |
| + _ = fs.Parse(args) |
| + |
| + if *bucket == "" || *prefix == "" { |
| + log.Fatal("litestream: -bucket and -prefix are required") |
| + } |
| + dir := dataDir(*data) |
| + rp := *regPath |
| + if rp == "" { |
| + rp = dir + "/registry.db" |
| + } |
| + reg, err := registry.Open(rp) |
| + if err != nil { |
| + log.Fatalf("litestream: open registry %s: %v", rp, err) |
| + } |
| + dbs, err := FleetDBs(reg, dir) |
| + if err != nil { |
| + log.Fatalf("litestream: %v", err) |
| + } |
| + if len(dbs) == 0 { |
| + log.Fatalf("litestream: no non-empty *.db files under %s — wrong box?", dir) |
| + } |
| + |
| + cfg := LitestreamConfig(dbs, *bucket, *prefix, *region) |
| + wrote, err := writeIfChanged(*out, cfg) |
| + if err != nil { |
| + log.Fatalf("litestream: write %s: %v", *out, err) |
| + } |
| + fmt.Fprintf(os.Stderr, "litestream: %d databases -> s3://%s/%s/ (%s)\n", len(dbs), *bucket, *prefix, *out) |
| + // The one contract line on stdout: the calling unit restarts the |
| + // replicator on "changed" and leaves it alone on "unchanged". |
| + if wrote { |
| + fmt.Println("changed") |
| + } else { |
| + fmt.Println("unchanged") |
| + } |
| +} |
| + |
| +// runRestoreVerify — restore every database in the generated config from S3 |
| +// into a temp dir and prove each opens clean. Exits non-zero on any failure so |
| +// the oneshot unit lands in 'failed' where the fleet sweeps see it. |
| +func runRestoreVerify(args []string) { |
| + fs := flag.NewFlagSet("ops restore-verify", flag.ExitOnError) |
| + cfgPath := fs.String("config", defaultConfigPath, "generated litestream config to read the database set from") |
| + lsBin := fs.String("litestream-bin", "litestream", "litestream binary (pinned release "+litestreamVersion+")") |
| + _ = fs.Parse(args) |
| + |
| + tmp, err := os.MkdirTemp("", "carlos-restore") |
| + if err != nil { |
| + log.Fatalf("restore-verify: %v", err) |
| + } |
| + defer os.RemoveAll(tmp) |
| + |
| + if err := RestoreVerify(*lsBin, *cfgPath, tmp); err != nil { |
| + os.RemoveAll(tmp) // log.Fatal skips the defer |
| + log.Fatal(err) |
| + } |
| +} |
diff --git a/internal/ops/ops_test.go b/internal/ops/ops_test.go| new file mode 100644 |
| index 000000000..bda9689d1 |
| --- /dev/null |
| +++ b/internal/ops/ops_test.go |
| @@ -0,0 +1,144 @@ |
| +package ops |
| + |
| +import ( |
| + "database/sql" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// TestConfigGenSkipsHibernatingRoutes pins Eleven's two-replicators lesson in |
| +// executable form: a route with Hibernate=true must never appear in the fleet |
| +// litestream config, because the activator's embedded Litestream owns that |
| +// database's replication — a second replicator on the same WAL would fight it |
| +// for checkpointing. |
| +func TestConfigGenSkipsHibernatingRoutes(t *testing.T) { |
| + dir := t.TempDir() |
| + reg, err := registry.Open(filepath.Join(dir, "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + awake := filepath.Join(dir, "a.example.com.db") |
| + asleep := filepath.Join(dir, "b.example.com.db") |
| + for _, p := range []string{awake, asleep} { |
| + if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := reg.Upsert(registry.Route{Host: "a.example.com", DBPath: awake}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(registry.Route{Host: "b.example.com", DBPath: asleep, Hibernate: true}); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + dbs, err := FleetDBs(reg, dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg := LitestreamConfig(dbs, "bucket-x", "fleet", "eu-west-1") |
| + if !strings.Contains(cfg, awake) { |
| + t.Fatalf("awake route's DB missing from config:\n%s", cfg) |
| + } |
| + if strings.Contains(cfg, asleep) { |
| + t.Fatalf("hibernating route's DB must be excluded (activator owns its replication):\n%s", cfg) |
| + } |
| + // The registry itself lives in the data dir and is not anyone's hibernating |
| + // instance — it must be replicated. |
| + if !strings.Contains(cfg, filepath.Join(dir, "registry.db")) { |
| + t.Fatalf("registry.db missing from config:\n%s", cfg) |
| + } |
| + if !strings.Contains(cfg, "GENERATED") { |
| + t.Fatalf("generated-file warning missing:\n%s", cfg) |
| + } |
| + if !strings.Contains(cfg, "region: eu-west-1") { |
| + t.Fatalf("replica region missing:\n%s", cfg) |
| + } |
| +} |
| + |
| +// The round-trip is the contract that lets restore-verify read the generated |
| +// config without a YAML parser: whatever LitestreamConfig writes, |
| +// ParseLitestreamDBs must recover exactly. |
| +func TestConfigRoundTrip(t *testing.T) { |
| + in := []string{"/data/registry.db", "/data/a.example.com.db", "/data/b.example.com.db"} |
| + got := ParseLitestreamDBs(LitestreamConfig(in, "b", "p", "eu-west-1")) |
| + if len(got) != len(in) { |
| + t.Fatalf("round-trip lost databases: in %d, out %d (%v)", len(in), len(got), got) |
| + } |
| + want := map[string]bool{} |
| + for _, p := range in { |
| + want[p] = true |
| + } |
| + for _, p := range got { |
| + if !want[p] { |
| + t.Fatalf("round-trip invented path %q", p) |
| + } |
| + } |
| +} |
| + |
| +func TestParseLitestreamDBsIgnoresNoise(t *testing.T) { |
| + got := ParseLitestreamDBs("# comment\ndbs:\n - path: /data/x.db\n replicas:\n - url: s3://b/p/x.db\n region: eu-west-1\n\n") |
| + if len(got) != 1 || got[0] != "/data/x.db" { |
| + t.Fatalf("ParseLitestreamDBs = %#v, want [/data/x.db]", got) |
| + } |
| + if got := ParseLitestreamDBs(""); len(got) != 0 { |
| + t.Fatalf("ParseLitestreamDBs(\"\") = %#v, want none", got) |
| + } |
| +} |
| + |
| +// Re-running the generator over an unchanged fleet must produce a |
| +// byte-identical file — that is what lets the `litestream` verb report |
| +// "unchanged" and skip a restart-worthy config write. |
| +func TestConfigChangeDetection(t *testing.T) { |
| + dbs := []string{"/data/b.db", "/data/a.db", "/data/registry.db"} |
| + first := LitestreamConfig(dbs, "bucket", "prefix", "eu-west-1") |
| + second := LitestreamConfig([]string{"/data/registry.db", "/data/a.db", "/data/b.db"}, "bucket", "prefix", "eu-west-1") |
| + if first != second { |
| + t.Fatalf("same fleet produced different configs:\n--- first\n%s\n--- second\n%s", first, second) |
| + } |
| +} |
| + |
| +func TestCheckRestoredDBRejectsEmptySchema(t *testing.T) { |
| + dir := t.TempDir() |
| + |
| + good := filepath.Join(dir, "good.db") |
| + db, err := sql.Open("sqlite", good) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := db.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY); INSERT INTO t VALUES (1)"); err != nil { |
| + t.Fatal(err) |
| + } |
| + db.Close() |
| + if err := CheckRestoredDB(good); err != nil { |
| + t.Fatalf("CheckRestoredDB(good) = %v, want nil", err) |
| + } |
| + |
| + // A schema-less database restores "successfully" but would be useless in a |
| + // disaster — the check must refuse it. |
| + empty := filepath.Join(dir, "empty.db") |
| + db, err = sql.Open("sqlite", empty) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := db.Exec("PRAGMA user_version=1"); err != nil { // force file creation |
| + t.Fatal(err) |
| + } |
| + db.Close() |
| + if err := CheckRestoredDB(empty); err == nil { |
| + t.Fatal("CheckRestoredDB accepted a database with no tables") |
| + } |
| + |
| + // Garbage bytes must fail loudly, not pass as an open-able file. |
| + junk := filepath.Join(dir, "junk.db") |
| + if err := os.WriteFile(junk, []byte("this is not a sqlite database at all"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := CheckRestoredDB(junk); err == nil { |
| + t.Fatal("CheckRestoredDB accepted a non-SQLite file") |
| + } |
| +} |
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| new file mode 100644 |
| index 000000000..261879ec4 |
| --- /dev/null |
| +++ b/internal/release/adopt.go |
| @@ -0,0 +1,217 @@ |
| +// Adoption — one check-and-apply sweep over the registry, shaped after |
| +// Eleven updater.go's updatePass, simplified: channel pointers replace the |
| +// GitHub feed, the Store replaces HTTP downloads, and per-host update prefs |
| +// are the registry row itself (Channel, Hibernate). |
| +package release |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "runtime" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// adoptPlatform picks which manifest artifact a box installs — a var so |
| +// tests can exercise resolution without faking runtime.GOOS/GOARCH. |
| +var adoptPlatform = runtime.GOOS + "-" + runtime.GOARCH |
| + |
| +// Pass is one full adoption sweep: group instance routes by (App, Channel), |
| +// resolve each channel pointer once, verify signatures and artifact sha256s, |
| +// download/stage missing versions, and repoint live/<host> symlinks. |
| +// |
| +// Die-free by design: per-host and per-channel problems log and continue — |
| +// an unreachable channel or one broken host must not strand the rest of the |
| +// fleet unchecked. Only foundation failures (registry unreadable, bake clock |
| +// unwritable) fail the pass. |
| +// |
| +// restart cycles an awake host's process onto the new symlink target (the |
| +// activator's proc converge); nil means symlink-only for every host. Hosts |
| +// whose route has Hibernate=true always get symlink-only and are NEVER |
| +// woken: an hourly wake would defeat hibernation, and the next wake execs |
| +// the new binary anyway since the spawn resolves live/<host> at exec time. |
| +// The trade (from Eleven): no health check for a sleeper — a bad build |
| +// surfaces at wake, recovered by Rollback, which repoints back. Hibernating |
| +// hosts are processed AFTER every awake host, so an awake host discovers a |
| +// boot-broken build (and poisons it) before any sleeper is moved onto it. |
| +func Pass(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, optDir string, restart func(host string) error) error { |
| + routes, err := reg.List() |
| + if err != nil { |
| + return err |
| + } |
| + |
| + // Resolve every distinct (App, Channel) once. Verification failures kill |
| + // the group (nil entry), not the pass. |
| + type groupKey struct{ app, channel string } |
| + pointers := map[groupKey]*ChannelPointer{} |
| + for _, rt := range routes { |
| + if !adoptable(rt) { |
| + continue |
| + } |
| + k := groupKey{rt.App, routeChannel(rt)} |
| + if _, done := pointers[k]; done { |
| + continue |
| + } |
| + ptr, err := Channel(ctx, s, pub, k.app, k.channel) |
| + if err != nil { |
| + pointers[k] = nil // resolved-and-failed: don't retry per host |
| + if errors.Is(err, os.ErrNotExist) { |
| + fmt.Printf(" %s/%s: channel has no release yet\n", k.app, k.channel) |
| + } else { |
| + fmt.Printf(" %s/%s: channel pointer rejected: %v\n", k.app, k.channel, err) |
| + } |
| + continue |
| + } |
| + pointers[k] = ptr |
| + } |
| + |
| + // Tick the bake clock: record every channel head this box hasn't seen at |
| + // that head before, so the supply-chain window is measured from the box's |
| + // own first sighting, never a feed timestamp (bake.go). The clock only |
| + // ever ticks for pointers that verified above. |
| + now := time.Now() |
| + bake := LoadFirstSeen(FirstSeenPath(optDir)) |
| + changed := false |
| + for k, ptr := range pointers { |
| + if ptr != nil && bake.Observe(k.app, k.channel, ptr.Version, now) { |
| + changed = true |
| + } |
| + } |
| + if changed { |
| + if err := bake.Save(); err != nil { |
| + return fmt.Errorf("record bake clock: %v", err) |
| + } |
| + } |
| + |
| + // A version that failed its restart on one host isn't tried on the next |
| + // this pass — one bad build shouldn't bounce every host in turn. |
| + poisoned := map[string]bool{} |
| + |
| + // Awake hosts first, hibernating second: repoints have no health gate, so |
| + // let an awake host discover (and poison) a broken build before any |
| + // sleeper is moved onto it. |
| + var hibernating []registry.Route |
| + for _, rt := range routes { |
| + if !adoptable(rt) { |
| + continue |
| + } |
| + if rt.Hibernate { |
| + hibernating = append(hibernating, rt) |
| + continue |
| + } |
| + ptr := pointers[groupKey{rt.App, routeChannel(rt)}] |
| + adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, restart, now) |
| + } |
| + for _, rt := range hibernating { |
| + ptr := pointers[groupKey{rt.App, routeChannel(rt)}] |
| + // restart=nil: hibernating hosts get the pure repoint — never woken. |
| + adoptHost(ctx, reg, s, optDir, bake, poisoned, rt, ptr, nil, now) |
| + } |
| + return nil |
| +} |
| + |
| +// adoptable filters the routes a Pass moves: lifecycle-managed instances of |
| +// a known app that are ready (an app-less or mid-provision route has nothing |
| +// to adopt or must not be disturbed). |
| +func adoptable(rt registry.Route) bool { |
| + return rt.Kind == "instance" && rt.App != "" && (rt.Status == "" || rt.Status == "ready") |
| +} |
| + |
| +// routeChannel is the channel a route follows; "" means stable. |
| +func routeChannel(rt registry.Route) string { |
| + if rt.Channel == "" { |
| + return "stable" |
| + } |
| + return rt.Channel |
| +} |
| + |
| +// adoptHost converges one host onto its channel head ptr (nil = the group's |
| +// pointer didn't resolve; skip quietly, the group phase already logged). |
| +// restart==nil is the symlink-only path (hibernating hosts, or a caller with |
| +// no proc manager): repoint and record, never wake, never health-gate. |
| +func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir string, bake *FirstSeen, poisoned map[string]bool, rt registry.Route, ptr *ChannelPointer, restart func(host string) error, now time.Time) { |
| + if ptr == nil { |
| + return |
| + } |
| + channel := routeChannel(rt) |
| + ver := ptr.Version |
| + cur, err := LiveVersion(optDir, rt.Host) |
| + if err != nil { |
| + fmt.Printf(" %s: read live link: %v — skipped\n", rt.Host, err) |
| + return |
| + } |
| + if cur == ver { |
| + return // up to date — stay quiet on the periodic tick |
| + } |
| + if hold := Hold(bake, rt.App, channel, ver, DefaultHolds, false, now); hold != "" { |
| + fmt.Printf(" %s: %s (%s) is %s\n", rt.Host, ver, channel, hold) |
| + return |
| + } |
| + if poisoned[ver] { |
| + fmt.Printf(" %s: skipping %s (failed on another host this pass)\n", rt.Host, ver) |
| + return |
| + } |
| + if err := ensureStaged(ctx, s, optDir, rt.App, ver); err != nil { |
| + fmt.Printf(" %s: %v\n", rt.Host, err) |
| + return |
| + } |
| + if err := PointLive(optDir, rt.Host, ver, rt.App); err != nil { |
| + fmt.Printf(" %s: repoint failed: %v\n", rt.Host, err) |
| + return |
| + } |
| + if restart != nil { |
| + if err := restart(rt.Host); err != nil { |
| + // Poison the build for the rest of the pass and repoint back — |
| + // the host keeps running what it ran. |
| + poisoned[ver] = true |
| + if cur != "" { |
| + _ = PointLive(optDir, rt.Host, cur, rt.App) |
| + } |
| + fmt.Printf(" %s: update to %s failed: %v — rolled the symlink back\n", rt.Host, ver, err) |
| + return |
| + } |
| + } |
| + if err := reg.SetVersions(rt.Host, ver, cur); err != nil { |
| + fmt.Printf(" %s: repointed but recording the version failed: %v\n", rt.Host, err) |
| + return |
| + } |
| + if rt.Hibernate { |
| + fmt.Printf(" %s: repointed %s -> %s (%s; hibernating — runs it on next wake)\n", rt.Host, cur, ver, channel) |
| + } else { |
| + fmt.Printf(" %s: updated %s -> %s (%s)\n", rt.Host, cur, ver, channel) |
| + } |
| +} |
| + |
| +// ensureStaged makes ver's build of app available in the version store, |
| +// downloading and sha256-verifying it if needed. Idempotent across hosts in |
| +// one pass. The manifest supplies the sha; the bytes are refused unless they |
| +// hash to it — the channel pointer's signature plus this gate is the whole |
| +// trust chain from operator to disk. |
| +func ensureStaged(ctx context.Context, s Store, optDir, app, ver string) error { |
| + if _, err := os.Stat(VersionPath(optDir, ver, app)); err == nil { |
| + return nil |
| + } |
| + m, err := GetManifest(ctx, s, app, ver) |
| + if err != nil { |
| + return fmt.Errorf("manifest for %s/%s: %w", app, ver, err) |
| + } |
| + art, ok := m.Artifacts[adoptPlatform] |
| + if !ok { |
| + // A box facing a release with no build for its platform: skip, never |
| + // install the wrong arch. The next release ships it and the box |
| + // catches up. |
| + return fmt.Errorf("release %s has no %s build — skipping", ver, adoptPlatform) |
| + } |
| + body, _, err := s.Get(ctx, ArtifactKey(app, ver, art.File)) |
| + if err != nil { |
| + return fmt.Errorf("download %s: %w", ver, err) |
| + } |
| + if got := etagOf(body); got != art.SHA256 { |
| + return fmt.Errorf("download %s: sha256 mismatch (refusing to stage)", ver) |
| + } |
| + return StageArtifact(optDir, ver, app, body) |
| +} |
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go| new file mode 100644 |
| index 000000000..4f10b9019 |
| --- /dev/null |
| +++ b/internal/release/adopt_test.go |
| @@ -0,0 +1,193 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func testRegistry(t *testing.T) *registry.Registry { |
| + t.Helper() |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatalf("open registry: %v", err) |
| + } |
| + return reg |
| +} |
| + |
| +func addRoute(t *testing.T, reg *registry.Registry, host, app, channel string, hibernate bool) { |
| + t.Helper() |
| + if err := reg.Upsert(registry.Route{ |
| + Host: host, Kind: "instance", App: app, Channel: channel, Hibernate: hibernate, |
| + }); err != nil { |
| + t.Fatalf("upsert %s: %v", host, err) |
| + } |
| +} |
| + |
| +func TestPassRepointsAwakeAndHibernating(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + |
| + // canary/* has no bake window, so the pass applies immediately. |
| + shipRelease(t, s, "myapp", "v1", adoptPlatform, []byte("build one")) |
| + if err := Promote(ctx, s, priv, "myapp", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + addRoute(t, reg, "awake.example.com", "myapp", "canary/x", false) |
| + addRoute(t, reg, "sleepy.example.com", "myapp", "canary/x", true) |
| + |
| + var restarted []string |
| + restart := func(host string) error { restarted = append(restarted, host); return nil } |
| + if err := Pass(ctx, reg, s, pub, opt, restart); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + |
| + for _, host := range []string{"awake.example.com", "sleepy.example.com"} { |
| + if v, _ := LiveVersion(opt, host); v != "v1" { |
| + t.Fatalf("%s live = %q, want v1", host, v) |
| + } |
| + rt, err := reg.Get(host) |
| + if err != nil || rt == nil || rt.Version != "v1" { |
| + t.Fatalf("%s registry version = %+v, %v", host, rt, err) |
| + } |
| + } |
| + // Only the awake host was restarted; the hibernating host is NEVER woken |
| + // — its next wake execs the new binary via the live symlink. |
| + if len(restarted) != 1 || restarted[0] != "awake.example.com" { |
| + t.Fatalf("restarted = %v, want only awake.example.com", restarted) |
| + } |
| + // The staged artifact matches the shipped bytes (sha256-gated download). |
| + body, err := os.ReadFile(VersionPath(opt, "v1", "myapp")) |
| + if err != nil || string(body) != "build one" { |
| + t.Fatalf("staged bytes = %q, %v", body, err) |
| + } |
| +} |
| + |
| +func TestPassPoisonedBuildSkipsRemainingHosts(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + |
| + // Both hosts start on v1 (staged locally), channel head moves to v2. |
| + shipRelease(t, s, "myapp", "v1", adoptPlatform, []byte("good")) |
| + shipRelease(t, s, "myapp", "v2", adoptPlatform, []byte("bad")) |
| + if err := StageArtifact(opt, "v1", "myapp", []byte("good")); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, h := range []string{"a.example.com", "b.example.com"} { |
| + addRoute(t, reg, h, "myapp", "canary/x", false) |
| + if err := PointLive(opt, h, "v1", "myapp"); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := Promote(ctx, s, priv, "myapp", "canary/x", "v2"); err != nil { |
| + t.Fatalf("Promote v2: %v", err) |
| + } |
| + |
| + // v2 fails its restart on the first host — it must be poisoned for the |
| + // rest of the pass, never cycling every host in turn. |
| + var calls int |
| + restart := func(host string) error { calls++; return errors.New("unit failed") } |
| + if err := Pass(ctx, reg, s, pub, opt, restart); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + if calls != 1 { |
| + t.Fatalf("restart called %d times, want 1 (poisoned build must not bounce every host)", calls) |
| + } |
| + // The failed host was rolled back to what it ran; the second never moved. |
| + for _, h := range []string{"a.example.com", "b.example.com"} { |
| + if v, _ := LiveVersion(opt, h); v != "v1" { |
| + t.Fatalf("%s live = %q, want v1", h, v) |
| + } |
| + } |
| +} |
| + |
| +func TestPassStableHeldByBakeWindow(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + |
| + shipRelease(t, s, "myapp", "v1", adoptPlatform, []byte("bin")) |
| + if err := Promote(ctx, s, priv, "myapp", "stable", "v1"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + addRoute(t, reg, "h.example.com", "myapp", "", false) // "" = stable |
| + |
| + if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + // A brand-new stable head is inside its 72h supply-chain window: the |
| + // clock started this pass, nothing installs. |
| + if v, _ := LiveVersion(opt, "h.example.com"); v != "" { |
| + t.Fatalf("live = %q, want unmoved (bake hold)", v) |
| + } |
| + // The pass persisted the first sighting so the window is measured from |
| + // the box's own clock. |
| + st := LoadFirstSeen(FirstSeenPath(opt)) |
| + if _, ok := st.EligibleAt("myapp", "stable", "v1", DefaultHolds); !ok { |
| + t.Fatalf("Pass did not tick the first-seen clock") |
| + } |
| +} |
| + |
| +func TestPassSkipsUnadoptableRoutes(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + |
| + shipRelease(t, s, "myapp", "v1", adoptPlatform, []byte("bin")) |
| + if err := Promote(ctx, s, priv, "myapp", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + // A static route, an app-less instance, and a mid-provision instance: |
| + // none may be moved by an adoption pass. |
| + if err := reg.Upsert(registry.Route{Host: "site.example.com", Kind: "static", App: "myapp", Channel: "canary/x"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + addRoute(t, reg, "noapp.example.com", "", "canary/x", false) |
| + if err := reg.Upsert(registry.Route{Host: "prov.example.com", Kind: "instance", App: "myapp", Channel: "canary/x", Status: "creating"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + for _, h := range []string{"site.example.com", "noapp.example.com", "prov.example.com"} { |
| + if v, _ := LiveVersion(opt, h); v != "" { |
| + t.Fatalf("%s was moved (%q) but is not adoptable", h, v) |
| + } |
| + } |
| +} |
| + |
| +func TestPassMissingPlatformArtifactSkips(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + |
| + // Shipped for some other platform only: this box must skip, never |
| + // install the wrong arch. |
| + shipRelease(t, s, "myapp", "v1", "plan9-mips", []byte("bin")) |
| + if err := Promote(ctx, s, priv, "myapp", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + addRoute(t, reg, "h.example.com", "myapp", "canary/x", false) |
| + if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil { |
| + t.Fatalf("Pass: %v", err) |
| + } |
| + if v, _ := LiveVersion(opt, "h.example.com"); v != "" { |
| + t.Fatalf("live = %q, want unmoved (no artifact for this platform)", v) |
| + } |
| +} |
diff --git a/internal/release/bake.go b/internal/release/bake.go| new file mode 100644 |
| index 000000000..7928d9e7d |
| --- /dev/null |
| +++ b/internal/release/bake.go |
| @@ -0,0 +1,179 @@ |
| +// Bake windows — ported from Eleven bake.go, intact. |
| +// |
| +// The supply-chain bake window: beta and stable subscribers don't install a |
| +// release the moment it appears — the box waits until the release has sat at |
| +// the channel head for a while first, so a compromised release that gets |
| +// caught and pulled back never reaches them. The clock is THIS BOX's own |
| +// observation ("when did I first see this version at this channel head"), |
| +// never a manifest timestamp: an attacker holding the signing key controls |
| +// every timestamp in the feed and would simply backdate one, but cannot |
| +// backdate what the box already saw. |
| +// |
| +// canary/* has no window — it IS the operator's own deploy path (`ship && |
| +// promote`). beta's 24h absorbs the caught-within-a-day mass of real |
| +// supply-chain incidents; stable's 72h additionally spans a weekend, so a |
| +// Friday-night malicious publish is seen by a human on Monday with margin to |
| +// pull it back. |
| +// |
| +// There is deliberately NO publisher-side bypass ("critical fix, skip the |
| +// hold") — that flag is the first thing an attacker sets. The bypass is |
| +// local and explicit (the ignore parameter of Hold), which needs root on the |
| +// box. |
| +package release |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "math/rand" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// DefaultHolds is the default bake window per channel; per-deployment |
| +// overrides come from host config as a replacement map passed by the caller. |
| +var DefaultHolds = map[string]time.Duration{ |
| + "canary/*": 0, |
| + "beta": 24 * time.Hour, |
| + "stable": 72 * time.Hour, |
| +} |
| + |
| +// HoldFor resolves a channel's bake window: an exact entry wins, then a |
| +// "<head>/*" wildcard for two-segment channels (canary/<branch>), and an |
| +// unknown channel gets the longest standard window — fail closed, never |
| +// zero, for a channel nobody configured. |
| +func HoldFor(holds map[string]time.Duration, channel string) time.Duration { |
| + if d, ok := holds[channel]; ok { |
| + return d |
| + } |
| + if i := strings.Index(channel, "/"); i >= 0 { |
| + if d, ok := holds[channel[:i]+"/*"]; ok { |
| + return d |
| + } |
| + } |
| + return 72 * time.Hour |
| +} |
| + |
| +// FirstSeenPath is where a box persists its bake clock, under its opt dir. |
| +func FirstSeenPath(optDir string) string { |
| + return filepath.Join(optDir, "channel-first-seen.json") |
| +} |
| + |
| +// firstSeenKeep bounds each channel's history. Past heads are kept (not just |
| +// the current one) so a pull-back to a previously-seen version is immediately |
| +// eligible — evacuating a bad release must never itself sit in a bake window. |
| +const firstSeenKeep = 12 |
| + |
| +// FirstSeen is the bake clock: for each app/channel, when this box first |
| +// observed each version at its head. |
| +type FirstSeen struct { |
| + // Jitter desynchronises a fleet of boxes: each waits hold*(1+Jitter), so |
| + // boxes don't all install in the same hour and the earliest become |
| + // canaries for the rest. Minted once per box, in [0, 0.25). |
| + Jitter float64 `json:"jitter"` |
| + // Channels is keyed "<app>/<channel>" -> version -> first-seen time. |
| + Channels map[string]map[string]time.Time `json:"channels"` |
| + |
| + path string // where Save persists; set by LoadFirstSeen |
| +} |
| + |
| +func channelClockKey(app, channel string) string { return app + "/" + channel } |
| + |
| +// LoadFirstSeen reads the bake clock from path. A missing or corrupt file is |
| +// an empty clock — every head then bakes from "now", which fails closed (a |
| +// wiped clock delays updates; it never lets one through early). |
| +func LoadFirstSeen(path string) *FirstSeen { |
| + st := &FirstSeen{path: path} |
| + if raw, err := os.ReadFile(path); err == nil { |
| + _ = json.Unmarshal(raw, st) |
| + } |
| + if st.Channels == nil { |
| + st.Channels = map[string]map[string]time.Time{} |
| + } |
| + if st.Jitter < 0 || st.Jitter >= 0.25 { |
| + st.Jitter = 0 |
| + } |
| + return st |
| +} |
| + |
| +// Observe records a channel head this box hasn't seen at that head before. |
| +// Returns whether anything changed (the caller persists via Save). Never |
| +// call it on a pointer that failed verification — the clock only ever ticks |
| +// for signed feeds. An already-known version's timestamp is NEVER touched: |
| +// that preserved first sighting is exactly what makes a pointer moved BACK |
| +// to a previously-seen version (rollback, evacuation) immediately eligible. |
| +func (st *FirstSeen) Observe(app, channel, version string, now time.Time) bool { |
| + changed := false |
| + if st.Jitter == 0 && len(st.Channels) == 0 { |
| + // A fresh box mints its jitter once, alongside its first observation. |
| + st.Jitter = rand.Float64() * 0.25 |
| + changed = true |
| + } |
| + key := channelClockKey(app, channel) |
| + seen := st.Channels[key] |
| + if seen == nil { |
| + seen = map[string]time.Time{} |
| + st.Channels[key] = seen |
| + } |
| + if _, ok := seen[version]; !ok { |
| + seen[version] = now.UTC().Truncate(time.Second) |
| + changed = true |
| + } |
| + // Prune oldest entries beyond the cap — the just-recorded head is the |
| + // newest, so it always survives. |
| + for len(seen) > firstSeenKeep { |
| + oldest, at := "", time.Time{} |
| + for v, t := range seen { |
| + if oldest == "" || t.Before(at) { |
| + oldest, at = v, t |
| + } |
| + } |
| + delete(seen, oldest) |
| + } |
| + return changed |
| +} |
| + |
| +// Save persists the clock back to the path it was loaded from. |
| +func (st *FirstSeen) Save() error { |
| + raw, err := json.MarshalIndent(st, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + return os.WriteFile(st.path, append(raw, '\n'), 0o644) |
| +} |
| + |
| +// EligibleAt is the moment a version first seen at a channel head may be |
| +// installed: firstSeen + hold*(1+jitter). ok=false when the version has no |
| +// record at this channel's head — brand new, so not eligible (callers that |
| +// Observe first make that unreachable; a status read that doesn't tick the |
| +// clock can legitimately see it). |
| +func (st *FirstSeen) EligibleAt(app, channel, version string, holds map[string]time.Duration) (time.Time, bool) { |
| + seen, ok := st.Channels[channelClockKey(app, channel)][version] |
| + if !ok { |
| + return time.Time{}, false |
| + } |
| + d := HoldFor(holds, channel) |
| + if d == 0 { |
| + return seen, true |
| + } |
| + return seen.Add(time.Duration(float64(d) * (1 + st.Jitter))), true |
| +} |
| + |
| +// Hold returns a non-empty human reason while a channel head must not yet be |
| +// installed on this box, "" once it may be. ignore is the local, root-only |
| +// override — deliberately never a publisher-side flag. |
| +func Hold(st *FirstSeen, app, channel, version string, holds map[string]time.Duration, ignore bool, now time.Time) string { |
| + if ignore { |
| + return "" |
| + } |
| + at, ok := st.EligibleAt(app, channel, version, holds) |
| + if !ok { |
| + // Fail closed: no record means no clock has run, so nothing installs. |
| + return "in its supply-chain bake window (clock starts this pass)" |
| + } |
| + if now.Before(at) { |
| + return fmt.Sprintf("in its supply-chain bake window until %s", at.UTC().Format("2006-01-02 15:04 MST")) |
| + } |
| + return "" |
| +} |
diff --git a/internal/release/bake_test.go b/internal/release/bake_test.go| new file mode 100644 |
| index 000000000..c1743710d |
| --- /dev/null |
| +++ b/internal/release/bake_test.go |
| @@ -0,0 +1,171 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// Jitter is in [0, 0.25), so a hold of H is eligible somewhere in |
| +// [firstSeen+H, firstSeen+1.25H). 91h is safely past stable's worst case |
| +// (72h * 1.25 = 90h); 71h is safely inside the best case. |
| +const ( |
| + insideStableHold = 71 * time.Hour |
| + pastStableHold = 91 * time.Hour |
| +) |
| + |
| +func TestBakeHoldBlocksNewStableUntilHeld(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "first-seen.json") |
| + st := LoadFirstSeen(path) |
| + t0 := time.Now() |
| + if !st.Observe("myapp", "stable", "v1", t0) { |
| + t.Fatalf("first Observe reported no change") |
| + } |
| + if hold := Hold(st, "myapp", "stable", "v1", DefaultHolds, false, t0); hold == "" { |
| + t.Fatalf("brand-new stable head not held") |
| + } |
| + if hold := Hold(st, "myapp", "stable", "v1", DefaultHolds, false, t0.Add(insideStableHold)); hold == "" { |
| + t.Fatalf("stable head released before its 72h window") |
| + } |
| + if hold := Hold(st, "myapp", "stable", "v1", DefaultHolds, false, t0.Add(pastStableHold)); hold != "" { |
| + t.Fatalf("stable head still held past its window: %s", hold) |
| + } |
| + // A version with NO first-seen record fails closed. |
| + if hold := Hold(st, "myapp", "stable", "vGhost", DefaultHolds, false, t0.Add(pastStableHold)); hold == "" { |
| + t.Fatalf("unseen version not held (must fail closed)") |
| + } |
| + // The local root-only override releases immediately. |
| + if hold := Hold(st, "myapp", "stable", "v1", DefaultHolds, true, t0); hold != "" { |
| + t.Fatalf("ignore=true still held: %s", hold) |
| + } |
| + // canary/* has no window: eligible the moment it is first seen. |
| + st.Observe("myapp", "canary/x", "v1", t0) |
| + if hold := Hold(st, "myapp", "canary/x", "v1", DefaultHolds, false, t0); hold != "" { |
| + t.Fatalf("canary held: %s", hold) |
| + } |
| +} |
| + |
| +// TestRollbackNeverBakes is THE key property: a pointer moved BACK to a |
| +// previously-seen version applies immediately — its first-seen record |
| +// predates the hold (that's how it got installed the first time), and |
| +// Observe never resets an existing timestamp. Evacuating a bad release must |
| +// never itself sit in a bake window. |
| +func TestRollbackNeverBakes(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "myapp", "v1", "linux-amd64", []byte("one")) |
| + shipRelease(t, s, "myapp", "v2", "linux-amd64", []byte("two")) |
| + |
| + st := LoadFirstSeen(filepath.Join(t.TempDir(), "first-seen.json")) |
| + t0 := time.Now() |
| + |
| + // v1 promoted to stable; the box first sees it at t0, bakes, installs. |
| + if err := Promote(ctx, s, priv, "myapp", "stable", "v1"); err != nil { |
| + t.Fatalf("Promote v1: %v", err) |
| + } |
| + ptr, err := Channel(ctx, s, pub, "myapp", "stable") |
| + if err != nil { |
| + t.Fatalf("Channel: %v", err) |
| + } |
| + st.Observe("myapp", "stable", ptr.Version, t0) |
| + |
| + // Time passes; v1's window elapses; v2 is promoted and first seen NOW. |
| + t1 := t0.Add(pastStableHold) |
| + if err := Promote(ctx, s, priv, "myapp", "stable", "v2"); err != nil { |
| + t.Fatalf("Promote v2: %v", err) |
| + } |
| + ptr, _ = Channel(ctx, s, pub, "myapp", "stable") |
| + st.Observe("myapp", "stable", ptr.Version, t1) |
| + if hold := Hold(st, "myapp", "stable", ptr.Version, DefaultHolds, false, t1); hold == "" { |
| + t.Fatalf("fresh v2 not held") |
| + } |
| + |
| + // v2 turns out bad — rollback. The pointer is back at v1, and v1 applies |
| + // IMMEDIATELY: its first-seen record is t0, long past the hold. Observe |
| + // must not reset it. |
| + if err := Rollback(ctx, s, priv, "myapp", "stable"); err != nil { |
| + t.Fatalf("Rollback: %v", err) |
| + } |
| + ptr, err = Channel(ctx, s, pub, "myapp", "stable") |
| + if err != nil { |
| + t.Fatalf("Channel after rollback: %v", err) |
| + } |
| + if ptr.Version != "v1" { |
| + t.Fatalf("rollback pointer = %s, want v1", ptr.Version) |
| + } |
| + st.Observe("myapp", "stable", ptr.Version, t1) // re-seen at head: no reset |
| + if hold := Hold(st, "myapp", "stable", ptr.Version, DefaultHolds, false, t1); hold != "" { |
| + t.Fatalf("rolled-back v1 held: %s (evacuation must never bake)", hold) |
| + } |
| + // And v2 stays held — the rollback didn't launder it. |
| + if hold := Hold(st, "myapp", "stable", "v2", DefaultHolds, false, t1); hold == "" { |
| + t.Fatalf("bad v2 no longer held after rollback") |
| + } |
| +} |
| + |
| +func TestLoadFirstSeenCorruptFailsClosed(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "first-seen.json") |
| + if err := os.WriteFile(path, []byte("{ this is not json"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + st := LoadFirstSeen(path) |
| + // A corrupt file is an empty clock: everything bakes from now — a wiped |
| + // clock delays updates, it never lets one through early. |
| + if hold := Hold(st, "myapp", "stable", "v1", DefaultHolds, false, time.Now()); hold == "" { |
| + t.Fatalf("corrupt clock did not fail closed") |
| + } |
| + // Missing file: same. |
| + st2 := LoadFirstSeen(filepath.Join(t.TempDir(), "absent.json")) |
| + if hold := Hold(st2, "myapp", "stable", "v1", DefaultHolds, false, time.Now()); hold == "" { |
| + t.Fatalf("missing clock did not fail closed") |
| + } |
| +} |
| + |
| +func TestFirstSeenSaveLoadRoundTrip(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "first-seen.json") |
| + st := LoadFirstSeen(path) |
| + t0 := time.Now() |
| + st.Observe("myapp", "stable", "v1", t0) |
| + if st.Jitter < 0 || st.Jitter >= 0.25 { |
| + t.Fatalf("minted jitter %v outside [0, 0.25)", st.Jitter) |
| + } |
| + if err := st.Save(); err != nil { |
| + t.Fatalf("Save: %v", err) |
| + } |
| + st2 := LoadFirstSeen(path) |
| + if st2.Jitter != st.Jitter { |
| + t.Fatalf("jitter not persisted: %v != %v", st2.Jitter, st.Jitter) |
| + } |
| + at, ok := st2.EligibleAt("myapp", "stable", "v1", DefaultHolds) |
| + if !ok { |
| + t.Fatalf("first-seen record lost on reload") |
| + } |
| + want := t0.UTC().Truncate(time.Second).Add(time.Duration(float64(72*time.Hour) * (1 + st.Jitter))) |
| + if !at.Equal(want) { |
| + t.Fatalf("EligibleAt = %v, want %v", at, want) |
| + } |
| +} |
| + |
| +func TestHoldForWildcardAndUnknown(t *testing.T) { |
| + if d := HoldFor(DefaultHolds, "canary/any-branch"); d != 0 { |
| + t.Fatalf("canary/* = %v, want 0", d) |
| + } |
| + if d := HoldFor(DefaultHolds, "beta"); d != 24*time.Hour { |
| + t.Fatalf("beta = %v, want 24h", d) |
| + } |
| + if d := HoldFor(DefaultHolds, "stable"); d != 72*time.Hour { |
| + t.Fatalf("stable = %v, want 72h", d) |
| + } |
| + // An unknown channel fails closed to the longest standard window. |
| + if d := HoldFor(DefaultHolds, "mystery"); d != 72*time.Hour { |
| + t.Fatalf("unknown channel = %v, want 72h (fail closed)", d) |
| + } |
| + // A per-deployment override map replaces the defaults wholesale. |
| + custom := map[string]time.Duration{"stable": time.Hour} |
| + if d := HoldFor(custom, "stable"); d != time.Hour { |
| + t.Fatalf("override stable = %v, want 1h", d) |
| + } |
| +} |
diff --git a/internal/release/release.go b/internal/release/release.go| new file mode 100644 |
| index 000000000..34e58cc7d |
| --- /dev/null |
| +++ b/internal/release/release.go |
| @@ -0,0 +1,477 @@ |
| +// Package release is the release plane: content-addressed release manifests |
| +// and signed, CAS-guarded channel pointers in a deployment bucket. |
| +// |
| +// Ported from Eleven release.go (the signedManifest wrapper, key parsing, |
| +// keygen) with the trust split the plan calls for: release manifests are |
| +// content-addressed and immutable (their bucket path is claimed create-only, |
| +// so their integrity is their existence), while CHANNEL POINTERS are the |
| +// trust boundary — a pointer is what tells a fleet which build to run, so it |
| +// carries the Ed25519 signature and moves only via compare-and-swap. |
| +// |
| +// Bucket layout (deployment bucket): |
| +// |
| +// apps/<app>/releases/<version>/manifest.json content-addressed, immutable |
| +// apps/<app>/releases/<version>/<artifact files> binaries or static tree tar |
| +// apps/<app>/channels/<channel>.json SIGNED channel pointer, CAS via If-Match |
| +// |
| +// Keys: the private half is the CARLOS_RELEASE_KEY Ed25519 seed (env only — |
| +// secret manager -> shell variable -> process memory, never disk); the |
| +// public key is pinned in the deployment's host config |
| +// (CARLOS_RELEASE_PUBKEY), not baked into the binary. |
| +package release |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "regexp" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// Manifest describes one shipped release of one app. It is content-addressed |
| +// (immutable at its bucket path), so it needs no signature of its own: a |
| +// verified channel pointer names its version, and the manifest's artifact |
| +// sha256s gate every byte downloaded from it. |
| +type Manifest struct { |
| + Schema int `json:"schema"` // 1 |
| + App string `json:"app"` |
| + Version string `json:"version"` // git short sha |
| + Kind string `json:"kind"` // "binary" | "static" |
| + Commit string `json:"commit,omitempty"` |
| + BuiltBy string `json:"built_by,omitempty"` |
| + BuiltAt string `json:"built_at,omitempty"` |
| + // Artifacts is keyed "linux-arm64" etc for binaries, or "site" for a |
| + // static tree tar. |
| + Artifacts map[string]Artifact `json:"artifacts"` |
| +} |
| + |
| +// Artifact is one file of a release: its bucket file name and the sha256 |
| +// every downloader must verify before staging it. |
| +type Artifact struct { |
| + File string `json:"file"` |
| + SHA256 string `json:"sha256"` |
| + Size int64 `json:"size"` |
| +} |
| + |
| +// ChannelPointer says which version a channel currently serves. This is the |
| +// document the fleet trusts, so it ships wrapped in signedWrapper and only |
| +// moves via CAS. |
| +type ChannelPointer struct { |
| + Schema int `json:"schema"` |
| + App string `json:"app"` |
| + Channel string `json:"channel"` |
| + Version string `json:"version"` |
| + PromotedAt time.Time `json:"promoted_at"` |
| + // History is the previous versions, newest first, max historyMax — |
| + // rollback targets. Rollback swaps Version <-> History[0], so a second |
| + // rollback returns to where you were (Eleven versions.go's |
| + // current<->previous swap, generalised). |
| + History []string `json:"history,omitempty"` |
| +} |
| + |
| +// historyMax bounds a pointer's rollback history. |
| +const historyMax = 10 |
| + |
| +// Bucket keys. Channel names may contain one "/" (canary/<branch>), which |
| +// simply nests the pointer one level deeper. |
| +func ManifestKey(app, version string) string { |
| + return "apps/" + app + "/releases/" + version + "/manifest.json" |
| +} |
| +func ArtifactKey(app, version, file string) string { |
| + return "apps/" + app + "/releases/" + version + "/" + file |
| +} |
| +func ChannelKey(app, channel string) string { |
| + return "apps/" + app + "/channels/" + channel + ".json" |
| +} |
| + |
| +// appRe pins app names to the boring lowercase words the box maps to |
| +// /opt/carlos paths — anything fancier is a manifest bug, not a feature. |
| +// (Eleven release.go's serviceNameRe.) |
| +var appRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`) |
| + |
| +func ValidApp(a string) bool { return appRe.MatchString(a) } |
| + |
| +// channelSegRe is one channel path segment. |
| +var channelSegRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) |
| + |
| +// ValidChannel accepts "stable", "beta", any other single name, or a |
| +// two-segment "canary/<branch>" — each segment directory-name safe (the |
| +// channel lands in a bucket key and in on-box state files). |
| +func ValidChannel(c string) bool { |
| + parts := strings.Split(c, "/") |
| + if len(parts) == 0 || len(parts) > 2 { |
| + return false |
| + } |
| + for _, p := range parts { |
| + if p == "" || p == "." || p == ".." || len(p) > 64 || !channelSegRe.MatchString(p) { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// fileRe keeps artifact file names usable as single path segments. |
| +var fileRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) |
| + |
| +func validArtifactFile(f string) bool { |
| + return f != "" && f != "." && f != ".." && len(f) <= 128 && fileRe.MatchString(f) |
| +} |
| + |
| +// validateManifest rejects a manifest that parsed but can't be acted on. |
| +func validateManifest(m *Manifest) error { |
| + if m.Schema != 1 { |
| + return fmt.Errorf("unsupported manifest schema %d", m.Schema) |
| + } |
| + if !ValidApp(m.App) { |
| + return fmt.Errorf("invalid app %q", m.App) |
| + } |
| + if !ValidVersion(m.Version) || m.Version == "dev" { |
| + return fmt.Errorf("invalid version %q", m.Version) |
| + } |
| + if m.Kind != "binary" && m.Kind != "static" { |
| + return fmt.Errorf("invalid kind %q (binary | static)", m.Kind) |
| + } |
| + if len(m.Artifacts) == 0 { |
| + return fmt.Errorf("manifest has no artifacts") |
| + } |
| + for k, a := range m.Artifacts { |
| + if !validArtifactFile(a.File) { |
| + return fmt.Errorf("artifact %s: invalid file name %q", k, a.File) |
| + } |
| + if raw, err := hex.DecodeString(a.SHA256); err != nil || len(raw) != sha256.Size { |
| + return fmt.Errorf("artifact %s: sha256 must be %d hex chars", k, sha256.Size*2) |
| + } |
| + if a.Size < 0 { |
| + return fmt.Errorf("artifact %s: negative size", k) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// ---- signing ---------------------------------------------------------------- |
| + |
| +// signedWrapper is the wire format of a channel pointer: the signature |
| +// covers the exact base64 payload string, so verification never depends on |
| +// re-marshalling JSON — verify first, decode after, no JSON canonicalisation |
| +// to get wrong. (Ported from Eleven release.go's signedManifest.) |
| +type signedWrapper struct { |
| + Payload string `json:"payload"` |
| + Sig string `json:"sig"` |
| +} |
| + |
| +// signPointer stamps PromotedAt (now) and wraps the pointer in the signed |
| +// wire format. |
| +func signPointer(priv ed25519.PrivateKey, p *ChannelPointer) ([]byte, error) { |
| + p.Schema = 1 |
| + p.PromotedAt = time.Now().UTC().Truncate(time.Second) |
| + if err := validatePointer(p, p.App, p.Channel); err != nil { |
| + return nil, err |
| + } |
| + payload, err := json.MarshalIndent(p, "", " ") |
| + if err != nil { |
| + return nil, err |
| + } |
| + b64 := base64.StdEncoding.EncodeToString(payload) |
| + sig := ed25519.Sign(priv, []byte(b64)) |
| + return json.MarshalIndent(signedWrapper{ |
| + Payload: b64, |
| + Sig: base64.StdEncoding.EncodeToString(sig), |
| + }, "", " ") |
| +} |
| + |
| +// verifyPointer checks the signature against pub and only then decodes and |
| +// validates the payload — everything the caller acts on came from under the |
| +// signature. wantApp/wantChannel are matched against the SIGNED payload, so |
| +// a validly-signed pointer copied from another channel's key (a cross-channel |
| +// replay) is rejected. |
| +func verifyPointer(pub ed25519.PublicKey, wrapper []byte, wantApp, wantChannel string) (*ChannelPointer, error) { |
| + var sw signedWrapper |
| + if err := json.Unmarshal(wrapper, &sw); err != nil { |
| + return nil, fmt.Errorf("pointer wrapper: %v", err) |
| + } |
| + sig, err := base64.StdEncoding.DecodeString(sw.Sig) |
| + if err != nil { |
| + return nil, fmt.Errorf("pointer sig: %v", err) |
| + } |
| + if !ed25519.Verify(pub, []byte(sw.Payload), sig) { |
| + return nil, fmt.Errorf("channel pointer signature verification failed") |
| + } |
| + payload, err := base64.StdEncoding.DecodeString(sw.Payload) |
| + if err != nil { |
| + return nil, fmt.Errorf("pointer payload: %v", err) |
| + } |
| + var p ChannelPointer |
| + if err := json.Unmarshal(payload, &p); err != nil { |
| + return nil, fmt.Errorf("pointer payload: %v", err) |
| + } |
| + if err := validatePointer(&p, wantApp, wantChannel); err != nil { |
| + return nil, err |
| + } |
| + return &p, nil |
| +} |
| + |
| +func validatePointer(p *ChannelPointer, wantApp, wantChannel string) error { |
| + if p.Schema != 1 { |
| + return fmt.Errorf("unsupported pointer schema %d", p.Schema) |
| + } |
| + if p.App != wantApp || p.Channel != wantChannel { |
| + return fmt.Errorf("pointer names %s/%s, expected %s/%s", p.App, p.Channel, wantApp, wantChannel) |
| + } |
| + if !ValidApp(p.App) || !ValidChannel(p.Channel) { |
| + return fmt.Errorf("pointer has invalid app/channel %s/%s", p.App, p.Channel) |
| + } |
| + if !ValidVersion(p.Version) || p.Version == "dev" { |
| + return fmt.Errorf("pointer has invalid version %q", p.Version) |
| + } |
| + if p.PromotedAt.IsZero() { |
| + return fmt.Errorf("pointer has no promoted_at") |
| + } |
| + if len(p.History) > historyMax { |
| + return fmt.Errorf("pointer history too long (%d > %d)", len(p.History), historyMax) |
| + } |
| + for _, v := range p.History { |
| + if !ValidVersion(v) { |
| + return fmt.Errorf("pointer history has invalid version %q", v) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// ---- keys ------------------------------------------------------------------- |
| + |
| +// GenerateKey mints a fresh signing pair: the base64 seed (the operator's |
| +// secret — password manager, then CARLOS_RELEASE_KEY) and the base64 public |
| +// key (pinned in host config as CARLOS_RELEASE_PUBKEY). |
| +func GenerateKey() (seedB64, pubB64 string, err error) { |
| + pub, priv, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + return base64.StdEncoding.EncodeToString(priv.Seed()), |
| + base64.StdEncoding.EncodeToString(pub), nil |
| +} |
| + |
| +// ParseSigningKey turns a base64 Ed25519 seed — from CARLOS_RELEASE_KEY — |
| +// into the signing key. src only names the origin in the error. |
| +func ParseSigningKey(b64, src string) (ed25519.PrivateKey, error) { |
| + seed, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) |
| + if err != nil || len(seed) != ed25519.SeedSize { |
| + return nil, fmt.Errorf("%s: expected a base64 %d-byte Ed25519 seed", src, ed25519.SeedSize) |
| + } |
| + return ed25519.NewKeyFromSeed(seed), nil |
| +} |
| + |
| +// ParsePublicKey turns a base64 public key — from CARLOS_RELEASE_PUBKEY — |
| +// into the verification key. |
| +func ParsePublicKey(b64, src string) (ed25519.PublicKey, error) { |
| + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) |
| + if err != nil || len(raw) != ed25519.PublicKeySize { |
| + return nil, fmt.Errorf("%s: expected a base64 %d-byte Ed25519 public key", src, ed25519.PublicKeySize) |
| + } |
| + return ed25519.PublicKey(raw), nil |
| +} |
| + |
| +// ---- top-level API ---------------------------------------------------------- |
| + |
| +// Ship uploads a release: artifacts first, manifest LAST — the manifest is |
| +// the commit record, so a crashed ship leaves orphan artifact bytes, never a |
| +// manifest naming files that don't exist. Everything is create-only |
| +// (immutable): re-shipping identical bytes is an idempotent no-op, while |
| +// re-shipping DIFFERENT content under an existing version is refused — a |
| +// version id, once published, can never quietly mean something else. |
| +// |
| +// files is keyed by artifact map key ("linux-arm64", "site", ...). Ship |
| +// fills each Artifact's SHA256/Size from the bytes (and, when already set, |
| +// requires them to match); an empty Artifact.File defaults to the map key. |
| +func Ship(ctx context.Context, s Store, m Manifest, files map[string][]byte) error { |
| + m.Schema = 1 |
| + for k, a := range m.Artifacts { |
| + b, ok := files[k] |
| + if !ok { |
| + return fmt.Errorf("ship: manifest artifact %q has no file bytes", k) |
| + } |
| + sum := etagOf(b) // hex sha256 |
| + if a.SHA256 != "" && a.SHA256 != sum { |
| + return fmt.Errorf("ship: artifact %q sha256 mismatch (manifest %s, bytes %s)", k, a.SHA256, sum) |
| + } |
| + a.SHA256 = sum |
| + a.Size = int64(len(b)) |
| + if a.File == "" { |
| + a.File = k |
| + } |
| + m.Artifacts[k] = a |
| + } |
| + for k := range files { |
| + if _, ok := m.Artifacts[k]; !ok { |
| + return fmt.Errorf("ship: file %q has no manifest artifact entry", k) |
| + } |
| + } |
| + if err := validateManifest(&m); err != nil { |
| + return fmt.Errorf("ship: %w", err) |
| + } |
| + for k, a := range m.Artifacts { |
| + if err := putImmutable(ctx, s, ArtifactKey(m.App, m.Version, a.File), files[k]); err != nil { |
| + return fmt.Errorf("ship: artifact %s: %w", k, err) |
| + } |
| + } |
| + raw, err := json.MarshalIndent(m, "", " ") |
| + if err != nil { |
| + return err |
| + } |
| + if err := putImmutable(ctx, s, ManifestKey(m.App, m.Version), raw); err != nil { |
| + return fmt.Errorf("ship: manifest: %w", err) |
| + } |
| + return nil |
| +} |
| + |
| +// putImmutable claims a content-addressed key create-only; a lost race is |
| +// fine iff the existing bytes are identical (idempotent re-ship), and an |
| +// error otherwise (an immutable path must never change meaning). |
| +func putImmutable(ctx context.Context, s Store, key string, body []byte) error { |
| + _, err := s.Put(ctx, key, body, "") |
| + if !errors.Is(err, ErrCASConflict) { |
| + return err |
| + } |
| + cur, _, gerr := s.Get(ctx, key) |
| + if gerr != nil { |
| + return fmt.Errorf("%s exists but is unreadable: %w", key, gerr) |
| + } |
| + if !bytes.Equal(cur, body) { |
| + return fmt.Errorf("%s already exists with different content — refusing to overwrite an immutable release object", key) |
| + } |
| + return nil |
| +} |
| + |
| +// GetManifest fetches and validates a release manifest, requiring it to name |
| +// the app/version its key claims. Not-found is os.ErrNotExist-wrapped. |
| +func GetManifest(ctx context.Context, s Store, app, version string) (*Manifest, error) { |
| + raw, _, err := s.Get(ctx, ManifestKey(app, version)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + var m Manifest |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + return nil, fmt.Errorf("manifest %s/%s: %v", app, version, err) |
| + } |
| + if err := validateManifest(&m); err != nil { |
| + return nil, fmt.Errorf("manifest %s/%s: %w", app, version, err) |
| + } |
| + if m.App != app || m.Version != version { |
| + return nil, fmt.Errorf("manifest at %s names %s/%s", ManifestKey(app, version), m.App, m.Version) |
| + } |
| + return &m, nil |
| +} |
| + |
| +// Promote points a channel at a shipped version, signing the new pointer and |
| +// swapping it in via CAS (a concurrent promote surfaces as ErrCASConflict — |
| +// re-read and retry, never overwrite blind). The outgoing version is |
| +// prepended to History (capped at historyMax), which is what Rollback |
| +// consumes. An existing pointer must verify against this key's public half |
| +// before it is replaced: history from a document we can't trust doesn't get |
| +// carried forward. |
| +func Promote(ctx context.Context, s Store, priv ed25519.PrivateKey, app, channel, version string) error { |
| + if !ValidApp(app) { |
| + return fmt.Errorf("promote: invalid app %q", app) |
| + } |
| + if !ValidChannel(channel) { |
| + return fmt.Errorf("promote: invalid channel %q", channel) |
| + } |
| + if !ValidVersion(version) || version == "dev" { |
| + return fmt.Errorf("promote: invalid version %q", version) |
| + } |
| + // A channel only ever points at a version that was actually shipped. |
| + if _, err := GetManifest(ctx, s, app, version); err != nil { |
| + return fmt.Errorf("promote: version %s is not shipped: %w", version, err) |
| + } |
| + pub, _ := priv.Public().(ed25519.PublicKey) |
| + key := ChannelKey(app, channel) |
| + cur, etag, err := s.Get(ctx, key) |
| + var old *ChannelPointer |
| + switch { |
| + case err == nil: |
| + old, err = verifyPointer(pub, cur, app, channel) |
| + if err != nil { |
| + return fmt.Errorf("promote: existing pointer at %s: %w", key, err) |
| + } |
| + case errors.Is(err, os.ErrNotExist): |
| + old, etag = &ChannelPointer{}, "" // first promote: create-only Put |
| + default: |
| + return err |
| + } |
| + next := &ChannelPointer{App: app, Channel: channel, Version: version, History: old.History} |
| + if old.Version != "" && old.Version != version { |
| + next.History = prependCapped(old.Version, old.History, historyMax) |
| + } |
| + wrapper, err := signPointer(priv, next) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = s.Put(ctx, key, wrapper, etag) |
| + return err |
| +} |
| + |
| +// Channel resolves a channel's current pointer, verifying its signature — |
| +// the one read path the fleet uses, so nothing unsigned ever reaches a box. |
| +// Not-found is os.ErrNotExist-wrapped. |
| +func Channel(ctx context.Context, s Store, pub ed25519.PublicKey, app, channel string) (*ChannelPointer, error) { |
| + raw, _, err := s.Get(ctx, ChannelKey(app, channel)) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return verifyPointer(pub, raw, app, channel) |
| +} |
| + |
| +// Rollback swaps a channel back to History[0], pushing the outgoing version |
| +// onto History — so a second rollback returns to where you were. Boxes apply |
| +// it immediately per the bake rules: a box that has previously seen the |
| +// target at this channel's head has a first-seen record older than the hold |
| +// (that's how it got installed the first time), so evacuating a bad release |
| +// never itself sits in a bake window (bake.go). |
| +func Rollback(ctx context.Context, s Store, priv ed25519.PrivateKey, app, channel string) error { |
| + if !ValidApp(app) || !ValidChannel(channel) { |
| + return fmt.Errorf("rollback: invalid app/channel %s/%s", app, channel) |
| + } |
| + pub, _ := priv.Public().(ed25519.PublicKey) |
| + key := ChannelKey(app, channel) |
| + cur, etag, err := s.Get(ctx, key) |
| + if err != nil { |
| + return fmt.Errorf("rollback: %w", err) |
| + } |
| + old, err := verifyPointer(pub, cur, app, channel) |
| + if err != nil { |
| + return fmt.Errorf("rollback: existing pointer at %s: %w", key, err) |
| + } |
| + if len(old.History) == 0 { |
| + return fmt.Errorf("rollback: %s/%s has no previous version recorded", app, channel) |
| + } |
| + next := &ChannelPointer{ |
| + App: app, |
| + Channel: channel, |
| + Version: old.History[0], |
| + History: prependCapped(old.Version, old.History[1:], historyMax), |
| + } |
| + wrapper, err := signPointer(priv, next) |
| + if err != nil { |
| + return err |
| + } |
| + _, err = s.Put(ctx, key, wrapper, etag) |
| + return err |
| +} |
| + |
| +// prependCapped puts v at the head of hist, dropping the tail beyond max. |
| +func prependCapped(v string, hist []string, max int) []string { |
| + out := append([]string{v}, hist...) |
| + if len(out) > max { |
| + out = out[:max] |
| + } |
| + return out |
| +} |
diff --git a/internal/release/release_test.go b/internal/release/release_test.go| new file mode 100644 |
| index 000000000..e358a422d |
| --- /dev/null |
| +++ b/internal/release/release_test.go |
| @@ -0,0 +1,279 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// testKeys mints a signing pair through the exported key helpers, so the |
| +// round trip seed -> base64 -> key is exercised too. |
| +func testKeys(t *testing.T) (ed25519.PrivateKey, ed25519.PublicKey) { |
| + t.Helper() |
| + seedB64, pubB64, err := GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + priv, err := ParseSigningKey(seedB64, "test") |
| + if err != nil { |
| + t.Fatalf("ParseSigningKey: %v", err) |
| + } |
| + pub, err := ParsePublicKey(pubB64, "test") |
| + if err != nil { |
| + t.Fatalf("ParsePublicKey: %v", err) |
| + } |
| + return priv, pub |
| +} |
| + |
| +// shipRelease ships a one-artifact binary release keyed by plat. |
| +func shipRelease(t *testing.T, s Store, app, version, plat string, body []byte) { |
| + t.Helper() |
| + m := Manifest{ |
| + App: app, |
| + Version: version, |
| + Kind: "binary", |
| + Artifacts: map[string]Artifact{plat: {}}, |
| + } |
| + if err := Ship(context.Background(), s, m, map[string][]byte{plat: body}); err != nil { |
| + t.Fatalf("Ship %s/%s: %v", app, version, err) |
| + } |
| +} |
| + |
| +func TestShipThenPromoteThenChannelRoundTrip(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + |
| + shipRelease(t, s, "myapp", "v1abc", "linux-amd64", []byte("binary bytes v1")) |
| + if err := Promote(ctx, s, priv, "myapp", "canary/x", "v1abc"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + ptr, err := Channel(ctx, s, pub, "myapp", "canary/x") |
| + if err != nil { |
| + t.Fatalf("Channel: %v", err) |
| + } |
| + if ptr.App != "myapp" || ptr.Channel != "canary/x" || ptr.Version != "v1abc" { |
| + t.Fatalf("pointer = %+v", ptr) |
| + } |
| + if ptr.PromotedAt.IsZero() || len(ptr.History) != 0 { |
| + t.Fatalf("pointer = %+v, want stamped PromotedAt and empty history", ptr) |
| + } |
| + // The manifest round-trips with the sha256 Ship computed. |
| + m, err := GetManifest(ctx, s, "myapp", "v1abc") |
| + if err != nil { |
| + t.Fatalf("GetManifest: %v", err) |
| + } |
| + art := m.Artifacts["linux-amd64"] |
| + if art.SHA256 != etagOf([]byte("binary bytes v1")) || art.Size != int64(len("binary bytes v1")) { |
| + t.Fatalf("artifact = %+v", art) |
| + } |
| + body, _, err := s.Get(ctx, ArtifactKey("myapp", "v1abc", art.File)) |
| + if err != nil || string(body) != "binary bytes v1" { |
| + t.Fatalf("artifact bytes = %q, %v", body, err) |
| + } |
| +} |
| + |
| +func TestShipImmutable(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + // Re-shipping identical bytes is an idempotent no-op... |
| + shipRelease(t, s, "myapp", "v1abc", "linux-amd64", []byte("same")) |
| + shipRelease(t, s, "myapp", "v1abc", "linux-amd64", []byte("same")) |
| + // ...but the same version with DIFFERENT content is refused. |
| + m := Manifest{App: "myapp", Version: "v1abc", Kind: "binary", |
| + Artifacts: map[string]Artifact{"linux-amd64": {}}} |
| + err := Ship(context.Background(), s, m, map[string][]byte{"linux-amd64": []byte("different")}) |
| + if err == nil { |
| + t.Fatalf("Ship accepted different content under an existing version") |
| + } |
| +} |
| + |
| +func TestPromoteRequiresShippedVersion(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + priv, _ := testKeys(t) |
| + err := Promote(context.Background(), s, priv, "myapp", "stable", "ghost1") |
| + if err == nil || !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("Promote of unshipped version: err = %v, want os.ErrNotExist-wrapped", err) |
| + } |
| +} |
| + |
| +func TestTamperedPointerRejected(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "myapp", "v1abc", "linux-amd64", []byte("bin")) |
| + if err := Promote(ctx, s, priv, "myapp", "stable", "v1abc"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + |
| + key := ChannelKey("myapp", "stable") |
| + raw, etag, err := s.Get(ctx, key) |
| + if err != nil { |
| + t.Fatalf("Get pointer: %v", err) |
| + } |
| + var sw signedWrapper |
| + if err := json.Unmarshal(raw, &sw); err != nil { |
| + t.Fatalf("unmarshal wrapper: %v", err) |
| + } |
| + |
| + // Flip a byte in the signature. |
| + sig := []byte(sw.Sig) |
| + if sig[10] == 'A' { |
| + sig[10] = 'B' |
| + } else { |
| + sig[10] = 'A' |
| + } |
| + tampered, _ := json.Marshal(signedWrapper{Payload: sw.Payload, Sig: string(sig)}) |
| + if _, err := s.Put(ctx, key, tampered, etag); err != nil { |
| + t.Fatalf("plant tampered pointer: %v", err) |
| + } |
| + if _, err := Channel(ctx, s, pub, "myapp", "stable"); err == nil { |
| + t.Fatalf("Channel accepted a pointer with a flipped signature byte") |
| + } |
| + |
| + // Flip a byte in the payload instead (signature intact) — the signature |
| + // covers the exact base64 payload bytes, so this must fail too. |
| + payload := []byte(sw.Payload) |
| + if payload[10] == 'A' { |
| + payload[10] = 'B' |
| + } else { |
| + payload[10] = 'A' |
| + } |
| + raw2, etag2, _ := s.Get(ctx, key) |
| + _ = raw2 |
| + tampered2, _ := json.Marshal(signedWrapper{Payload: string(payload), Sig: sw.Sig}) |
| + if _, err := s.Put(ctx, key, tampered2, etag2); err != nil { |
| + t.Fatalf("plant tampered payload: %v", err) |
| + } |
| + if _, err := Channel(ctx, s, pub, "myapp", "stable"); err == nil { |
| + t.Fatalf("Channel accepted a pointer with a flipped payload byte") |
| + } |
| + |
| + // And a validly-signed pointer copied from ANOTHER channel's key is a |
| + // cross-channel replay — rejected because app/channel live under the |
| + // signature. |
| + if err := Promote(ctx, s, priv, "myapp", "canary/evil", "v1abc"); err != nil { |
| + t.Fatalf("Promote canary: %v", err) |
| + } |
| + canaryRaw, _, _ := s.Get(ctx, ChannelKey("myapp", "canary/evil")) |
| + _, curEtag, _ := s.Get(ctx, key) |
| + if _, err := s.Put(ctx, key, canaryRaw, curEtag); err != nil { |
| + t.Fatalf("plant replayed pointer: %v", err) |
| + } |
| + if _, err := Channel(ctx, s, pub, "myapp", "stable"); err == nil { |
| + t.Fatalf("Channel accepted a pointer replayed from another channel") |
| + } |
| +} |
| + |
| +func TestPromoteHistoryCapTen(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + for i := 1; i <= 12; i++ { |
| + v := fmt.Sprintf("v%02d", i) |
| + shipRelease(t, s, "myapp", v, "linux-amd64", []byte("bin "+v)) |
| + if err := Promote(ctx, s, priv, "myapp", "stable", v); err != nil { |
| + t.Fatalf("Promote %s: %v", v, err) |
| + } |
| + } |
| + ptr, err := Channel(ctx, s, pub, "myapp", "stable") |
| + if err != nil { |
| + t.Fatalf("Channel: %v", err) |
| + } |
| + if ptr.Version != "v12" { |
| + t.Fatalf("Version = %s, want v12", ptr.Version) |
| + } |
| + if len(ptr.History) != 10 { |
| + t.Fatalf("History has %d entries, want 10: %v", len(ptr.History), ptr.History) |
| + } |
| + if ptr.History[0] != "v11" || ptr.History[9] != "v02" { |
| + t.Fatalf("History = %v, want newest-first v11..v02", ptr.History) |
| + } |
| +} |
| + |
| +func TestRollbackSwapsAndReturns(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + shipRelease(t, s, "myapp", "v1", "linux-amd64", []byte("one")) |
| + shipRelease(t, s, "myapp", "v2", "linux-amd64", []byte("two")) |
| + if err := Promote(ctx, s, priv, "myapp", "stable", "v1"); err != nil { |
| + t.Fatalf("Promote v1: %v", err) |
| + } |
| + |
| + // Nothing to roll back to yet. |
| + if err := Rollback(ctx, s, priv, "myapp", "stable"); err == nil { |
| + t.Fatalf("Rollback with empty history succeeded") |
| + } |
| + |
| + if err := Promote(ctx, s, priv, "myapp", "stable", "v2"); err != nil { |
| + t.Fatalf("Promote v2: %v", err) |
| + } |
| + if err := Rollback(ctx, s, priv, "myapp", "stable"); err != nil { |
| + t.Fatalf("Rollback: %v", err) |
| + } |
| + ptr, err := Channel(ctx, s, pub, "myapp", "stable") |
| + if err != nil { |
| + t.Fatalf("Channel: %v", err) |
| + } |
| + if ptr.Version != "v1" || len(ptr.History) != 1 || ptr.History[0] != "v2" { |
| + t.Fatalf("after rollback: %+v, want v1 with history [v2]", ptr) |
| + } |
| + // A second rollback returns to where you were (the swap semantics). |
| + if err := Rollback(ctx, s, priv, "myapp", "stable"); err != nil { |
| + t.Fatalf("second Rollback: %v", err) |
| + } |
| + ptr, _ = Channel(ctx, s, pub, "myapp", "stable") |
| + if ptr.Version != "v2" || len(ptr.History) != 1 || ptr.History[0] != "v1" { |
| + t.Fatalf("after second rollback: %+v, want v2 with history [v1]", ptr) |
| + } |
| +} |
| + |
| +func TestChannelWrongKeyRejected(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, _ := testKeys(t) |
| + _, otherPub := testKeys(t) |
| + shipRelease(t, s, "myapp", "v1", "linux-amd64", []byte("one")) |
| + if err := Promote(ctx, s, priv, "myapp", "stable", "v1"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + if _, err := Channel(ctx, s, otherPub, "myapp", "stable"); err == nil { |
| + t.Fatalf("Channel verified against the wrong public key") |
| + } |
| +} |
| + |
| +func TestValidChannel(t *testing.T) { |
| + for c, want := range map[string]bool{ |
| + "stable": true, "beta": true, "canary/feature-x": true, |
| + "canary/..": false, "a/b/c": false, "": false, "sta ble": false, |
| + } { |
| + if got := ValidChannel(c); got != want { |
| + t.Errorf("ValidChannel(%q) = %v, want %v", c, got, want) |
| + } |
| + } |
| +} |
| + |
| +// Guard against the PromotedAt stamp being mistaken for a trust input |
| +// anywhere: the bake clock must run off the box's own sightings, and the |
| +// stamp is informational. This just pins that signPointer stamps it. |
| +func TestSignStampsPromotedAt(t *testing.T) { |
| + priv, pub := testKeys(t) |
| + p := &ChannelPointer{App: "myapp", Channel: "stable", Version: "v1"} |
| + before := time.Now().Add(-time.Second) |
| + raw, err := signPointer(priv, p) |
| + if err != nil { |
| + t.Fatalf("signPointer: %v", err) |
| + } |
| + got, err := verifyPointer(pub, raw, "myapp", "stable") |
| + if err != nil { |
| + t.Fatalf("verifyPointer: %v", err) |
| + } |
| + if got.PromotedAt.Before(before) { |
| + t.Fatalf("PromotedAt = %v, want stamped at signing time", got.PromotedAt) |
| + } |
| +} |
diff --git a/internal/release/store.go b/internal/release/store.go| new file mode 100644 |
| index 000000000..c2abaf452 |
| --- /dev/null |
| +++ b/internal/release/store.go |
| @@ -0,0 +1,280 @@ |
| +// Store — the deployment bucket behind the release plane: S3 in production, |
| +// a plain directory in tests. CAS discipline (If-Match / If-None-Match) |
| +// ported from Eleven internal/idalloc/idalloc.go (the litestream leaser's |
| +// conditional-write pattern); not-found folding from Eleven s3blobstore.go. |
| +package release |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "io/fs" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| + "sync" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + awsconfig "github.com/aws/aws-sdk-go-v2/config" |
| + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" |
| + "github.com/aws/smithy-go" |
| +) |
| + |
| +// ErrCASConflict is a conditional Put that lost: the object already existed |
| +// (ifMatch "") or moved on since the etag was read (ifMatch stale). Callers |
| +// re-read and retry; they never overwrite blind. |
| +var ErrCASConflict = errors.New("store: compare-and-swap conflict") |
| + |
| +// Store is the deployment bucket. Get's not-found is os.ErrNotExist-wrapped |
| +// so callers use errors.Is; Put with ifMatch "" is create-only |
| +// (If-None-Match: *), non-empty is replace-only (If-Match: <etag>), and a |
| +// lost race is ErrCASConflict either way. |
| +type Store interface { |
| + Get(ctx context.Context, key string) ([]byte, string, error) // body, etag |
| + Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) // new etag |
| + List(ctx context.Context, prefix string) ([]string, error) |
| +} |
| + |
| +// ---- DirStore --------------------------------------------------------------- |
| + |
| +// DirStore keeps objects as files under a root directory — the test double |
| +// and the single-box no-AWS deployment shape. The etag is the hex sha256 of |
| +// the content (content-derived, so it survives process restarts with no |
| +// sidecar files). CAS is enforced under one process-wide mutex plus |
| +// temp-file + rename writes: single-process fidelity, which is what the |
| +// tests and a one-box deployment need. |
| +type DirStore struct { |
| + root string |
| + mu sync.Mutex |
| +} |
| + |
| +func NewDirStore(root string) *DirStore { return &DirStore{root: root} } |
| + |
| +// path maps a bucket key onto the root dir, refusing traversal — a key is |
| +// bucket data, never a filesystem instruction. |
| +func (d *DirStore) path(key string) (string, error) { |
| + if key == "" || strings.HasPrefix(key, "/") { |
| + return "", fmt.Errorf("dirstore: invalid key %q", key) |
| + } |
| + for _, seg := range strings.Split(key, "/") { |
| + if seg == "" || seg == "." || seg == ".." { |
| + return "", fmt.Errorf("dirstore: invalid key %q", key) |
| + } |
| + } |
| + return filepath.Join(d.root, filepath.FromSlash(key)), nil |
| +} |
| + |
| +func etagOf(body []byte) string { |
| + sum := sha256.Sum256(body) |
| + return hex.EncodeToString(sum[:]) |
| +} |
| + |
| +func (d *DirStore) Get(_ context.Context, key string) ([]byte, string, error) { |
| + p, err := d.path(key) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + d.mu.Lock() |
| + defer d.mu.Unlock() |
| + b, err := os.ReadFile(p) |
| + if err != nil { |
| + if errors.Is(err, fs.ErrNotExist) { |
| + return nil, "", fmt.Errorf("dirstore: %s: %w", key, os.ErrNotExist) |
| + } |
| + return nil, "", err |
| + } |
| + return b, etagOf(b), nil |
| +} |
| + |
| +func (d *DirStore) Put(_ context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + p, err := d.path(key) |
| + if err != nil { |
| + return "", err |
| + } |
| + d.mu.Lock() |
| + defer d.mu.Unlock() |
| + cur, err := os.ReadFile(p) |
| + exists := err == nil |
| + if err != nil && !errors.Is(err, fs.ErrNotExist) { |
| + return "", err |
| + } |
| + // The CAS gate, mirroring S3's semantics exactly: "" = create-only, |
| + // non-empty = the object must still carry that etag. |
| + if ifMatch == "" && exists { |
| + return "", fmt.Errorf("dirstore: %s exists: %w", key, ErrCASConflict) |
| + } |
| + if ifMatch != "" && (!exists || etagOf(cur) != ifMatch) { |
| + return "", fmt.Errorf("dirstore: %s moved on: %w", key, ErrCASConflict) |
| + } |
| + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { |
| + return "", err |
| + } |
| + // Temp + rename so a crashed write never leaves a half-object a reader |
| + // could mistake for the real thing. |
| + tmp := p + ".tmp" |
| + if err := os.WriteFile(tmp, body, 0o644); err != nil { |
| + return "", err |
| + } |
| + if err := os.Rename(tmp, p); err != nil { |
| + os.Remove(tmp) |
| + return "", err |
| + } |
| + return etagOf(body), nil |
| +} |
| + |
| +func (d *DirStore) List(_ context.Context, prefix string) ([]string, error) { |
| + d.mu.Lock() |
| + defer d.mu.Unlock() |
| + var out []string |
| + err := filepath.WalkDir(d.root, func(p string, de fs.DirEntry, err error) error { |
| + if err != nil { |
| + if errors.Is(err, fs.ErrNotExist) { |
| + return nil // an empty store lists empty, like an empty bucket |
| + } |
| + return err |
| + } |
| + if de.IsDir() || strings.HasSuffix(p, ".tmp") { |
| + return nil |
| + } |
| + rel, err := filepath.Rel(d.root, p) |
| + if err != nil { |
| + return err |
| + } |
| + key := filepath.ToSlash(rel) |
| + if strings.HasPrefix(key, prefix) { |
| + out = append(out, key) |
| + } |
| + return nil |
| + }) |
| + sort.Strings(out) |
| + return out, err |
| +} |
| + |
| +// ---- S3Store ---------------------------------------------------------------- |
| + |
| +// S3Store is the production bucket via aws-sdk-go-v2, with the conditional |
| +// headers doing the CAS work server-side. Compiles always; exercised live |
| +// only when CARLOS_TEST_S3_BUCKET is set (see release_test.go). |
| +type S3Store struct { |
| + Client *awss3.Client |
| + Bucket string |
| +} |
| + |
| +// NewS3Store builds a store on the standard AWS credential/region chain. |
| +// CARLOS_S3_ENDPOINT (optional) points at an S3-compatible (R2, MinIO) and |
| +// implies path-style addressing; checksums go when-required because |
| +// default-on flexible checksums wrap bodies in aws-chunked framing some |
| +// compatibles reject (Eleven s3blobstore.go's R2 lesson). |
| +func NewS3Store(ctx context.Context, bucket string) (*S3Store, error) { |
| + cfg, err := awsconfig.LoadDefaultConfig(ctx) |
| + if err != nil { |
| + return nil, fmt.Errorf("s3store: load aws config: %w", err) |
| + } |
| + client := awss3.NewFromConfig(cfg, func(o *awss3.Options) { |
| + if ep := strings.TrimSpace(os.Getenv("CARLOS_S3_ENDPOINT")); ep != "" { |
| + o.BaseEndpoint = &ep |
| + o.UsePathStyle = true |
| + } |
| + o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired |
| + o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired |
| + }) |
| + return &S3Store{Client: client, Bucket: bucket}, nil |
| +} |
| + |
| +// s3NotFound folds the shapes a missing object answers with (NoSuchKey on |
| +// GET, NotFound on HEAD, bare 404s from S3-compatibles) into one test. |
| +func s3NotFound(err error) bool { |
| + var apiErr smithy.APIError |
| + if errors.As(err, &apiErr) { |
| + switch apiErr.ErrorCode() { |
| + case "NoSuchKey", "NotFound", "404": |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// s3CASConflict reports whether an error is a conditional-write loss: 412 |
| +// (If-Match / If-None-Match precondition failed) or 409 (S3's |
| +// ConditionalRequestConflict when two conditional writes race). Matched by |
| +// both the S3 error code and the raw HTTP status, so an S3-compatible that |
| +// only sets one of them still classifies correctly. |
| +func s3CASConflict(err error) bool { |
| + var apiErr smithy.APIError |
| + if errors.As(err, &apiErr) { |
| + switch apiErr.ErrorCode() { |
| + case "PreconditionFailed", "ConditionalRequestConflict": |
| + return true |
| + } |
| + } |
| + var httpErr interface{ HTTPStatusCode() int } |
| + if errors.As(err, &httpErr) { |
| + switch httpErr.HTTPStatusCode() { |
| + case http.StatusPreconditionFailed, http.StatusConflict: |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +func (s *S3Store) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + out, err := s.Client.GetObject(ctx, &awss3.GetObjectInput{Bucket: &s.Bucket, Key: &key}) |
| + if err != nil { |
| + if s3NotFound(err) { |
| + return nil, "", fmt.Errorf("s3store: %s: %w", key, os.ErrNotExist) |
| + } |
| + return nil, "", err |
| + } |
| + defer out.Body.Close() |
| + b, err := io.ReadAll(out.Body) |
| + if err != nil { |
| + return nil, "", err |
| + } |
| + return b, aws.ToString(out.ETag), nil |
| +} |
| + |
| +func (s *S3Store) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + in := &awss3.PutObjectInput{ |
| + Bucket: &s.Bucket, |
| + Key: &key, |
| + Body: bytes.NewReader(body), |
| + ContentLength: aws.Int64(int64(len(body))), |
| + } |
| + if ifMatch == "" { |
| + in.IfNoneMatch = aws.String("*") // create-only: 412 if someone created it first |
| + } else { |
| + in.IfMatch = aws.String(ifMatch) // replace-only: 412 if the object moved on |
| + } |
| + out, err := s.Client.PutObject(ctx, in) |
| + if err != nil { |
| + if s3CASConflict(err) { |
| + return "", fmt.Errorf("s3store: %s: %w", key, ErrCASConflict) |
| + } |
| + return "", err |
| + } |
| + return aws.ToString(out.ETag), nil |
| +} |
| + |
| +func (s *S3Store) List(ctx context.Context, prefix string) ([]string, error) { |
| + var out []string |
| + p := awss3.NewListObjectsV2Paginator(s.Client, &awss3.ListObjectsV2Input{ |
| + Bucket: &s.Bucket, |
| + Prefix: &prefix, |
| + }) |
| + for p.HasMorePages() { |
| + page, err := p.NextPage(ctx) |
| + if err != nil { |
| + return nil, err |
| + } |
| + for _, o := range page.Contents { |
| + out = append(out, aws.ToString(o.Key)) |
| + } |
| + } |
| + return out, nil |
| +} |
diff --git a/internal/release/store_test.go b/internal/release/store_test.go| new file mode 100644 |
| index 000000000..50e456ab3 |
| --- /dev/null |
| +++ b/internal/release/store_test.go |
| @@ -0,0 +1,137 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestPutIfNoneMatchConflict(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if _, err := s.Put(ctx, "a/b.json", []byte("one"), ""); err != nil { |
| + t.Fatalf("create: %v", err) |
| + } |
| + // A second create-only Put of an existing key must lose. |
| + if _, err := s.Put(ctx, "a/b.json", []byte("two"), ""); !errors.Is(err, ErrCASConflict) { |
| + t.Fatalf("create over existing: err = %v, want ErrCASConflict", err) |
| + } |
| + // The loser changed nothing. |
| + body, _, err := s.Get(ctx, "a/b.json") |
| + if err != nil || string(body) != "one" { |
| + t.Fatalf("Get = %q, %v; want %q", body, err, "one") |
| + } |
| +} |
| + |
| +func TestPutIfMatchStaleEtagFails(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + etag1, err := s.Put(ctx, "k", []byte("one"), "") |
| + if err != nil { |
| + t.Fatalf("create: %v", err) |
| + } |
| + etag2, err := s.Put(ctx, "k", []byte("two"), etag1) |
| + if err != nil { |
| + t.Fatalf("CAS replace: %v", err) |
| + } |
| + if etag2 == etag1 { |
| + t.Fatalf("etag didn't change on replace") |
| + } |
| + // Replaying the old etag must lose. |
| + if _, err := s.Put(ctx, "k", []byte("three"), etag1); !errors.Is(err, ErrCASConflict) { |
| + t.Fatalf("stale etag: err = %v, want ErrCASConflict", err) |
| + } |
| + // And a CAS write against a key that doesn't exist must lose too. |
| + if _, err := s.Put(ctx, "missing", []byte("x"), etag1); !errors.Is(err, ErrCASConflict) { |
| + t.Fatalf("CAS on missing key: err = %v, want ErrCASConflict", err) |
| + } |
| + body, _, _ := s.Get(ctx, "k") |
| + if string(body) != "two" { |
| + t.Fatalf("Get = %q, want %q", body, "two") |
| + } |
| +} |
| + |
| +func TestGetNotFoundWrapsErrNotExist(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + if _, _, err := s.Get(context.Background(), "no/such/key"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("err = %v, want os.ErrNotExist-wrapped", err) |
| + } |
| +} |
| + |
| +func TestListPrefix(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + for _, k := range []string{"apps/x/releases/v1/manifest.json", "apps/x/releases/v1/bin", "apps/x/channels/stable.json", "apps/y/one"} { |
| + if _, err := s.Put(ctx, k, []byte(k), ""); err != nil { |
| + t.Fatalf("put %s: %v", k, err) |
| + } |
| + } |
| + keys, err := s.List(ctx, "apps/x/releases/v1/") |
| + if err != nil { |
| + t.Fatalf("list: %v", err) |
| + } |
| + want := []string{"apps/x/releases/v1/bin", "apps/x/releases/v1/manifest.json"} |
| + if len(keys) != len(want) || keys[0] != want[0] || keys[1] != want[1] { |
| + t.Fatalf("List = %v, want %v", keys, want) |
| + } |
| + // An empty store (or unmatched prefix) lists empty, like an empty bucket. |
| + keys, err = s.List(ctx, "nothing/") |
| + if err != nil || len(keys) != 0 { |
| + t.Fatalf("List(nothing/) = %v, %v; want empty", keys, err) |
| + } |
| +} |
| + |
| +func TestDirStoreRejectsTraversal(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + for _, k := range []string{"../evil", "a/../../evil", "/abs", "a//b", ""} { |
| + if _, err := s.Put(ctx, k, []byte("x"), ""); err == nil { |
| + t.Fatalf("Put(%q) accepted a traversal key", k) |
| + } |
| + } |
| +} |
| + |
| +// TestS3StoreLive exercises the production store against a real bucket — |
| +// only when CARLOS_TEST_S3_BUCKET is set; the type otherwise just compiles. |
| +func TestS3StoreLive(t *testing.T) { |
| + bucket := os.Getenv("CARLOS_TEST_S3_BUCKET") |
| + if bucket == "" { |
| + t.Skip("CARLOS_TEST_S3_BUCKET not set — skipping live S3Store test") |
| + } |
| + ctx := context.Background() |
| + s, err := NewS3Store(ctx, bucket) |
| + if err != nil { |
| + t.Fatalf("NewS3Store: %v", err) |
| + } |
| + key := fmt.Sprintf("carlos-release-test/%d/ptr.json", time.Now().UnixNano()) |
| + |
| + if _, _, err := s.Get(ctx, key); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("Get missing: err = %v, want os.ErrNotExist-wrapped", err) |
| + } |
| + etag, err := s.Put(ctx, key, []byte("one"), "") |
| + if err != nil { |
| + t.Fatalf("create: %v", err) |
| + } |
| + if _, err := s.Put(ctx, key, []byte("two"), ""); !errors.Is(err, ErrCASConflict) { |
| + t.Fatalf("create over existing: err = %v, want ErrCASConflict", err) |
| + } |
| + body, gotEtag, err := s.Get(ctx, key) |
| + if err != nil || string(body) != "one" || gotEtag != etag { |
| + t.Fatalf("Get = %q, %q, %v; want %q, %q", body, gotEtag, err, "one", etag) |
| + } |
| + etag2, err := s.Put(ctx, key, []byte("two"), etag) |
| + if err != nil { |
| + t.Fatalf("CAS replace: %v", err) |
| + } |
| + if _, err := s.Put(ctx, key, []byte("three"), etag); !errors.Is(err, ErrCASConflict) { |
| + t.Fatalf("stale etag: err = %v, want ErrCASConflict", err) |
| + } |
| + _ = etag2 |
| + keys, err := s.List(ctx, key) |
| + if err != nil || len(keys) != 1 || keys[0] != key { |
| + t.Fatalf("List = %v, %v; want [%s]", keys, err, key) |
| + } |
| +} |
diff --git a/internal/release/versions.go b/internal/release/versions.go| new file mode 100644 |
| index 000000000..49a872610 |
| --- /dev/null |
| +++ b/internal/release/versions.go |
| @@ -0,0 +1,236 @@ |
| +// The on-box version store — ported from Eleven versions.go. |
| +// |
| +// Versioned, side-by-side deploys: the box runs one binary per host, but |
| +// they need not be the SAME binary. Each host's unit execs |
| +// <opt>/live/<host>, a symlink into a content store at |
| +// <opt>/versions/<ver>/<app>. Swapping one link (and restarting one unit) |
| +// moves one host to a new build while every other host stays put — so a new |
| +// build can be canaried on one host before it's promoted fleet-wide, and |
| +// rolled back per host. |
| +// |
| +// The opt dir defaults from CARLOS_OPT_DIR (default /opt/carlos), but every |
| +// function takes the base dir as a parameter so tests never touch /opt. |
| +package release |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "io" |
| + "net" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "regexp" |
| + "time" |
| +) |
| + |
| +// OptDir is the on-box install root: CARLOS_OPT_DIR, default /opt/carlos. |
| +// Resolved at call time (not init) so a test's Setenv is honoured. |
| +func OptDir() string { |
| + if d := os.Getenv("CARLOS_OPT_DIR"); d != "" { |
| + return d |
| + } |
| + return "/opt/carlos" |
| +} |
| + |
| +func versionsDir(optDir string) string { return filepath.Join(optDir, "versions") } |
| +func liveDir(optDir string) string { return filepath.Join(optDir, "live") } |
| + |
| +// VersionPath is where a staged build of app lives: <opt>/versions/<ver>/<app>. |
| +func VersionPath(optDir, ver, app string) string { |
| + return filepath.Join(versionsDir(optDir), ver, app) |
| +} |
| + |
| +// LiveLink is the per-host pointer the unit execs (<opt>/live/<host>) — a |
| +// symlink to a VersionPath. Swapping it atomically is how one host moves |
| +// between versions independently of the others. |
| +func LiveLink(optDir, host string) string { return filepath.Join(liveDir(optDir), host) } |
| + |
| +// versionRe keeps a version usable as a directory name (no slashes, no |
| +// traversal). |
| +var versionRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) |
| + |
| +func ValidVersion(v string) bool { |
| + return v != "" && len(v) <= 64 && v != "." && v != ".." && versionRe.MatchString(v) |
| +} |
| + |
| +// CopyFileAtomic copies src to dst via a temp file + rename, so a reader |
| +// never sees a half-written binary (and a crashed copy leaves the old one |
| +// intact). |
| +func CopyFileAtomic(src, dst string, mode os.FileMode) error { |
| + in, err := os.Open(src) |
| + if err != nil { |
| + return err |
| + } |
| + defer in.Close() |
| + tmp := dst + ".tmp" |
| + out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := io.Copy(out, in); err != nil { |
| + out.Close() |
| + os.Remove(tmp) |
| + return err |
| + } |
| + if err := out.Close(); err != nil { |
| + os.Remove(tmp) |
| + return err |
| + } |
| + // O_CREATE's mode is masked by umask; set it explicitly so the binary is +x. |
| + if err := os.Chmod(tmp, mode); err != nil { |
| + os.Remove(tmp) |
| + return err |
| + } |
| + if err := os.Rename(tmp, dst); err != nil { |
| + os.Remove(tmp) |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +// WriteFileAtomic is CopyFileAtomic for in-memory bytes (a downloaded, |
| +// sha256-verified artifact). |
| +func WriteFileAtomic(dst string, data []byte, mode os.FileMode) error { |
| + tmp := dst + ".tmp" |
| + if err := os.WriteFile(tmp, data, mode); err != nil { |
| + return err |
| + } |
| + if err := os.Chmod(tmp, mode); err != nil { // umask, as above |
| + os.Remove(tmp) |
| + return err |
| + } |
| + if err := os.Rename(tmp, dst); err != nil { |
| + os.Remove(tmp) |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +// StageArtifact puts verified artifact bytes into the store under ver, so |
| +// hosts can be pointed at it. Idempotent: re-staging overwrites. |
| +func StageArtifact(optDir, ver, app string, data []byte) error { |
| + if !ValidVersion(ver) { |
| + return fmt.Errorf("invalid version %q (allowed: letters, digits, . _ -)", ver) |
| + } |
| + if !ValidApp(app) { |
| + return fmt.Errorf("invalid app %q", app) |
| + } |
| + dir := filepath.Join(versionsDir(optDir), ver) |
| + if err := os.MkdirAll(dir, 0o755); err != nil { |
| + return err |
| + } |
| + // MkdirAll's mode is masked by the caller's umask, so a tight operator |
| + // umask (e.g. 077 from a hardened shell) leaves the version dir 0700 — |
| + // the sandboxed service user then can't traverse in to exec its binary |
| + // (203/EXEC "Permission denied", found staging a canary on Eleven). |
| + // Force it, like the write below forces the binary's +x. |
| + if err := os.Chmod(dir, 0o755); err != nil { |
| + return err |
| + } |
| + return WriteFileAtomic(VersionPath(optDir, ver, app), data, 0o755) |
| +} |
| + |
| +// PointLive atomically repoints a host's live symlink at ver's build of app. |
| +// It verifies ver is staged first, so a typo can't leave a dangling link |
| +// that would fail the unit's ExecStart on the next restart. |
| +func PointLive(optDir, host, ver, app string) error { |
| + target := VersionPath(optDir, ver, app) |
| + if _, err := os.Stat(target); err != nil { |
| + return fmt.Errorf("version %q of %s is not staged (%s)", ver, app, target) |
| + } |
| + if err := os.MkdirAll(liveDir(optDir), 0o755); err != nil { |
| + return err |
| + } |
| + link := LiveLink(optDir, host) |
| + tmp := link + ".tmp" |
| + _ = os.Remove(tmp) // a leftover temp link from a crashed swap |
| + if err := os.Symlink(target, tmp); err != nil { |
| + return err |
| + } |
| + if err := os.Rename(tmp, link); err != nil { |
| + os.Remove(tmp) |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +// LiveVersion reports the version a host's live symlink currently points at, |
| +// or "" (no error) if the host hasn't been put under version management yet. |
| +func LiveVersion(optDir, host string) (string, error) { |
| + target, err := os.Readlink(LiveLink(optDir, host)) |
| + if err != nil { |
| + if os.IsNotExist(err) { |
| + return "", nil |
| + } |
| + return "", err |
| + } |
| + // target = <opt>/versions/<ver>/<app> -> the parent dir's name is <ver>. |
| + return filepath.Base(filepath.Dir(target)), nil |
| +} |
| + |
| +// WaitHealthyVersion waits until the host behind socket not only answers but |
| +// reports the expected build at /api/version — so a deploy fails loudly if |
| +// the process is still the old binary (a stale socket, or a restart that |
| +// didn't take), rather than silently "succeeding". |
| +func WaitHealthyVersion(socket, wantBuild string, timeout time.Duration) error { |
| + deadline := time.Now().Add(timeout) |
| + client := unixHTTPClient(socket) |
| + var last error |
| + for time.Now().Before(deadline) { |
| + resp, err := client.Get("http://carlos/api/version") |
| + if err == nil { |
| + var body struct { |
| + Build string `json:"build"` |
| + } |
| + derr := json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&body) |
| + resp.Body.Close() |
| + if derr == nil && body.Build == wantBuild { |
| + return nil |
| + } |
| + if derr != nil { |
| + last = fmt.Errorf("read /api/version: %v", derr) |
| + } else { |
| + last = fmt.Errorf("live build %q != expected %q", body.Build, wantBuild) |
| + } |
| + } else { |
| + last = err |
| + } |
| + time.Sleep(150 * time.Millisecond) |
| + } |
| + return fmt.Errorf("host didn't report build %q within %s: %v", wantBuild, timeout, last) |
| +} |
| + |
| +// ProbeVersion reads the build the process behind a socket is ACTUALLY |
| +// serving (its /api/version), not the symlink target — how a status probe |
| +// catches an awake hibernating host that repointed its symlink but still |
| +// runs the old binary. "" + ok=false when the socket doesn't answer (asleep, |
| +// or no such host). |
| +func ProbeVersion(socket string) (build string, ok bool) { |
| + resp, err := unixHTTPClient(socket).Get("http://carlos/api/version") |
| + if err != nil { |
| + return "", false |
| + } |
| + defer resp.Body.Close() |
| + var body struct { |
| + Build string `json:"build"` |
| + } |
| + if json.NewDecoder(io.LimitReader(resp.Body, 1<<16)).Decode(&body) != nil { |
| + return "", false |
| + } |
| + return body.Build, body.Build != "" |
| +} |
| + |
| +// unixHTTPClient dials a unix socket over HTTP — the edge<->host transport, |
| +// reused by the health checks. |
| +func unixHTTPClient(socket string) *http.Client { |
| + return &http.Client{ |
| + Timeout: 3 * time.Second, |
| + Transport: &http.Transport{ |
| + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return (&net.Dialer{}).DialContext(ctx, "unix", socket) |
| + }, |
| + }, |
| + } |
| +} |
diff --git a/internal/release/versions_test.go b/internal/release/versions_test.go| new file mode 100644 |
| index 000000000..73f805466 |
| --- /dev/null |
| +++ b/internal/release/versions_test.go |
| @@ -0,0 +1,112 @@ |
| +package release |
| + |
| +import ( |
| + "errors" |
| + "net" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestStagePointLiveRoundTrip(t *testing.T) { |
| + opt := t.TempDir() |
| + if err := StageArtifact(opt, "v1abc", "myapp", []byte("the binary")); err != nil { |
| + t.Fatalf("StageArtifact: %v", err) |
| + } |
| + p := VersionPath(opt, "v1abc", "myapp") |
| + st, err := os.Stat(p) |
| + if err != nil { |
| + t.Fatalf("staged file: %v", err) |
| + } |
| + if st.Mode().Perm() != 0o755 { |
| + t.Fatalf("staged mode = %v, want 0755 (+x survives umask)", st.Mode().Perm()) |
| + } |
| + // An unmanaged host reads as "" with no error. |
| + if v, err := LiveVersion(opt, "h.example.com"); err != nil || v != "" { |
| + t.Fatalf("LiveVersion before pointing = %q, %v", v, err) |
| + } |
| + if err := PointLive(opt, "h.example.com", "v1abc", "myapp"); err != nil { |
| + t.Fatalf("PointLive: %v", err) |
| + } |
| + v, err := LiveVersion(opt, "h.example.com") |
| + if err != nil || v != "v1abc" { |
| + t.Fatalf("LiveVersion = %q, %v; want v1abc", v, err) |
| + } |
| + // Repointing at an unstaged version is refused (no dangling links). |
| + if err := PointLive(opt, "h.example.com", "vGhost", "myapp"); err == nil { |
| + t.Fatalf("PointLive accepted an unstaged version") |
| + } |
| + // The refusal left the link untouched. |
| + if v, _ := LiveVersion(opt, "h.example.com"); v != "v1abc" { |
| + t.Fatalf("live link moved to %q after refused repoint", v) |
| + } |
| + // A second staged version swaps atomically. |
| + if err := StageArtifact(opt, "v2def", "myapp", []byte("newer binary")); err != nil { |
| + t.Fatalf("StageArtifact v2: %v", err) |
| + } |
| + if err := PointLive(opt, "h.example.com", "v2def", "myapp"); err != nil { |
| + t.Fatalf("PointLive v2: %v", err) |
| + } |
| + if v, _ := LiveVersion(opt, "h.example.com"); v != "v2def" { |
| + t.Fatalf("LiveVersion = %q, want v2def", v) |
| + } |
| +} |
| + |
| +func TestValidVersion(t *testing.T) { |
| + for v, want := range map[string]bool{ |
| + "abc1234": true, "1.2.3": true, "a_b-c.d": true, |
| + "": false, ".": false, "..": false, "a/b": false, "a b": false, |
| + } { |
| + if got := ValidVersion(v); got != want { |
| + t.Errorf("ValidVersion(%q) = %v, want %v", v, got, want) |
| + } |
| + } |
| +} |
| + |
| +func TestOptDirEnvOverride(t *testing.T) { |
| + t.Setenv("CARLOS_OPT_DIR", "/x/y") |
| + if d := OptDir(); d != "/x/y" { |
| + t.Fatalf("OptDir = %q, want /x/y", d) |
| + } |
| + t.Setenv("CARLOS_OPT_DIR", "") |
| + if d := OptDir(); d != "/opt/carlos" { |
| + t.Fatalf("OptDir = %q, want /opt/carlos", d) |
| + } |
| +} |
| + |
| +func TestProbeAndWaitHealthyVersion(t *testing.T) { |
| + sock := filepath.Join(t.TempDir(), "h.sock") |
| + ln, err := net.Listen("unix", sock) |
| + if err != nil { |
| + if errors.Is(err, os.ErrPermission) || strings.Contains(err.Error(), "operation not permitted") { |
| + t.Skipf("unix sockets unavailable in this sandbox: %v", err) |
| + } |
| + t.Fatalf("listen: %v", err) |
| + } |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("/api/version", func(w http.ResponseWriter, r *http.Request) { |
| + w.Write([]byte(`{"build":"v1abc"}`)) |
| + }) |
| + srv := &http.Server{Handler: mux} |
| + go srv.Serve(ln) |
| + defer srv.Close() |
| + |
| + build, ok := ProbeVersion(sock) |
| + if !ok || build != "v1abc" { |
| + t.Fatalf("ProbeVersion = %q, %v; want v1abc, true", build, ok) |
| + } |
| + if err := WaitHealthyVersion(sock, "v1abc", 3*time.Second); err != nil { |
| + t.Fatalf("WaitHealthyVersion: %v", err) |
| + } |
| + // A wrong expected build times out with the mismatch surfaced. |
| + if err := WaitHealthyVersion(sock, "vOther", 400*time.Millisecond); err == nil { |
| + t.Fatalf("WaitHealthyVersion accepted the wrong build") |
| + } |
| + // A dead socket probes false. |
| + if _, ok := ProbeVersion(filepath.Join(t.TempDir(), "dead.sock")); ok { |
| + t.Fatalf("ProbeVersion answered on a dead socket") |
| + } |
| +} |