carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostbox
2
3import (
4 "context"
5 "crypto/rand"
6 "crypto/sha256"
7 "database/sql"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "os"
13 "path/filepath"
14 "reflect"
15 "strings"
16
17 "github.com/carlosframework/platform/internal/hostclaim"
18 "github.com/carlosframework/platform/internal/hostguard"
19 "github.com/carlosframework/platform/internal/hostwrite"
20 "github.com/carlosframework/platform/internal/provision"
21 "github.com/carlosframework/platform/internal/registry"
22)
23
24type ReconcileAdapter struct {
25 RequireTimerInventory bool
26 DataRoot string
27 RemoteRetire func(context.Context, hostguard.FenceTarget) error
28 Census *EmptyInstallationCensus
29 Reconciler Reconciler
30 Registry *registry.Registry
31 Inventory *StopInventoryDB
32 Box, Enrollment, DirectoryRevision string
33 Stop func(context.Context, registry.Route) (hostguard.FenceTarget, error)
34 Retirement TypedRetirement
35}
36
37func (a ReconcileAdapter) Configure(cfg *provision.ReconcileConfig) {
38 cfg.ClaimEnabled = a.Reconciler.Writes.Enabled
39 cfg.ClaimInstall = a.Install
40 cfg.ClaimRetire = a.Retire
41}
42func (a ReconcileAdapter) Install(ctx context.Context, rec provision.ClaimRecord, write func(registry.ClaimOwnership) error) error {
43 if a.Registry == nil || a.Inventory == nil || a.Reconciler.Claims == nil || write == nil || rec.Value == nil || a.Box == "" || a.Enrollment == "" || a.DirectoryRevision == "" {
44 return ErrUnsupported
45 }
46 body, etag, err := a.Reconciler.Claims.Get(ctx, rec.Key)
47 if err != nil {
48 return err
49 }
50 current := reflect.New(reflect.TypeOf(rec.Value))
51 if err = json.Unmarshal(body, current.Interface()); err != nil {
52 return err
53 }
54 if !reflect.DeepEqual(current.Elem().Interface(), rec.Value) {
55 return ErrChanged
56 }
57 inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt)
58 if err != nil {
59 return err
60 }
61 // The claim inventory records where this box's files are: the route's own
62 // paths say so (sidecars are always flat).
63 layout := "flat"
64 if rec.Family != "sidecar" && provision.NestedPaths(a.Reconciler.Guard.DataDir, rec.Route) {
65 layout = "nested"
66 }
67 selected := RecordSelection{Host: rec.Host, Key: rec.Key, ETag: etag, Incarnation: inc, Family: rec.Family, Layout: layout, DryRun: rec.DryRun}
68 reconciler := a.Reconciler
69 priorCheck := reconciler.CheckRecord
70 reconciler.CheckRecord = func(ctx context.Context, selected RecordSelection) error {
71 _, currentETag, err := reconciler.Claims.Get(ctx, selected.Key)
72 if err != nil {
73 return err
74 }
75 if currentETag != selected.ETag {
76 return ErrChanged
77 }
78 if priorCheck != nil {
79 return priorCheck(ctx, selected)
80 }
81 return nil
82 }
83 if reconciler.CheckHeld == nil && a.Census != nil {
84 // The hold-only path's local check is the census's own (Task 10b).
85 reconciler.CheckHeld = a.Census.CheckHeld
86 }
87 if a.Inventory.remote != nil && rec.Route.Status == "landing" {
88 if rec.DryRun {
89 return nil
90 }
91 obs, err := hostclaim.Read(ctx, a.Reconciler.Claims, rec.Host)
92 if err != nil {
93 return err
94 }
95 return a.RegisterLanding(ctx, obs)
96 }
97 if a.Inventory.remote != nil {
98 return a.installRemote(ctx, rec, selected, reconciler, write)
99 }
100 return reconciler.Install(ctx, selected, func() error {
101 captured, err := a.Reconciler.Guard.Capture(rec.Host)
102 if err != nil {
103 return err
104 }
105 if captured.Incarnation != inc {
106 return ErrChanged
107 }
108 return a.ownedRow(ctx, rec, inc, selected, captured, write)
109 })
110}
111
112// ownedRow is the typed-inventory half of a row write under the host guard
113// at captured (extracted unchanged from Install): the installation's
114// inventory (initialised by the census when none exists), one route entry
115// owned by captured's digest, write(owner), and the entry materialized.
116// Install calls it inside Reconciler.Install's selected work; MintHeld
117// inside a relocation install's typed work (region HA rulings 44, 46).
118func (a ReconcileAdapter) ownedRow(ctx context.Context, rec provision.ClaimRecord, inc string, selected RecordSelection, captured hostguard.Captured, write func(registry.ClaimOwnership) error) error {
119 in, err := a.Inventory.ReadInstallation(ctx, rec.Host, inc, captured.Stamp)
120 if errors.Is(err, sql.ErrNoRows) {
121 if a.Census == nil {
122 return ErrTypedInventory
123 }
124 observed, e := hostclaim.Read(ctx, a.Reconciler.Claims, rec.Host)
125 if e != nil {
126 return e
127 }
128 if observed.Claim == nil || observed.Claim.Holder == nil || observed.Claim.Holder.Incarnation != captured.Incarnation || observed.Claim.Holder.Stamp != captured.Stamp {
129 return ErrChanged
130 }
131 if e = a.Census.Initialize(ctx, observed, selected); e != nil {
132 return e
133 }
134 in, err = a.Inventory.ReadInstallation(ctx, rec.Host, inc, captured.Stamp)
135 }
136 if err != nil {
137 return err
138 }
139 if !in.Complete || in.Box != a.Box || in.Enrollment != a.Enrollment || in.DirectoryRevision != a.DirectoryRevision {
140 return ErrTypedInventory
141 }
142 ownerDigest := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", rec.Host, inc, captured.Stamp, a.Box)))
143 owner := registry.ClaimOwnership{ClaimIncarnation: inc, ClaimStamp: captured.Stamp, ClaimOwner: hex.EncodeToString(ownerDigest[:])}
144 entry := -1
145 for i, e := range in.Entries {
146 if e.Kind == "route" && e.RelativePath == rec.Host {
147 if entry != -1 || e.Ownership != owner.ClaimOwner || e.RowIdentity == "" {
148 return ErrTypedInventory
149 }
150 entry = i
151 owner.ClaimRowID = e.RowIdentity
152 }
153 }
154 if entry < 0 {
155 var id [16]byte
156 if _, err = rand.Read(id[:]); err != nil {
157 return err
158 }
159 owner.ClaimRowID = hex.EncodeToString(id[:])
160 entry = len(in.Entries)
161 in.Entries = append(in.Entries, StopInventoryEntry{Kind: "route", ArtifactID: owner.ClaimRowID, ManagedRoot: "registry", RelativePath: rec.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "intended"})
162 if err = a.Inventory.WriteStopInventory(ctx, in); err != nil {
163 return err
164 }
165 }
166 row, err := a.Registry.Get(rec.Host)
167 if err != nil {
168 return err
169 }
170 if row == nil && in.Entries[entry].Materialization == "materialized" {
171 return ErrTypedInventory
172 }
173 if row != nil && row.ClaimOwnership != owner {
174 return registry.ErrClaimOwnership
175 }
176 if err = write(owner); err != nil {
177 return err
178 }
179 row, err = a.Registry.Get(rec.Host)
180 if err != nil {
181 return err
182 }
183 if row == nil || row.ClaimOwnership != owner {
184 return registry.ErrClaimOwnership
185 }
186 in.Entries[entry].Materialization = "materialized"
187 return a.Inventory.WriteStopInventory(ctx, in)
188}
189
190// MintHeld mints, or keeps, the host's row held by op with status under the
191// installation captured, for a relocation target's local install (region
192// HA rulings 31, 44): the route reconcile would mint for rec, nested
193// (ruling 45), with its ClaimOwnership, status and hold_op set in one
194// Upsert; an existing row must already be this installation's and held by
195// op. It runs inside the install's typed work, first, before any file,
196// timer or env is written, so the census initialises the installation
197// from an empty box.
198func (a ReconcileAdapter) MintHeld(ctx context.Context, rec provision.ClaimRecord, captured hostguard.Captured, status, op string) error {
199 if !registry.IsHold(status) || op == "" || a.Registry == nil || a.Inventory == nil || a.Box == "" || a.Enrollment == "" || a.DirectoryRevision == "" {
200 return ErrUnsupported
201 }
202 inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt)
203 if err != nil {
204 return err
205 }
206 if captured.Incarnation != inc || rec.Family != "instance" || !provision.NestedPaths(a.Reconciler.Guard.DataDir, rec.Route) {
207 return ErrChanged
208 }
209 selected := RecordSelection{Host: rec.Host, Key: rec.Key, Incarnation: inc, Family: rec.Family, Layout: "nested"}
210 return a.ownedRow(ctx, rec, inc, selected, captured, func(owner registry.ClaimOwnership) error {
211 row, err := a.Registry.Get(rec.Host)
212 if err != nil {
213 return err
214 }
215 if row != nil {
216 if row.ClaimOwnership != owner || row.HoldOp != op || !registry.IsHold(row.Status) {
217 return registry.ErrClaimOwnership
218 }
219 return nil
220 }
221 rt := rec.Route
222 rt.ClaimOwnership, rt.Status, rt.HoldOp = owner, status, op
223 return a.Registry.Upsert(rt)
224 })
225}
226
227func (a ReconcileAdapter) MintHeldUnder(ctx context.Context, rec provision.ClaimRecord, captured hostguard.Captured, status, op string, work hostwrite.Work) error {
228 if work == nil || a.Inventory == nil {
229 return ErrUnsupported
230 }
231 if a.Inventory.remote == nil {
232 return work.With(ctx, hostwrite.Identity{}, func() error { return a.MintHeld(ctx, rec, captured, status, op) })
233 }
234 if !registry.IsHold(status) || op == "" || a.Registry == nil || a.Reconciler.Claims == nil || a.Box == "" || a.Enrollment == "" || a.DirectoryRevision == "" || rec.Value == nil {
235 return ErrUnsupported
236 }
237 inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt)
238 if err != nil {
239 return err
240 }
241 if captured.Incarnation != inc || rec.Family != "instance" || !provision.NestedPaths(a.Reconciler.Guard.DataDir, rec.Route) {
242 return ErrChanged
243 }
244 body, etag, err := a.Reconciler.Claims.Get(ctx, rec.Key)
245 if err != nil {
246 return err
247 }
248 current := reflect.New(reflect.TypeOf(rec.Value))
249 if err = json.Unmarshal(body, current.Interface()); err != nil {
250 return err
251 }
252 if !reflect.DeepEqual(current.Elem().Interface(), rec.Value) {
253 return ErrChanged
254 }
255 selected := RecordSelection{Host: rec.Host, Key: rec.Key, ETag: etag, Incarnation: inc, Family: rec.Family, Layout: "nested"}
256 if err := a.prepareRemoteInventory(ctx, selected, captured); err != nil {
257 return err
258 }
259 return a.registerRemote(ctx, selected, captured, work, func(owner registry.ClaimOwnership) error {
260 row, err := a.Registry.Get(rec.Host)
261 if err != nil {
262 return err
263 }
264 if row != nil {
265 if row.ClaimOwnership != owner || row.HoldOp != op || !registry.IsHold(row.Status) {
266 return registry.ErrClaimOwnership
267 }
268 return nil
269 }
270 rt := rec.Route
271 rt.ClaimOwnership, rt.Status, rt.HoldOp = owner, status, op
272 return a.Registry.Upsert(rt)
273 })
274}
275
276func (a ReconcileAdapter) Retire(ctx context.Context, row registry.Route) error {
277 if a.Registry == nil || a.Inventory == nil || a.Stop == nil || !row.ClaimOwnership.Valid() {
278 return ErrUnsupported
279 }
280 target, err := a.Stop(ctx, row)
281 if err != nil {
282 return err
283 }
284 if target.Host != row.Host || target.Incarnation != row.ClaimIncarnation || target.Stamp != row.ClaimStamp || target.Box != a.Box {
285 return ErrChanged
286 }
287
288 if a.RemoteRetire != nil {
289 return a.RemoteRetire(ctx, target)
290 }
291 if !component(target.FenceID) {
292 return ErrTypedJournal
293 }
294 // Root's own stop inventory is the only authority for what this
295 // retirement may park. A saved journal is root's resumable progress in its
296 // own namespace; it can never add an artifact the inventory does not name.
297 in, err := a.Inventory.ReadExactStopInventory(ctx, target)
298 if err != nil {
299 return err
300 }
301 if !in.Complete || in.Box != target.Box || in.Enrollment != target.Enrollment || in.DirectoryRevision != target.DirectoryRevision {
302 return ErrTypedInventory
303 }
304 trustedRow, err := a.retirementRoute(row, target)
305 if err != nil {
306 return err
307 }
308 if err = ValidateAgentInventory(in, trustedRow, a.inventoryDataRoot()); err != nil {
309 return err
310 }
311 inventory := RetirementInventory{Host: row.Host, Captured: hostguard.Captured{Incarnation: row.ClaimIncarnation, Stamp: row.ClaimStamp}, Ownership: row.ClaimOwner}
312 for _, e := range in.Entries {
313 if e.Ownership != row.ClaimOwner || (e.Materialization != "materialized" && !(a.RequireTimerInventory && e.Kind == "timer" && e.Materialization == "intended")) {
314 return ErrTypedInventory
315 }
316 inventory.Artifacts = append(inventory.Artifacts, Artifact{AllowAbsent: e.Kind == "timer" && (e.TimerRetired || e.Materialization == "intended"), Kind: ArtifactKind(e.Kind), Root: e.ManagedRoot, Name: e.RelativePath, Owner: e.Ownership, RowID: e.RowIdentity, Identity: e.Identity, Dev: e.Device, Ino: e.Inode, Type: e.Type})
317 }
318 if err = checkTrustedJournalDir(a.Retirement.JournalDir, true); err != nil {
319 return err
320 }
321 saved, loadErr := readTrustedJournal(filepath.Join(a.Retirement.JournalDir, target.FenceID+".json"))
322 if loadErr == nil {
323 var journal RetirementJournal
324 if decodeStrict(saved, &journal) != nil || journal.ID != target.FenceID || journal.Inventory.Host != row.Host || journal.Inventory.Captured != inventory.Captured || journal.Inventory.Ownership != row.ClaimOwner {
325 return ErrTypedJournal
326 }
327 // Timer rows retire in the journalled step, so the registry no longer
328 // lists them on resume. They are only ever this route's own registry
329 // rows; every other artifact must be exactly root's inventory.
330 var timers []Artifact
331 for _, x := range journal.Inventory.Artifacts {
332 if x.Kind == ArtifactTimer {
333 if x.Root != "registry" || x.Owner != row.ClaimOwner || !strings.HasPrefix(x.Name, row.Host+"/") || !managedName(strings.TrimPrefix(x.Name, row.Host+"/")) {
334 return ErrTypedJournal
335 }
336 timers = append(timers, x)
337 }
338 }
339 combined, err := retirementWithTimers(inventory, timers, a.RequireTimerInventory)
340 if err != nil {
341 return err
342 }
343 want, err := NewRetirementJournal(target.FenceID, combined)
344 if err != nil {
345 return ErrTypedJournal
346 }
347 got, err := NewRetirementJournal(target.FenceID, journal.Inventory)
348 if err != nil || !reflect.DeepEqual(want.Inventory, got.Inventory) {
349 return ErrTypedJournal
350 }
351 inventory = journal.Inventory
352 } else if !errors.Is(loadErr, os.ErrNotExist) {
353 return loadErr
354 } else {
355 timers, err := a.Registry.TimersFor(row.Host)
356 if err != nil {
357 return err
358 }
359 for _, t := range timers {
360 if t.ClaimIncarnation != row.ClaimIncarnation || t.ClaimStamp != row.ClaimStamp || t.ClaimOwner != row.ClaimOwner || t.ClaimRowID == "" || t.Registration == "" {
361 return ErrTypedInventory
362 }
363 inventory, err = retirementWithTimers(inventory, []Artifact{{Kind: ArtifactTimer, Root: "registry", Name: row.Host + "/" + t.Name, Owner: row.ClaimOwner, RowID: t.ClaimRowID + "~" + t.Registration}}, a.RequireTimerInventory)
364 if err != nil {
365 return err
366 }
367 }
368 }
369
370 // FenceID is durable and unique for this exact stop and retirement scope.
371 intent, err := NewRetirementJournal(target.FenceID, inventory)
372 if err != nil {
373 return err
374 }
375
376 receipt, err := a.Reconciler.Guard.FenceReceiptFor(ctx, target)
377 if err != nil {
378 return err
379 }
380 intent.Target = &target
381 intent.StopReceipt = append([]byte(nil), receipt.Bytes...)
382 retirement := a.Retirement
383 retirement.Rows = RegistryRows{Registry: a.Registry, Inventory: inventory}
384 return retirement.Run(ctx, target, intent)
385}
386
387func (a ReconcileAdapter) inventoryDataRoot() string {
388 if a.DataRoot != "" {
389 return a.DataRoot
390 }
391 return a.Reconciler.Guard.DataDir
392}
393