package hostbox import ( "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "reflect" "strings" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/hostwrite" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" ) type ReconcileAdapter struct { RequireTimerInventory bool DataRoot string RemoteRetire func(context.Context, hostguard.FenceTarget) error Census *EmptyInstallationCensus Reconciler Reconciler Registry *registry.Registry Inventory *StopInventoryDB Box, Enrollment, DirectoryRevision string Stop func(context.Context, registry.Route) (hostguard.FenceTarget, error) Retirement TypedRetirement } func (a ReconcileAdapter) Configure(cfg *provision.ReconcileConfig) { cfg.ClaimEnabled = a.Reconciler.Writes.Enabled cfg.ClaimInstall = a.Install cfg.ClaimRetire = a.Retire } func (a ReconcileAdapter) Install(ctx context.Context, rec provision.ClaimRecord, write func(registry.ClaimOwnership) error) error { if a.Registry == nil || a.Inventory == nil || a.Reconciler.Claims == nil || write == nil || rec.Value == nil || a.Box == "" || a.Enrollment == "" || a.DirectoryRevision == "" { return ErrUnsupported } body, etag, err := a.Reconciler.Claims.Get(ctx, rec.Key) if err != nil { return err } current := reflect.New(reflect.TypeOf(rec.Value)) if err = json.Unmarshal(body, current.Interface()); err != nil { return err } if !reflect.DeepEqual(current.Elem().Interface(), rec.Value) { return ErrChanged } inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) if err != nil { return err } // The claim inventory records where this box's files are: the route's own // paths say so (sidecars are always flat). layout := "flat" if rec.Family != "sidecar" && provision.NestedPaths(a.Reconciler.Guard.DataDir, rec.Route) { layout = "nested" } selected := RecordSelection{Host: rec.Host, Key: rec.Key, ETag: etag, Incarnation: inc, Family: rec.Family, Layout: layout, DryRun: rec.DryRun} reconciler := a.Reconciler priorCheck := reconciler.CheckRecord reconciler.CheckRecord = func(ctx context.Context, selected RecordSelection) error { _, currentETag, err := reconciler.Claims.Get(ctx, selected.Key) if err != nil { return err } if currentETag != selected.ETag { return ErrChanged } if priorCheck != nil { return priorCheck(ctx, selected) } return nil } if reconciler.CheckHeld == nil && a.Census != nil { // The hold-only path's local check is the census's own (Task 10b). reconciler.CheckHeld = a.Census.CheckHeld } if a.Inventory.remote != nil && rec.Route.Status == "landing" { if rec.DryRun { return nil } obs, err := hostclaim.Read(ctx, a.Reconciler.Claims, rec.Host) if err != nil { return err } return a.RegisterLanding(ctx, obs) } if a.Inventory.remote != nil { return a.installRemote(ctx, rec, selected, reconciler, write) } return reconciler.Install(ctx, selected, func() error { captured, err := a.Reconciler.Guard.Capture(rec.Host) if err != nil { return err } if captured.Incarnation != inc { return ErrChanged } return a.ownedRow(ctx, rec, inc, selected, captured, write) }) } // ownedRow is the typed-inventory half of a row write under the host guard // at captured (extracted unchanged from Install): the installation's // inventory (initialised by the census when none exists), one route entry // owned by captured's digest, write(owner), and the entry materialized. // Install calls it inside Reconciler.Install's selected work; MintHeld // inside a relocation install's typed work (region HA rulings 44, 46). func (a ReconcileAdapter) ownedRow(ctx context.Context, rec provision.ClaimRecord, inc string, selected RecordSelection, captured hostguard.Captured, write func(registry.ClaimOwnership) error) error { in, err := a.Inventory.ReadInstallation(ctx, rec.Host, inc, captured.Stamp) if errors.Is(err, sql.ErrNoRows) { if a.Census == nil { return ErrTypedInventory } observed, e := hostclaim.Read(ctx, a.Reconciler.Claims, rec.Host) if e != nil { return e } if observed.Claim == nil || observed.Claim.Holder == nil || observed.Claim.Holder.Incarnation != captured.Incarnation || observed.Claim.Holder.Stamp != captured.Stamp { return ErrChanged } if e = a.Census.Initialize(ctx, observed, selected); e != nil { return e } in, err = a.Inventory.ReadInstallation(ctx, rec.Host, inc, captured.Stamp) } if err != nil { return err } if !in.Complete || in.Box != a.Box || in.Enrollment != a.Enrollment || in.DirectoryRevision != a.DirectoryRevision { return ErrTypedInventory } ownerDigest := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", rec.Host, inc, captured.Stamp, a.Box))) owner := registry.ClaimOwnership{ClaimIncarnation: inc, ClaimStamp: captured.Stamp, ClaimOwner: hex.EncodeToString(ownerDigest[:])} entry := -1 for i, e := range in.Entries { if e.Kind == "route" && e.RelativePath == rec.Host { if entry != -1 || e.Ownership != owner.ClaimOwner || e.RowIdentity == "" { return ErrTypedInventory } entry = i owner.ClaimRowID = e.RowIdentity } } if entry < 0 { var id [16]byte if _, err = rand.Read(id[:]); err != nil { return err } owner.ClaimRowID = hex.EncodeToString(id[:]) entry = len(in.Entries) in.Entries = append(in.Entries, StopInventoryEntry{Kind: "route", ArtifactID: owner.ClaimRowID, ManagedRoot: "registry", RelativePath: rec.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "intended"}) if err = a.Inventory.WriteStopInventory(ctx, in); err != nil { return err } } row, err := a.Registry.Get(rec.Host) if err != nil { return err } if row == nil && in.Entries[entry].Materialization == "materialized" { return ErrTypedInventory } if row != nil && row.ClaimOwnership != owner { return registry.ErrClaimOwnership } if err = write(owner); err != nil { return err } row, err = a.Registry.Get(rec.Host) if err != nil { return err } if row == nil || row.ClaimOwnership != owner { return registry.ErrClaimOwnership } in.Entries[entry].Materialization = "materialized" return a.Inventory.WriteStopInventory(ctx, in) } // MintHeld mints, or keeps, the host's row held by op with status under the // installation captured, for a relocation target's local install (region // HA rulings 31, 44): the route reconcile would mint for rec, nested // (ruling 45), with its ClaimOwnership, status and hold_op set in one // Upsert; an existing row must already be this installation's and held by // op. It runs inside the install's typed work, first, before any file, // timer or env is written, so the census initialises the installation // from an empty box. func (a ReconcileAdapter) MintHeld(ctx context.Context, rec provision.ClaimRecord, captured hostguard.Captured, status, op string) error { if !registry.IsHold(status) || op == "" || a.Registry == nil || a.Inventory == nil || a.Box == "" || a.Enrollment == "" || a.DirectoryRevision == "" { return ErrUnsupported } inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) if err != nil { return err } if captured.Incarnation != inc || rec.Family != "instance" || !provision.NestedPaths(a.Reconciler.Guard.DataDir, rec.Route) { return ErrChanged } selected := RecordSelection{Host: rec.Host, Key: rec.Key, Incarnation: inc, Family: rec.Family, Layout: "nested"} return a.ownedRow(ctx, rec, inc, selected, captured, func(owner registry.ClaimOwnership) error { row, err := a.Registry.Get(rec.Host) if err != nil { return err } if row != nil { if row.ClaimOwnership != owner || row.HoldOp != op || !registry.IsHold(row.Status) { return registry.ErrClaimOwnership } return nil } rt := rec.Route rt.ClaimOwnership, rt.Status, rt.HoldOp = owner, status, op return a.Registry.Upsert(rt) }) } func (a ReconcileAdapter) MintHeldUnder(ctx context.Context, rec provision.ClaimRecord, captured hostguard.Captured, status, op string, work hostwrite.Work) error { if work == nil || a.Inventory == nil { return ErrUnsupported } if a.Inventory.remote == nil { return work.With(ctx, hostwrite.Identity{}, func() error { return a.MintHeld(ctx, rec, captured, status, op) }) } if !registry.IsHold(status) || op == "" || a.Registry == nil || a.Reconciler.Claims == nil || a.Box == "" || a.Enrollment == "" || a.DirectoryRevision == "" || rec.Value == nil { return ErrUnsupported } inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) if err != nil { return err } if captured.Incarnation != inc || rec.Family != "instance" || !provision.NestedPaths(a.Reconciler.Guard.DataDir, rec.Route) { return ErrChanged } body, etag, err := a.Reconciler.Claims.Get(ctx, rec.Key) if err != nil { return err } current := reflect.New(reflect.TypeOf(rec.Value)) if err = json.Unmarshal(body, current.Interface()); err != nil { return err } if !reflect.DeepEqual(current.Elem().Interface(), rec.Value) { return ErrChanged } selected := RecordSelection{Host: rec.Host, Key: rec.Key, ETag: etag, Incarnation: inc, Family: rec.Family, Layout: "nested"} if err := a.prepareRemoteInventory(ctx, selected, captured); err != nil { return err } return a.registerRemote(ctx, selected, captured, work, func(owner registry.ClaimOwnership) error { row, err := a.Registry.Get(rec.Host) if err != nil { return err } if row != nil { if row.ClaimOwnership != owner || row.HoldOp != op || !registry.IsHold(row.Status) { return registry.ErrClaimOwnership } return nil } rt := rec.Route rt.ClaimOwnership, rt.Status, rt.HoldOp = owner, status, op return a.Registry.Upsert(rt) }) } func (a ReconcileAdapter) Retire(ctx context.Context, row registry.Route) error { if a.Registry == nil || a.Inventory == nil || a.Stop == nil || !row.ClaimOwnership.Valid() { return ErrUnsupported } target, err := a.Stop(ctx, row) if err != nil { return err } if target.Host != row.Host || target.Incarnation != row.ClaimIncarnation || target.Stamp != row.ClaimStamp || target.Box != a.Box { return ErrChanged } if a.RemoteRetire != nil { return a.RemoteRetire(ctx, target) } if !component(target.FenceID) { return ErrTypedJournal } // Root's own stop inventory is the only authority for what this // retirement may park. A saved journal is root's resumable progress in its // own namespace; it can never add an artifact the inventory does not name. in, err := a.Inventory.ReadExactStopInventory(ctx, target) if err != nil { return err } if !in.Complete || in.Box != target.Box || in.Enrollment != target.Enrollment || in.DirectoryRevision != target.DirectoryRevision { return ErrTypedInventory } trustedRow, err := a.retirementRoute(row, target) if err != nil { return err } if err = ValidateAgentInventory(in, trustedRow, a.inventoryDataRoot()); err != nil { return err } inventory := RetirementInventory{Host: row.Host, Captured: hostguard.Captured{Incarnation: row.ClaimIncarnation, Stamp: row.ClaimStamp}, Ownership: row.ClaimOwner} for _, e := range in.Entries { if e.Ownership != row.ClaimOwner || (e.Materialization != "materialized" && !(a.RequireTimerInventory && e.Kind == "timer" && e.Materialization == "intended")) { return ErrTypedInventory } 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}) } if err = checkTrustedJournalDir(a.Retirement.JournalDir, true); err != nil { return err } saved, loadErr := readTrustedJournal(filepath.Join(a.Retirement.JournalDir, target.FenceID+".json")) if loadErr == nil { var journal RetirementJournal if decodeStrict(saved, &journal) != nil || journal.ID != target.FenceID || journal.Inventory.Host != row.Host || journal.Inventory.Captured != inventory.Captured || journal.Inventory.Ownership != row.ClaimOwner { return ErrTypedJournal } // Timer rows retire in the journalled step, so the registry no longer // lists them on resume. They are only ever this route's own registry // rows; every other artifact must be exactly root's inventory. var timers []Artifact for _, x := range journal.Inventory.Artifacts { if x.Kind == ArtifactTimer { if x.Root != "registry" || x.Owner != row.ClaimOwner || !strings.HasPrefix(x.Name, row.Host+"/") || !managedName(strings.TrimPrefix(x.Name, row.Host+"/")) { return ErrTypedJournal } timers = append(timers, x) } } combined, err := retirementWithTimers(inventory, timers, a.RequireTimerInventory) if err != nil { return err } want, err := NewRetirementJournal(target.FenceID, combined) if err != nil { return ErrTypedJournal } got, err := NewRetirementJournal(target.FenceID, journal.Inventory) if err != nil || !reflect.DeepEqual(want.Inventory, got.Inventory) { return ErrTypedJournal } inventory = journal.Inventory } else if !errors.Is(loadErr, os.ErrNotExist) { return loadErr } else { timers, err := a.Registry.TimersFor(row.Host) if err != nil { return err } for _, t := range timers { if t.ClaimIncarnation != row.ClaimIncarnation || t.ClaimStamp != row.ClaimStamp || t.ClaimOwner != row.ClaimOwner || t.ClaimRowID == "" || t.Registration == "" { return ErrTypedInventory } inventory, err = retirementWithTimers(inventory, []Artifact{{Kind: ArtifactTimer, Root: "registry", Name: row.Host + "/" + t.Name, Owner: row.ClaimOwner, RowID: t.ClaimRowID + "~" + t.Registration}}, a.RequireTimerInventory) if err != nil { return err } } } // FenceID is durable and unique for this exact stop and retirement scope. intent, err := NewRetirementJournal(target.FenceID, inventory) if err != nil { return err } receipt, err := a.Reconciler.Guard.FenceReceiptFor(ctx, target) if err != nil { return err } intent.Target = &target intent.StopReceipt = append([]byte(nil), receipt.Bytes...) retirement := a.Retirement retirement.Rows = RegistryRows{Registry: a.Registry, Inventory: inventory} return retirement.Run(ctx, target, intent) } func (a ReconcileAdapter) inventoryDataRoot() string { if a.DataRoot != "" { return a.DataRoot } return a.Reconciler.Guard.DataDir }