//go:build linux && rootrunner package host import ( "context" "encoding/json" "errors" "github.com/carlosframework/platform/internal/instancectl" "github.com/carlosframework/platform/internal/rootstate" "os" "path/filepath" "testing" "time" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" ) func TestPlan14CombinedBoundRetirementRootRunner(t *testing.T) { for _, unit := range []string{"tcp", "socket-program", "backing-program"} { t.Run(unit, func(t *testing.T) { plan14InstalledClaimLaunch(t, "combined:materialized:"+unit) }) } } // Installation, admission and the completed receipt are fixture authority. // The caller has already joined the real controller and stopped both real unit // invocations. This adds the production retirement composition and registry. func plan14CombinedBoundRetirement(t *testing.T, dir string, f *launchFixture, m *claimUnitMaterializer, startup bool) (func() error, func()) { t.Helper() t.Setenv("CARLOS_HOST_CLAIM", "true") target := f.target target.FenceID = "combined-retirement" observed, err := f.claims.Read(t.Context(), target.Host) if err != nil { t.Fatal(err) } target.ClaimETag, target.ClaimGen = observed.ETag, observed.Claim.Gen if target, err = f.root.Guard.BeginFence(t.Context(), target); err != nil { t.Fatal("combined fence", err) } at := time.Now().UTC() receipt := StopReceipt{V: 1, Kind: "stopped", ID: "combined-receipt", FenceID: target.FenceID, Host: target.Host, Incarnation: target.Incarnation, InstallationStamp: target.Stamp, WriterSession: target.Session, Region: target.Region, ClaimETag: target.ClaimETag, ClaimGen: target.ClaimGen, OperationID: target.Op, Phase: target.Phase, ObligationID: target.ObligationID, Box: target.Box, Enrollment: target.Enrollment, BootID: target.BootID, ControllerID: target.ControllerID, DirectoryRevision: testReceiptNumeral(target), Binding: target.DirectoryRevision, EvidenceAt: at} raw, _ := json.Marshal(receipt) if err = f.root.Guard.CompleteFence(t.Context(), target, raw, at, func(ctx context.Context, t hostguard.FenceTarget, body []byte, when time.Time) error { return verifyRetirementReceipt(ctx, hostguard.FenceReceipt{Target: t, Bytes: body, EvidenceAt: when}) }); err != nil { t.Fatal(err) } reg, err := registry.Open(filepath.Join(dir, "combined-registry.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { reg.Close() }) inventory, err := openRebootInventory(t, filepath.Join(dir, "combined-inventory.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { inventory.Close() }) b, err := f.root.unitBindings.read(target.Host) if err != nil { t.Fatal(err) } row := b.Shape.route() row.ClaimOwnership = registry.ClaimOwnership{ClaimIncarnation: target.Incarnation, ClaimStamp: target.Stamp, ClaimOwner: "combined-owner", ClaimRowID: "combined-route"} if err = reg.Upsert(row); err != nil { t.Fatal(err) } timer := registry.Timer{Host: target.Host, Name: "backup", Kind: "cron", Registration: "timer-1", ClaimOwnership: row.ClaimOwnership} timer.ClaimRowID = "timer-1" if err = reg.ReplaceTimers(target.Host, []registry.Timer{timer}); err != nil { t.Fatal(err) } in := hostbox.StopInventory{Complete: true, Host: target.Host, Incarnation: target.Incarnation, InstallationStamp: target.Stamp, Box: target.Box, Enrollment: target.Enrollment, DirectoryRevision: target.DirectoryRevision, Entries: []hostbox.StopInventoryEntry{ {Kind: "route", ArtifactID: "route", ManagedRoot: "registry", RelativePath: target.Host, RowIdentity: row.ClaimRowID, Ownership: row.ClaimOwner, Materialization: "materialized"}, {Kind: "timer", ArtifactID: "timer", ManagedRoot: "registry", RelativePath: target.Host + "/backup", RowIdentity: "timer-1", Ownership: row.ClaimOwner, Materialization: "materialized"}, }} if err = inventory.WriteStopInventory(t.Context(), in); err != nil { t.Fatal(err) } journal := filepath.Join(dir, "combined-retirement") root := ClaimRootHTTP{Stop: ClaimStopHTTP{Coordinator: ClaimStopCoordinator{Guard: f.root.Guard}}, Roots: map[string]string{"data": "/data"}, Reconcile: hostbox.ReconcileAdapter{Inventory: inventory, Registry: reg, Retirement: hostbox.TypedRetirement{JournalDir: journal}}, retireBoundUnit: func(ctx context.Context, t hostguard.FenceTarget) error { return retireClaimBoundUnit(ctx, t, f.root.unitBindings, *m, f.root.Units.Gone) }} crashed := false replace := m.replace if startup { m.replace = func(unit string, id rootstate.FileIdentity, raw []byte) error { if !crashed { crashed = true return os.ErrClosed } return replace(unit, id, raw) } } restarted := false retire := func() error { err := root.retire(t.Context(), target) if startup && crashed && !restarted { if !errors.Is(err, os.ErrClosed) { t.Fatal("crash boundary", err) } partial, e := m.load(target.Host) if e != nil || !partial.Retiring || partial.Retired { t.Fatal("missing retirement intention", e) } oldOpen, oldMaterials, oldShapes := openClaimUnitBindings, openClaimUnitMaterializations, productionClaimUnitShapes defer func() { openClaimUnitBindings, openClaimUnitMaterializations, productionClaimUnitShapes = oldOpen, oldMaterials, oldShapes }() openClaimUnitBindings = func() (rootstate.Store, error) { return rootstate.Open(filepath.Join(dir, "unit-bindings"), false, false) } openClaimUnitMaterializations = func() (rootstate.Store, error) { return rootstate.Open(filepath.Join(dir, "unit-materializations"), false, false) } productionClaimUnitShapes = func() (map[string]claimUnitShape, error) { return nil, os.ErrPermission } copyLaunch := *f.root copyLaunch.Units = f.root.Units.(claimUnitDispatch).base copyRoot := root copyRoot.Stop.Coordinator.Kernel = claimUnitStopKernel{Units: copyLaunch.Units.(instancectl.ClaimUnits)} wrapped := ClaimRootWithLaunch{Root: copyRoot, Launch: ©Launch} assembly := &ClaimAssembly{} if e = bindProductionClaimUnits(t.Context(), &wrapped, assembly); e != nil { t.Fatal("startup crash replay", e) } defer assembly.Close(t.Context()) saved, e := wrapped.Launch.unitBindings.read(target.Host) if e != nil || !saved.Retired { t.Fatal("startup did not finish bound retirement", saved, e) } if _, _, e = claimLaunchPlacement(wrapped.Launch.Units, "ordinary.example"); e != nil { t.Fatal("ordinary host blocked", e) } restarted = true err = nil } records, e := generationRetirementRecords(t.Context(), &rollbackDisk{dir: journal}) if e == nil && len(records) == 1 && records[0].Complete { return nil } return err } verify := func() { got, e := reg.Get(target.Host) if e != nil || got != nil { t.Fatal("route not retired", got, e) } timers, e := reg.TimersFor(target.Host) if e != nil || len(timers) != 0 { t.Fatal("timer not retired", timers, e) } for _, leaf := range []string{"launch.json", "permit.json", "exec.sock"} { if _, e := os.Lstat(filepath.Join("/run/carlos-control/exe/.claim", provision.InstanceID(target.Host), leaf)); !os.IsNotExist(e) { t.Fatal("runtime environment retained", leaf, e) } } before, e := os.ReadFile(filepath.Join(journal, target.FenceID+".json")) if e != nil { t.Fatal(e) } row.ClaimStamp++ row.ClaimRowID = "successor-route" row.ClaimOwner = "successor-owner" timer.ClaimOwnership = row.ClaimOwnership timer.ClaimRowID = "successor-timer" timer.Registration = "successor-timer" if e = reg.Upsert(row); e != nil { t.Fatal(e) } if e = reg.ReplaceTimers(target.Host, []registry.Timer{timer}); e != nil { t.Fatal(e) } if e = retire(); e != nil { t.Fatal("local completed replay", e) } got, e = reg.Get(target.Host) if e != nil || got == nil || got.ClaimRowID != row.ClaimRowID { t.Fatal("successor route changed", e) } timers, e = reg.TimersFor(target.Host) if e != nil || len(timers) != 1 || timers[0].Registration != timer.Registration { t.Fatal("successor timer changed", e) } after, e := os.ReadFile(filepath.Join(journal, target.FenceID+".json")) if e != nil || string(before) != string(after) { t.Fatal("completed retirement changed", e) } } return retire, verify } func TestPlan14BoundStartupRetirementRootRunner(t *testing.T) { plan14InstalledClaimLaunch(t, "startup:combined:materialized:tcp") }