carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "log"
9 "math/rand/v2"
10 "os"
11 "path/filepath"
12 "sort"
13 "time"
14
15 "github.com/carlosframework/platform/internal/registry"
16 "github.com/carlosframework/platform/internal/release"
17)
18
19// The cold-standby spec (2026-08-12 HA posture, Part I rung 1): the current
20// snapshot at a per-box key a runbook can name (snapshotDBKey), a sidecar
21// describing it, and a short tail of prior generations. All under control/
22// beside control/serial; platform boxes write direct S3, so no store-door
23// ACL is involved.
24const (
25 snapshotKeep = 8
26 snapshotStamp = "20060102T150405Z"
27)
28
29// Per-box keys (spec 2026-08-20 Part 1, closes #158): every platform box
30// snapshots ITS OWN registry under its own name; boxes never contend.
31// The pre-multi-region fixed keys ("control/registry-snapshot.db" etc.)
32// are still present in production buckets until both deployments roll —
33// they are abandoned, not rewritten; deletion is a rollout step, not code.
34func snapshotDBKey(box string) string { return "control/registry-snapshot/" + box + ".db" }
35func snapshotMetaKey(box string) string { return "control/registry-snapshot/" + box + ".json" }
36func snapshotGenPrefix(box string) string {
37 return "control/registry-snapshots/" + box + "/"
38}
39
40// snapshotMeta describes the object at snapshotDBKey(box). `Box` is the writing
41// box's label (per-instance hashed machine id) — a restore must check it
42// names the DEAD box, not the standby, or it is restoring the standby's own
43// empty registry. `Fingerprint` carries registry.SnapshotFingerprint (ALL
44// columns), not registry.Fingerprint (edge-reload columns only): it is only
45// ever compared against itself — the loop seeds lastFP from it and skips an
46// unchanged registry — so no reader outside this file depends on which
47// digest it is.
48type snapshotMeta struct {
49 V int `json:"v"`
50 Box string `json:"box"`
51 Fingerprint string `json:"fingerprint"`
52 CreatedAt time.Time `json:"created_at"`
53 Bytes int `json:"bytes"`
54}
55
56// registrySnapshotPass snapshots the registry into the bucket when its
57// snapshot fingerprint moved since *lastFP. One pass; the loop owns cadence.
58// *lastFP is updated only after every write (including pruning) succeeded, so
59// a partial failure is retried whole next tick.
60//
61// The gate is SnapshotFingerprint, NOT Fingerprint: Fingerprint deliberately
62// omits caps, unit, replication, environment, account, canary and
63// provisioned, which are precisely the box-scoped columns only this snapshot
64// can rebuild — gating on it meant a caps grant or a console unit stamp never
65// produced a snapshot at all.
66func registrySnapshotPass(ctx context.Context, s release.Store, reg *registry.Registry, box string, now time.Time, lastFP *string) error {
67 fp, err := reg.SnapshotFingerprint()
68 if err != nil {
69 return fmt.Errorf("snapshot: fingerprint: %w", err)
70 }
71 if fp == *lastFP {
72 return nil
73 }
74
75 dir, err := os.MkdirTemp("", "carlos-registry-snapshot")
76 if err != nil {
77 return fmt.Errorf("snapshot: %w", err)
78 }
79 defer os.RemoveAll(dir)
80 file := filepath.Join(dir, "registry.db")
81 if err := reg.SnapshotTo(file); err != nil {
82 return fmt.Errorf("snapshot: vacuum: %w", err)
83 }
84 body, err := os.ReadFile(file)
85 if err != nil {
86 return fmt.Errorf("snapshot: %w", err)
87 }
88
89 // Generation first (create-only — a same-second re-run or a clock
90 // step-back can collide on the stamp, in which case Put's ErrCASConflict
91 // is deliberately swallowed and the retained generation is KEPT rather
92 // than replaced). Be precise about what that costs: after a clock
93 // step-back WITH a registry change in between, the retained generation
94 // holds OLDER content while the current key and its sidecar hold the
95 // newer — the two are not equivalent, and we are choosing to keep the
96 // older one under that stamp. What is lost is one intermediate rung of
97 // the generation tail, never the restore target itself: current + sidecar
98 // are written unconditionally below, so a restore still gets the newest
99 // registry. Retrying under a bumped stamp would buy a slightly denser
100 // tail at the cost of a write loop against a lying clock.
101 //
102 // Then current + sidecar (replace with the CAS-retry recipe
103 // usage.UpsertEdgeDay uses), then prune.
104 genKey := snapshotGenPrefix(box) + now.UTC().Format(snapshotStamp) + ".db"
105 if _, err := s.Put(ctx, genKey, body, ""); err != nil && !errors.Is(err, release.ErrCASConflict) {
106 return fmt.Errorf("snapshot: put %s: %w", genKey, err)
107 }
108 // This guard predates per-box keys and is retained for the shared-key
109 // era and for hand-placed sidecars only (a sidecar copied or edited by
110 // an operator with a Box field that does not match this box). Under
111 // per-box keys it reads ONLY this box's own key, and the only writer
112 // that ever lands there stamps meta.Box = box — so prior.Box != box
113 // cannot occur through normal operation, INCLUDING the same-name
114 // standby-restore case the runbook worries about (a rebuilt standby
115 // reusing the dead box's machine-id before restoring: same name means
116 // prior.Box already equals box, so this stays silent). That clobber —
117 // the freshly bootstrapped standby's agent starting and writing its own
118 // empty registry over the dead box's last-good snapshot before the
119 // operator restores it — is NOT detected here; the DR runbook's Step 0
120 // ordering rule (restore before the agent is allowed to snapshot) owns
121 // that hazard instead.
122 if prior := readSnapshotMeta(ctx, s, box); prior != nil && prior.Box != "" && prior.Box != box {
123 log.Printf("agent: registry snapshot: OVERWRITING a snapshot written by box %q (this box is %q, prior created_at %s) — if this box is a standby that has not been restored yet, STOP and preserve the prior snapshot",
124 prior.Box, box, prior.CreatedAt.Format(time.RFC3339))
125 }
126 if err := putReplacing(ctx, s, snapshotDBKey(box), body); err != nil {
127 return err
128 }
129 meta := snapshotMeta{V: 1, Box: box, Fingerprint: fp, CreatedAt: now.UTC(), Bytes: len(body)}
130 metaRaw, err := json.Marshal(meta)
131 if err != nil {
132 return fmt.Errorf("snapshot: %w", err)
133 }
134 if err := putReplacing(ctx, s, snapshotMetaKey(box), metaRaw); err != nil {
135 return err
136 }
137
138 gens, err := s.List(ctx, snapshotGenPrefix(box))
139 if err != nil {
140 return fmt.Errorf("snapshot: list generations: %w", err)
141 }
142 sort.Strings(gens) // stamps are lexically chronological
143 for len(gens) > snapshotKeep {
144 if err := s.Delete(ctx, gens[0]); err != nil {
145 return fmt.Errorf("snapshot: prune %s: %w", gens[0], err)
146 }
147 gens = gens[1:]
148 }
149
150 *lastFP = fp
151 return nil
152}
153
154// seedSnapshotFingerprint reads the sidecar a previous run (or a previous
155// process on this same box) left at snapshotMetaKey(box) and returns its
156// fingerprint, so a freshly started loop's in-memory lastFP starts where the
157// bucket already is rather than empty. Without this, a crash-looping agent
158// would re-snapshot an unchanged registry on every restart and burn through
159// all snapshotKeep generations with identical copies. Any read or parse
160// failure — no sidecar yet, a corrupt object, a store that is down — returns
161// "": the loop then snapshots on its first tick, the same behavior a box
162// that has never snapshotted before gets. This must never fail the loop.
163func seedSnapshotFingerprint(ctx context.Context, s release.Store, box string) string {
164 meta := readSnapshotMeta(ctx, s, box)
165 if meta == nil {
166 return ""
167 }
168 return meta.Fingerprint
169}
170
171// readSnapshotMeta returns the sidecar currently in the bucket at this box's
172// own key, or nil when there is none, it cannot be read, or it does not
173// parse. Advisory only: both callers treat nil as "no prior snapshot" and
174// carry on, so a store outage must never fail a pass.
175func readSnapshotMeta(ctx context.Context, s release.Store, box string) *snapshotMeta {
176 raw, _, err := s.Get(ctx, snapshotMetaKey(box))
177 if err != nil {
178 return nil
179 }
180 var meta snapshotMeta
181 if err := json.Unmarshal(raw, &meta); err != nil {
182 return nil
183 }
184 return &meta
185}
186
187// registrySnapshotLoop snapshots the registry to the deployment bucket every
188// 5 minutes + jitter when it changed — rung 1 of the HA ladder (cold
189// standby restores this file). Platform-box job, retentionLoop's rules:
190// skipped on fleet boxes, disabled with a log line when there is no bucket.
191// Per-box keys (spec 2026-08-20 Part 1, closes #158): each platform box
192// writes under its own name, so N boxes sharing one deployment bucket never
193// interleave each other's current/sidecar writes.
194func registrySnapshotLoop(reg *registry.Registry) {
195 ctx := context.Background()
196 if shouldSkipPublisherElection(os.Getenv) {
197 log.Printf("agent: registry snapshot loop is a platform-box job — skipped on a fleet box")
198 return
199 }
200 store, err := openDeployStore(ctx)
201 if err != nil {
202 log.Printf("agent: registry snapshot loop disabled: %v", err)
203 return
204 }
205 box := metricsBoxLabel(os.Getenv, readMachineID())
206 log.Printf("agent: registry snapshot loop armed (5min + jitter, %s)", snapshotDBKey(box))
207 lastFP := seedSnapshotFingerprint(ctx, store, box)
208 delay := time.Minute
209 for {
210 time.Sleep(delay)
211 delay = 5*time.Minute + rand.N(time.Minute)
212 if err := registrySnapshotPass(ctx, store, reg, box, time.Now(), &lastFP); err != nil {
213 log.Printf("agent: registry snapshot pass: %v", err)
214 }
215 }
216}
217
218// putReplacing writes key regardless of prior state: create-only when
219// absent, etag-replace when present, re-reading on a lost race. Three
220// attempts, like usage.UpsertEdgeDay.
221func putReplacing(ctx context.Context, s release.Store, key string, body []byte) error {
222 for attempt := 0; attempt < 3; attempt++ {
223 _, etag, err := s.Get(ctx, key)
224 switch {
225 case errors.Is(err, os.ErrNotExist):
226 etag = ""
227 case err != nil:
228 return fmt.Errorf("snapshot: get %s: %w", key, err)
229 }
230 if _, err := s.Put(ctx, key, body, etag); err == nil {
231 return nil
232 } else if !errors.Is(err, release.ErrCASConflict) {
233 return fmt.Errorf("snapshot: put %s: %w", key, err)
234 }
235 }
236 return fmt.Errorf("snapshot: %s: lost CAS race three times", key)
237}
238