host: a relocation target mints its held row through the typed census and installs files, env and wake state under main's WorkSource, then the held config and the pinned release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 files changed,
+1708
−91
internal/host/claimcensus.go+8 −1internal/host/relocationassembly.go+19 −0internal/host/relocationcensus_test.go+18 −9internal/host/relocationheldstage_census_test.go+18 −0internal/host/relocationinstall.go+447 −6internal/host/relocationinstall_linux_test.go+643 −0internal/host/relocationinstall_test.go+61 −0internal/hostbox/reconcile_adapter.go+103 −55internal/hostbox/reconcile_adapter_mint_test.go+134 −0internal/hostfiles/present.go+70 −0internal/hostfiles/present_test.go+128 −0internal/provision/reconcile.go+6 −6internal/provision/reconcile_test.go+13 −13internal/registry/registry.go+1 −1internal/registry/relocating_test.go+39 −0
diff --git a/internal/host/claimcensus.go b/internal/host/claimcensus.go| index 4d6bb8aa0..dbe24ae62 100644 |
| --- a/internal/host/claimcensus.go |
| +++ b/internal/host/claimcensus.go |
| @@ -49,7 +49,14 @@ func claimDataCensus(dataDir string) []string { |
| // the claim's: install and attempt journals, release positions and the |
| // build's scratch, every one keyed by op id under relocate/ and swept |
| // once its operation is over. No installation artifact lives there. |
| - return []string{".hostclaims", ".hostexec", "hostguard", "relocate", hostbox.CensusFile(reg), hostbox.CensusFile(reg + "-wal"), hostbox.CensusFile(reg + "-shm")} |
| + // Moves' held-stage relay (N11, until moves ask M-K25 classifies it): |
| + // requests and acks are keyed by host, validated field by field by |
| + // root against the claim before any effect, and never an installation |
| + // artifact. A relocation target whose stage is blocked leaves its |
| + // request there (M3). |
| + stage := claimCensusRel(dataDir, release.HeldStageDir(dataDir)) |
| + staged := claimCensusRel(dataDir, release.HeldStagedDir(dataDir)) |
| + return []string{".hostclaims", ".hostexec", "hostguard", "relocate", stage, staged, hostbox.CensusFile(reg), hostbox.CensusFile(reg + "-wal"), hostbox.CensusFile(reg + "-shm")} |
| } |
| func claimOptCensus(optDir string) []string { |
diff --git a/internal/host/relocationassembly.go b/internal/host/relocationassembly.go| index e2089018b..dc710e43f 100644 |
| --- a/internal/host/relocationassembly.go |
| +++ b/internal/host/relocationassembly.go |
| @@ -3,12 +3,14 @@ package host |
| import ( |
| "context" |
| "crypto/ecdh" |
| + "crypto/ed25519" |
| "errors" |
| "fmt" |
| "path/filepath" |
| "time" |
| "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/controlsock" |
| "github.com/carlosframework/platform/internal/genselect" |
| "github.com/carlosframework/platform/internal/hostactors" |
| @@ -16,6 +18,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostclaim" |
| execctl "github.com/carlosframework/platform/internal/hostexec" |
| "github.com/carlosframework/platform/internal/hostrelocate" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -55,6 +58,7 @@ type RelocationAssemblyConfig struct { |
| Region, LeaseRegion string |
| Registry *registry.Registry |
| DataDir string // hostguard.Guard.DataDir, the managed data root |
| + OptDir string // the box's opt root: the version store and live/<host> |
| WorkDir string // the build's scratch: <DataDir>/relocate/build |
| ReleaseDir string // <DataDir>/relocate/release |
| SecretsKey *ecdh.PrivateKey |
| @@ -67,6 +71,18 @@ type RelocationAssemblyConfig struct { |
| // writer's stamp, not only at install_stamp (ruling 58). |
| LaterProbeAttempts bool |
| Now func() time.Time |
| + // ReleasePub and Ratchet are §4.1's evidence for the held stage |
| + // (release.MaterializeHeldUnder): the release signing key and the |
| + // agent's pointer digest ratchet, as its held-stage pass takes them |
| + // (heldStagePass). Task 10 passes the agent's. |
| + ReleasePub ed25519.PublicKey |
| + Ratchet *release.DigestRatchet |
| + |
| + // render and materialize are appconfig.RenderHeldUnder and |
| + // release.MaterializeHeldUnder (with ReleasePub and Ratchet bound); |
| + // nil is those. Tests replace them. |
| + render func(context.Context, release.Store, *ecdh.PrivateKey, string, registry.Route, string, string, func(appconfig.HeldRender) (hostwrite.Work, error)) (appconfig.HeldRender, error) |
| + materialize func(context.Context, release.Store, string, registry.Route, string, *release.ProofGate, hostwrite.Work, func(func() error) error) (*release.Manifest, error) |
| } |
| // RelocationEffects binds every provider to this bound assembly. ack is |
| @@ -109,6 +125,9 @@ func (a ClaimAssembly) RelocationEffects(cfg RelocationAssemblyConfig, ack func( |
| Positions: ReleasePositionsFile{Dir: cfg.ReleaseDir}, |
| PreflightKind: a.relocationPreflight(cfg), |
| BuildP: a.relocationBuild(cfg), |
| + Install: a.relocationInstall(cfg), |
| + VerifyLocal: a.relocationVerifyLocal(cfg), |
| + Receipt: a.relocationReceipt(cfg), |
| } |
| } |
diff --git a/internal/host/relocationcensus_test.go b/internal/host/relocationcensus_test.go| index d8ed9ea66..174c73b6a 100644 |
| --- a/internal/host/relocationcensus_test.go |
| +++ b/internal/host/relocationcensus_test.go |
| @@ -21,6 +21,20 @@ import ( |
| // the directory as the claim classifies its own journals); without the |
| // classification the same tree refuses, which is the defect. |
| func TestCensusInitializesWithOldRelocationJournals(t *testing.T) { |
| + censusWithSharedFiles(t, "relocate", map[string]string{ |
| + "relocate/ops/op-old/install.json": `{"v":1,"host":"old.example","op":"op-old"}`, |
| + "relocate/ops/op-old/attempts.json": `{"v":1,"host":"old.example","op":"op-old"}`, |
| + "relocate/release/op-old.json": `{"v":1,"request":{"Host":"old.example"}}`, |
| + "relocate/build/op-old/old.example.db": "scratch", |
| + }) |
| +} |
| + |
| +// censusWithSharedFiles plants files under a production box's data root |
| +// and checks an empty installation of another host initialises with them, |
| +// and refuses once the shared entry named classified is taken out of |
| +// claimGlobalSharedPaths. |
| +func censusWithSharedFiles(t *testing.T, classified string, files map[string]string) { |
| + t.Helper() |
| ctx := t.Context() |
| r := claimCensusBox(t) |
| reg, err := registry.Open(filepath.Join(r.DataDir, "registry.db")) |
| @@ -38,12 +52,7 @@ func TestCensusInitializesWithOldRelocationJournals(t *testing.T) { |
| t.Fatal(err) |
| } |
| defer launches.Close() |
| - for path, body := range map[string]string{ |
| - "relocate/ops/op-old/install.json": `{"v":1,"host":"old.example","op":"op-old"}`, |
| - "relocate/ops/op-old/attempts.json": `{"v":1,"host":"old.example","op":"op-old"}`, |
| - "relocate/release/op-old.json": `{"v":1,"request":{"Host":"old.example"}}`, |
| - "relocate/build/op-old/old.example.db": "scratch", |
| - } { |
| + for path, body := range files { |
| full := filepath.Join(r.DataDir, filepath.FromSlash(path)) |
| if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil { |
| t.Fatal(err) |
| @@ -74,15 +83,15 @@ func TestCensusInitializesWithOldRelocationJournals(t *testing.T) { |
| unclassified.SharedPaths = map[string][]string{} |
| for root, entries := range claimGlobalSharedPaths(r) { |
| for _, e := range entries { |
| - if e != "relocate" { |
| + if e != classified { |
| unclassified.SharedPaths[root] = append(unclassified.SharedPaths[root], e) |
| } |
| } |
| } |
| if err := unclassified.Initialize(ctx, obs, selected); !errors.Is(err, hostbox.ErrTypedInventory) { |
| - t.Fatalf("without N9 the journals were accepted: %v", err) |
| + t.Fatalf("without %s classified the files were accepted: %v", classified, err) |
| } |
| if err := census.Initialize(ctx, obs, selected); err != nil { |
| - t.Fatalf("initialisation with old relocation journals: %v", err) |
| + t.Fatalf("initialisation with %s present: %v", classified, err) |
| } |
| } |
diff --git a/internal/host/relocationheldstage_census_test.go b/internal/host/relocationheldstage_census_test.go| new file mode 100644 |
| index 000000000..6ada890e6 |
| --- /dev/null |
| +++ b/internal/host/relocationheldstage_census_test.go |
| @@ -0,0 +1,18 @@ |
| +//go:build unix |
| + |
| +package host |
| + |
| +import "testing" |
| + |
| +// K25, N11: a relocation target whose stage is blocked leaves moves' |
| +// held-stage request, keyed by its host, and root's pass leaves an ack |
| +// beside it. Another host's empty installation still initialises on the |
| +// box: the relay's two directories are census-opaque. |
| +func TestCensusInitializesWithAHeldStageRequest(t *testing.T) { |
| + censusWithSharedFiles(t, "moves/held-stage", map[string]string{ |
| + "moves/held-stage/other.example.json": `{"account":"acct","app":"app","host":"other.example","version":"v1","op":"op-1","box":"box"}`, |
| + }) |
| + censusWithSharedFiles(t, "moves/held-staged", map[string]string{ |
| + "moves/held-staged/other.example.json": `{"version":"v1","op":"op-1","at":"2026-10-07T00:00:00Z"}`, |
| + }) |
| +} |
diff --git a/internal/host/relocationinstall.go b/internal/host/relocationinstall.go| index 74206d16d..84299ceed 100644 |
| --- a/internal/host/relocationinstall.go |
| +++ b/internal/host/relocationinstall.go |
| @@ -2,23 +2,40 @@ package host |
| import ( |
| "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| "fmt" |
| + "os" |
| + "path/filepath" |
| "slices" |
| "strconv" |
| + "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostfiles" |
| "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/hostrelocate" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -// relocationActionInstall is the install authority's action for the |
| -// target's guard activation, its build writes and its local install |
| -// (ruling 46). The claim's Policy admits no action for relocating (R1); |
| -// main's Local is asked for this one until claim ask C9 moves the install |
| -// into the claim. |
| -const relocationActionInstall = "install" |
| +// The install authority's actions (ruling 46): the target's guard |
| +// activation, its build writes and its local install; its held config; |
| +// its held release. The claim's Policy admits none of them for relocating |
| +// (R1); relocationInstallPolicy does, on main's WorkSource, until claim |
| +// ask C9 moves them into the claim. |
| +const ( |
| + relocationActionInstall = "install" |
| + relocationActionHeldConfig = "held-config" |
| + relocationActionHeldRelease = "held-release" |
| +) |
| func relocationInstallStamp(c *hostclaim.Claim) (uint64, error) { |
| stamp, err := strconv.ParseUint(c.Op.Journal["install_stamp"], 10, 64) |
| @@ -152,3 +169,427 @@ func (a ClaimAssembly) relocationBuildAuthority(cfg RelocationAssemblyConfig, o |
| return relocationInstallLocal(ctx, src, c, captured) |
| } |
| } |
| + |
| +// relocationInstallJournal is the box's durable record of its install for |
| +// one operation; its digest is the receipt's journal_sha256. |
| +type relocationInstallJournal struct { |
| + V int `json:"v"` |
| + Host string `json:"host"` |
| + Op string `json:"op"` |
| + Box string `json:"box"` |
| + Enrollment string `json:"enrollment"` |
| + InstallStamp uint64 `json:"install_stamp"` |
| + Prepared string `json:"prepared"` |
| + Steps map[string]string `json:"steps"` // row, files, env, wake, render, key, materialize |
| +} |
| + |
| +// relocationOpDir is the operation's local journal directory (ruling 44): |
| +// keyed by the op id, never by the host, so no region HA file under a |
| +// managed root names a host and the empty-installation census never sees |
| +// one (N9 makes relocate/ opaque). |
| +func relocationOpDir(dataDir, op string) (string, error) { |
| + if dataDir == "" || !validOpID(op) { |
| + return "", ErrClaimStopUnavailable |
| + } |
| + return filepath.Join(dataDir, "relocate", "ops", op), nil |
| +} |
| + |
| +func relocationInstallPath(dataDir, op string) (string, error) { |
| + dir, err := relocationOpDir(dataDir, op) |
| + if err != nil { |
| + return "", err |
| + } |
| + return filepath.Join(dir, "install.json"), nil |
| +} |
| + |
| +// relocationRecord reads the host's authoritative record and the route |
| +// this box mints for it (ruling 45): always the nested layout, which the |
| +// isolated launch requires, so only a hibernating, path-deriving instance |
| +// record relocates. |
| +func relocationRecord(ctx context.Context, dataDir string, claims release.Store, o hostclaim.Observed) (provision.ClaimRecord, error) { |
| + if o.Claim == nil || o.Claim.Holder == nil { |
| + return provision.ClaimRecord{}, hostbox.ErrChanged |
| + } |
| + rec, err := hostops.ReadRecord(ctx, claims, o.Claim.Holder.Key) |
| + if err != nil { |
| + return provision.ClaimRecord{}, err |
| + } |
| + var record provision.Record |
| + if err := json.Unmarshal(rec.Body, &record); err != nil { |
| + return provision.ClaimRecord{}, err |
| + } |
| + if record.Host != o.Host || !record.DerivesPaths() || !record.HibernateOrDefault() || record.Unit != "" { |
| + return provision.ClaimRecord{}, fmt.Errorf("%w: %s is not a relocatable hibernating instance record", hostrelocate.ErrRefused, o.Host) |
| + } |
| + rt := provision.RouteFor(record, dataDir, "", true) |
| + if rt.DBPath != provision.NestedDBPath(dataDir, o.Host) || rt.Socket != provision.NestedSocket(o.Host) { |
| + return provision.ClaimRecord{}, fmt.Errorf("%w: %s does not mint nested", hostrelocate.ErrRefused, o.Host) |
| + } |
| + return provision.ClaimRecord{Host: o.Host, Key: provision.RecordKey(record.Account, record.App, record.Host), Family: "instance", Account: record.Account, App: record.App, CreatedAt: record.CreatedAt, Value: record, Route: rt}, nil |
| +} |
| + |
| +// relocationPolicy is region HA's relocating owner: it runs after the |
| +// claim's own gate and only refuses. The captured installation must be the |
| +// holder's, this box a participant and not lost. |
| +func relocationPolicy(box string) func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error { |
| + return func(_ context.Context, c *hostclaim.Claim, s hostbox.Selection, _ hostwrite.Identity) error { |
| + if c == nil || c.Holder == nil || s.Box != box || !slices.Contains(c.Op.Participants, box) || slices.Contains(c.Lost, box) { |
| + return hostbox.ErrChanged |
| + } |
| + if s.Captured.Incarnation != c.Holder.Incarnation || s.Captured.Stamp != c.Holder.Stamp { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| + } |
| +} |
| + |
| +// relocationInstallPolicy is the typed install authority's policy (ruling |
| +// 46): only the three install actions, only under |
| +// relocationInstallAuthority at installed or probing, and a registry hold |
| +// absent or this op's; then region HA's relocating owner, which checks the |
| +// captured installation and can only refuse. |
| +func relocationInstallPolicy(held func(context.Context, string) (string, error), owner func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error) func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error { |
| + return func(ctx context.Context, c *hostclaim.Claim, s hostbox.Selection, id hostwrite.Identity) error { |
| + switch s.Action { |
| + case relocationActionInstall, relocationActionHeldConfig, relocationActionHeldRelease: |
| + default: |
| + return hostbox.ErrChanged |
| + } |
| + if held == nil || owner == nil { |
| + return hostbox.ErrUnsupported |
| + } |
| + if err := relocationInstallAuthority(c, s.Op, s.Box, hostclaim.PhaseInstalled, hostclaim.PhaseRelocatingProbing); err != nil { |
| + return errors.Join(hostbox.ErrChanged, err) |
| + } |
| + hold, err := held(ctx, s.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if hold != "" && hold != c.Op.ID { |
| + return hostbox.ErrChanged |
| + } |
| + return owner(ctx, c, s, id) |
| + } |
| +} |
| + |
| +// relocationInstallSource is main's WorkSource with only its Policy |
| +// replaced (ruling 46): Guard, Box, Region, Now, Prepare (main's |
| +// participation) and Local (identity and quarantine, the lease's deadline |
| +// and loss latch, failed cleanup) are the assembly's own, and every With |
| +// re-checks them. Continue is nil: a phase change ends the selection. |
| +func relocationInstallSource(src hostbox.WorkSource, reg *registry.Registry) hostbox.WorkSource { |
| + src.Policy = relocationInstallPolicy(hostbox.RegistryHoldPolicy(reg), relocationPolicy(src.Box)) |
| + src.Continue = nil |
| + return src |
| +} |
| + |
| +// relocationInstall is §5.8's local install (B1-2) in ruling 44's order, |
| +// every write through the typed install authority (ruling 46): |
| +// |
| +// 1. the captured installation must be the active install stamp |
| +// (activated at installing; never reactivated, ruling 52); |
| +// 2. the claim's typed inventory says nested (park.Local, once); |
| +// 3. the build's scratch is removed (ruling 44); |
| +// 4. in the install work: MintHeld first (the census initialises the |
| +// installation from an empty box; the route entry; the row held by |
| +// the op with status relocating); then the instance directory through |
| +// main's PrepareState (ruling 57); then the files and env from P's |
| +// manifest into the nested paths, and the wake state; |
| +// 5. the held config (held-config selection), and the pinned key checked |
| +// against the env the target will launch with (O3'); |
| +// 6. the pinned release (held-release selection; a blocked stage leaves |
| +// root's relay request, M3); |
| +// 7. the journal, then the receipt. |
| +// |
| +// Idempotent: a pass that ends in ErrNotYet is repeated whole. |
| +func (a ClaimAssembly) relocationInstall(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed) (hostclaim.LocalInstall, error) { |
| + render, materialize := cfg.render, cfg.materialize |
| + if render == nil { |
| + render = appconfig.RenderHeldUnder |
| + } |
| + if materialize == nil { |
| + materialize = func(ctx context.Context, s release.Store, optDir string, rt registry.Route, version string, proof *release.ProofGate, work hostwrite.Work, guard func(func() error) error) (*release.Manifest, error) { |
| + return release.MaterializeHeldUnder(ctx, s, cfg.ReleasePub, cfg.Ratchet, optDir, rt, version, proof, work, guard) |
| + } |
| + } |
| + return func(ctx context.Context, o hostclaim.Observed) (hostclaim.LocalInstall, error) { |
| + if cfg.Data == nil || cfg.Registry == nil || cfg.OptDir == "" || a.Reconcile == nil || cfg.Source.Box == "" || o.Claim == nil || o.Claim.Holder == nil { |
| + return hostclaim.LocalInstall{}, ErrClaimStopUnavailable |
| + } |
| + c := o.Claim |
| + local, err := a.localStopIdentity(ctx) |
| + if err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + box := local.Actor.Box |
| + check := func(fresh *hostclaim.Claim) error { |
| + return relocationInstallAuthority(fresh, c.Op.ID, box, hostclaim.PhaseInstalled, hostclaim.PhaseRelocatingProbing) |
| + } |
| + if err := check(c); err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + stamp, _ := relocationInstallStamp(c) |
| + var ref hostclaim.RecoveryPin |
| + if err := json.Unmarshal([]byte(c.Op.Journal["pin"]), &ref); err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + pin, err := hostrelocate.ReadPin(ctx, a.Claims, ref) |
| + if err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + p := c.Op.Journal["prepared"] |
| + prefix := c.Holder.Location + "/files/" + p + "/" |
| + origins := append(append([]string(nil), pin.Files.Origins...), prefix) |
| + rec, err := relocationRecord(ctx, cfg.DataDir, a.Claims, o) |
| + if err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + rt := rec.Route |
| + paths := relocationPathsFor(cfg.DataDir, rt) |
| + captured, err := hostguard.Capture(a.Guard.DataDir, o.Host) |
| + if err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + if captured.Stamp != stamp || captured.Incarnation != c.Holder.Incarnation { |
| + return hostclaim.LocalInstall{}, fmt.Errorf("%w: the install stamp %d is not active here (%w)", hostrelocate.ErrRefused, stamp, hostguard.ErrStampMismatch) |
| + } |
| + src := relocationInstallSource(cfg.Source, cfg.Registry) |
| + work, err := src.SelectAt(ctx, o.Host, relocationActionInstall, captured) |
| + if err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + // The claim's typed inventory for this box, once: RecordInventory |
| + // writes the claim on every call, so a repeated install (a retry, a |
| + // repairing EnsureLocal) does not rewrite it once it says nested |
| + // (Astra round 2 finding 2; the participant re-reads after). |
| + if entry, ok := c.Inventory[box]; !ok || entry.Layout != "nested" { |
| + if err := a.Reconcile.Reconciler.Local.RecordInventory(ctx, o.Host, captured, "nested", nil, check); err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + } |
| + // The build's scratch names the host: gone before the census runs |
| + // (ruling 44), even after a crash between the build and here. |
| + if err := removeRelocationScratch(cfg, c.Op.ID); err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + journal := relocationInstallJournal{V: 1, Host: o.Host, Op: c.Op.ID, Box: box, Enrollment: local.Actor.Enrollment, InstallStamp: stamp, Prepared: p, Steps: map[string]string{}} |
| + if err := work.With(ctx, hostwrite.Identity{}, func() error { |
| + return a.Reconcile.MintHeld(ctx, rec, captured, "relocating", c.Op.ID) |
| + }); err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + journal.Steps["row"] = c.Op.ID |
| + // The instance directory through main's typed protocol (ruling 57): |
| + // ClaimRoleAssembly.PrepareState records the path entry intended, |
| + // makes the directory, then records its device and inode |
| + // materialized, under the install work; the probe's own |
| + // PrepareState then finds it as its own. Never MkdirAll. |
| + if a.Wake == nil || a.Wake.Roles == nil { |
| + return hostclaim.LocalInstall{}, ErrClaimStopUnavailable |
| + } |
| + installed, err := hostclaim.Read(ctx, a.Claims, o.Host) |
| + if err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + if err := check(installed.Claim); err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + if err := a.Wake.Roles.PrepareState(ctx, installed, work, ClaimLaunchInputs{DBPath: rt.DBPath}); err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + var m hostfiles.Manifest |
| + if err := work.With(ctx, hostwrite.Identity{}, func() error { |
| + if err := os.MkdirAll(paths.FilesDir, 0o700); err != nil { |
| + return err // inside the materialized instance directory |
| + } |
| + var err error |
| + if m, _, err = hostfiles.Restore(ctx, cfg.Data, prefix, paths.FilesDir, origins); err != nil { |
| + return err |
| + } |
| + journal.Steps["files"] = strconv.FormatInt(m.Seq, 10) |
| + // O3: the env file is P's EnvItem byte for byte; nothing is |
| + // minted. A deleted EnvItem is the same as none (Astra round 3 |
| + // finding 5): any env file here is not the host's, so it goes. |
| + if e, ok := m.Platform[hostfiles.EnvItem]; ok && !e.Deleted { |
| + env, err := hostfiles.ReadPlatform(ctx, cfg.Data, prefix, m, hostfiles.EnvItem, origins) |
| + if err != nil { |
| + return err |
| + } |
| + if err := writeFileAtomic(paths.EnvPath, env, 0o600); err != nil { |
| + return err |
| + } |
| + journal.Steps["env"] = "written" |
| + } else if err := os.Remove(paths.EnvPath); err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return err |
| + } else { |
| + journal.Steps["env"] = "none" |
| + } |
| + ws, ok, err := hostfiles.LoadWake(ctx, cfg.Data, prefix, m, origins) |
| + if err != nil { |
| + return err |
| + } |
| + journal.Steps["wake"] = "none" |
| + if ok { |
| + if err := hostfiles.ImportWake(cfg.Registry, o.Host, ws); err != nil { |
| + return err |
| + } |
| + journal.Steps["wake"] = "imported" |
| + } |
| + return nil |
| + }); err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + held := rt |
| + held.Status, held.HoldOp = "relocating", c.Op.ID |
| + rendered, err := render(ctx, a.Claims, cfg.SecretsKey, cfg.DataDir, held, c.Op.ID, box, func(pre appconfig.HeldRender) (hostwrite.Work, error) { |
| + w, err := src.SelectAt(ctx, o.Host, relocationActionHeldConfig, captured) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return heldIdentityWork{Work: w, ID: HeldConfigIdentity(pre.BundleETags, pre.OverlayETag)}, nil |
| + }) |
| + if err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + journal.Steps["render"] = rendered.SHA256 |
| + // O3' (instance-key plan rev 3; ruling 55): when the pin carries the |
| + // key by an adoption record, the env files this install will launch |
| + // with must deliver exactly that key; a configuration changed since |
| + // the pin refuses here, before anything launches. The adoption item |
| + // itself is never restored: the target's sessions are new writers. |
| + if pin.InstanceKey != nil { |
| + configEnv, err := os.ReadFile(rendered.Path) |
| + if err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + ownEnv, err := os.ReadFile(paths.EnvPath) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + if err := instancekey.VerifyDelivered(configEnv, ownEnv, pin.InstanceKey.SHA256); err != nil { |
| + return hostclaim.LocalInstall{}, errors.Join(hostrelocate.ErrRefused, err) |
| + } |
| + journal.Steps["key"] = pin.InstanceKey.SHA256 |
| + } |
| + // The pinned release (R9): install_release, never the route's |
| + // version, through the claim-aware held stage (never MaterializeHeld, |
| + // which refuses with CARLOS_HOST_CLAIM on). The edge unit cannot |
| + // write the version store: ErrStageBlocked leaves root's relay |
| + // request (M3) and the receipt waits for live/<host>. |
| + version := c.Op.Journal["install_release"] |
| + if version == "" { |
| + return hostclaim.LocalInstall{}, fmt.Errorf("%w: no install_release journaled", hostrelocate.ErrRefused) |
| + } |
| + released, err := src.SelectAt(ctx, o.Host, relocationActionHeldRelease, captured) |
| + if err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + proof := release.NewProofGate(release.ProofPolicyPath, cfg.DataDir) |
| + _, err = materialize(ctx, a.Claims, cfg.OptDir, held, version, proof, heldIdentityWork{Work: released, ID: hostwrite.Identity{Release: version}}, nil) |
| + switch { |
| + case errors.Is(err, release.ErrStageBlocked): |
| + req := release.HeldStageRequest{Account: rec.Account, App: rec.App, Host: o.Host, Version: version, Op: c.Op.ID, Box: box} |
| + if err := writeHeldStageRequest(cfg.DataDir, req); err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + case err != nil: |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + if live, err := release.LiveVersion(cfg.OptDir, o.Host); err != nil || live != version { |
| + return hostclaim.LocalInstall{}, errors.Join(fmt.Errorf("%w: the pinned release %s is not staged yet (live %q)", hostrelocate.ErrNotYet, version, live), err) |
| + } |
| + journal.Steps["materialize"] = version |
| + raw, err := json.Marshal(journal) |
| + if err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + path, err := relocationInstallPath(cfg.DataDir, c.Op.ID) |
| + if err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + if err := work.With(ctx, hostwrite.Identity{}, func() error { |
| + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { |
| + return err |
| + } |
| + return writeFileAtomic(path, raw, 0o600) |
| + }); err != nil { |
| + return hostclaim.LocalInstall{}, err |
| + } |
| + sum := sha256.Sum256(raw) |
| + return hostclaim.LocalInstall{Box: box, Enrollment: local.Actor.Enrollment, InstallStamp: stamp, FilesManifestVersion: m.Seq, JournalSHA256: hex.EncodeToString(sum[:])}, nil |
| + } |
| +} |
| + |
| +// relocationVerifyLocal reports whether the receipt's install is whole on |
| +// this box now: the journal at the receipt's digest, the guard record |
| +// active at its stamp, the row held by the operation, and every manifest |
| +// entry and the env file as P's manifest says (hostfiles.Present). |
| +func (a ClaimAssembly) relocationVerifyLocal(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed, hostclaim.LocalInstall) (bool, error) { |
| + receipt := a.relocationReceipt(cfg) |
| + return func(ctx context.Context, o hostclaim.Observed, l hostclaim.LocalInstall) (bool, error) { |
| + if cfg.Registry == nil || cfg.Data == nil || o.Claim == nil || o.Claim.Holder == nil { |
| + return false, ErrClaimStopUnavailable |
| + } |
| + got, ok, err := receipt(ctx, o) |
| + if err != nil || !ok || got != l { |
| + return false, err |
| + } |
| + captured, err := hostguard.Capture(a.Guard.DataDir, o.Host) |
| + if errors.Is(err, hostguard.ErrNoRecord) { |
| + return false, nil |
| + } |
| + if err != nil { |
| + return false, err |
| + } |
| + if captured.Stamp != l.InstallStamp { |
| + return false, nil |
| + } |
| + row, err := cfg.Registry.Get(o.Host) |
| + if err != nil { |
| + return false, err |
| + } |
| + if row == nil || row.HoldOp != o.Claim.Op.ID || !registry.IsHold(row.Status) { |
| + return false, nil |
| + } |
| + p := o.Claim.Op.Journal["prepared"] |
| + raw, _, err := cfg.Data.Get(ctx, hostfiles.ManifestKey(o.Claim.Holder.Location+"/files/"+p+"/")) |
| + if err != nil { |
| + return false, err |
| + } |
| + var m hostfiles.Manifest |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + return false, err |
| + } |
| + paths := relocationPathsFor(cfg.DataDir, *row) |
| + return hostfiles.Present(paths.FilesDir, paths.EnvPath, m) |
| + } |
| +} |
| + |
| +// relocationReceipt reads this box's durable install journal for the |
| +// claim's operation; false when there is none, it is damaged, or it is not |
| +// this operation's. |
| +func (a ClaimAssembly) relocationReceipt(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed) (hostclaim.LocalInstall, bool, error) { |
| + return func(_ context.Context, o hostclaim.Observed) (hostclaim.LocalInstall, bool, error) { |
| + if o.Claim == nil { |
| + return hostclaim.LocalInstall{}, false, ErrClaimStopUnavailable |
| + } |
| + path, err := relocationInstallPath(cfg.DataDir, o.Claim.Op.ID) |
| + if err != nil { |
| + return hostclaim.LocalInstall{}, false, err |
| + } |
| + raw, err := os.ReadFile(path) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return hostclaim.LocalInstall{}, false, nil |
| + } |
| + if err != nil { |
| + return hostclaim.LocalInstall{}, false, err |
| + } |
| + var j relocationInstallJournal |
| + if err := json.Unmarshal(raw, &j); err != nil || j.V != 1 || j.Host != o.Host || j.Op != o.Claim.Op.ID || j.Steps["materialize"] == "" { |
| + return hostclaim.LocalInstall{}, false, nil |
| + } |
| + seq, _ := strconv.ParseInt(j.Steps["files"], 10, 64) |
| + sum := sha256.Sum256(raw) |
| + return hostclaim.LocalInstall{Box: j.Box, Enrollment: j.Enrollment, InstallStamp: j.InstallStamp, FilesManifestVersion: seq, JournalSHA256: hex.EncodeToString(sum[:])}, true, nil |
| + } |
| +} |
diff --git a/internal/host/relocationinstall_linux_test.go b/internal/host/relocationinstall_linux_test.go| new file mode 100644 |
| index 000000000..94a1f41d6 |
| --- /dev/null |
| +++ b/internal/host/relocationinstall_linux_test.go |
| @@ -0,0 +1,643 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdh" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "io/fs" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "syscall" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + execlease "github.com/carlosframework/platform/internal/hostexec" |
| + "github.com/carlosframework/platform/internal/hostfiles" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostrelocate" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/park" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const installEnv = "# the host's own env\nCARLOS_ADMIN_TOKEN=t0k3n\n CARLOS_INSTANCE_KEY = q+/Zk3vJ0aW1Yx9sR7tUe2oPl4mN8bC6dF5gH0iJ1kA=\nAPP_MODE=prod" |
| + |
| +// installFixture is a fresh spare at installed, after P's build: the |
| +// production box layout (bootstrap), the real EmptyInstallationCensus with |
| +// claimGlobalSharedPaths, the real ReconcileAdapter with park.Local, main's |
| +// ClaimRoleAssembly.PrepareState, and the claim, record and P's files |
| +// manifest as the build left them. The held config render and the held |
| +// stage are seams that select their work exactly as production does. |
| +type installFixture struct { |
| + *buildFixture |
| + r ClaimRuntimeResources |
| + reg *registry.Registry |
| + inv *hostbox.StopInventoryDB |
| + actor hostbox.Actor |
| + stageErr error // the held stage's answer; nil points live/<host> at the version |
| + renders int |
| + configs map[string]string // the rendered config's variables |
| +} |
| + |
| +func newInstallFixture(t *testing.T, platform map[string]string) *installFixture { |
| + t.Helper() |
| + ctx := t.Context() |
| + for _, k := range []string{"CARLOS_HOST_CLAIM", "CARLOS_MOVES_PHASE1", "CARLOS_REGION_HA"} { |
| + t.Setenv(k, "1") |
| + } |
| + r := claimCensusBox(t) |
| + if platform == nil { |
| + ws, _ := json.Marshal(hostfiles.WakeState{V: 1, Host: buildHost, NextDue: 1700000000, Timers: []hostfiles.WakeTimer{ |
| + {Name: "nightly", Kind: "cron", Spec: "0 3 * * *", Path: "/jobs/nightly", NextAt: 1700000000, CreatedAt: 1690000000, Registration: "r1"}, |
| + {Name: "hourly", Kind: "cron", Spec: "0 * * * *", Path: "/jobs/hourly", NextAt: 1700000100, CreatedAt: 1690000000, Registration: "r2"}, |
| + }}) |
| + platform = map[string]string{hostfiles.EnvItem: installEnv, hostfiles.WakeItem: string(ws)} |
| + } |
| + b := newBuildFixtureWith(t, buildOptions{Data: r.DataDir, Platform: platform, Files: map[string]string{"public/index.html": "<h1>shop</h1>", "uploads/a/b.png": "png-bytes"}}) |
| + f := &installFixture{buildFixture: b, r: r, actor: hostbox.Actor{Box: "dst", Enrollment: strings.Repeat("a", 32), Region: "ie", Revision: 4}} |
| + // The record the claim's holder names: a hibernating instance that |
| + // derives its paths. |
| + if err := provision.CreateRecord(ctx, b.claims, provision.Record{Schema: 1, Host: buildHost, Slug: "shop", App: "app", Account: "acct", Region: "ie", Channel: "stable", CreatedAt: time.Date(2026, 10, 7, 1, 0, 0, 0, time.UTC)}); err != nil && !errors.Is(err, release.ErrCASConflict) { |
| + t.Fatal(err) |
| + } |
| + if err := b.build(); err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err := hostclaim.Update(ctx, b.claims, b.obs, func(c *hostclaim.Claim) error { |
| + c.Op.Phase = hostclaim.PhaseInstalled |
| + c.Op.Intent["target_enrollment"] = f.actor.Enrollment |
| + c.Op.Journal["exec_ack.src"], c.Op.Journal["exec_ack.dst"] = `{"op":"rel-1"}`, `{"op":"rel-1"}` |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b.obs = o |
| + if f.reg, err = registry.Open(filepath.Join(r.DataDir, "registry.db")); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { f.reg.Close() }) |
| + if f.inv, err = hostbox.OpenStopInventoryDB(filepath.Join(r.DataDir, ".hostclaims", "inventory.db")); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { f.inv.Close() }) |
| + launches, err := hostbox.OpenLaunchJournal(filepath.Join(r.DataDir, ".hostclaims", "launches.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { launches.Close() }) |
| + census := &hostbox.EmptyInstallationCensus{Inventory: f.inv, Registry: f.reg, Launches: launches, Box: "dst", Enrollment: f.actor.Enrollment, DirectoryRevision: "4", |
| + Roots: map[string]string{"data": r.DataDir, "opt": r.OptDir, "units": r.UnitsDir}, SharedPaths: claimGlobalSharedPaths(r)} |
| + guard := b.a.Guard |
| + b.a.Reconcile = &hostbox.ReconcileAdapter{Registry: f.reg, Inventory: f.inv, Census: census, Box: "dst", Enrollment: f.actor.Enrollment, DirectoryRevision: "4", |
| + Reconciler: hostbox.Reconciler{Claims: b.claims, Guard: guard, Box: "dst", Region: "ie", Local: park.Local{Guard: guard, Claims: b.claims, DataDir: r.DataDir, Box: "dst"}}} |
| + b.a.Wake = &ClaimWakeAssembly{Roles: &ClaimRoleAssembly{Inventory: f.inv, Identity: handoverRecoveryIdentity(t, f.actor), ManagedRoots: map[string]string{"data": r.DataDir}}} |
| + b.a.stopLocal = func(context.Context) (claimStopLocal, error) { |
| + return claimStopLocal{Actor: f.actor, BootID: "boot", ControllerID: "ctl", PID: 100, ProcessStart: "5000"}, nil |
| + } |
| + b.cfg.Registry, b.cfg.OptDir = f.reg, r.OptDir |
| + b.cfg.Source.Guard, b.cfg.Source.Now = guard, time.Now |
| + b.cfg.render = f.render |
| + b.cfg.materialize = f.materialize |
| + return f |
| +} |
| + |
| +// render stands for appconfig.RenderHeldUnder: it selects its work with |
| +// the merged versions, as production does, and writes the held config in |
| +// the host's instance directory through that work. |
| +func (f *installFixture) render(ctx context.Context, _ release.Store, _ *ecdh.PrivateKey, dataDir string, rt registry.Route, op, box string, work func(appconfig.HeldRender) (hostwrite.Work, error)) (appconfig.HeldRender, error) { |
| + f.renders++ |
| + pre := appconfig.HeldRender{BundleETags: map[string]string{"default": "d1", "environment": "e1"}, OverlayETag: "o1"} |
| + w, err := work(pre) |
| + if err != nil { |
| + return appconfig.HeldRender{}, err |
| + } |
| + if rt.Status != "relocating" || rt.HoldOp != op || box != "dst" { |
| + return appconfig.HeldRender{}, appconfig.ErrNotHeld |
| + } |
| + var body strings.Builder |
| + for k, v := range f.configs { |
| + body.WriteString(k + "=" + v + "\n") |
| + } |
| + path := filepath.Join(provision.InstanceDir(dataDir, rt.Host), "config.env") |
| + if err := w.With(ctx, hostwrite.Identity{}, func() error { return os.WriteFile(path, []byte(body.String()), 0o600) }); err != nil { |
| + return appconfig.HeldRender{}, err |
| + } |
| + sum := sha256.Sum256([]byte(body.String())) |
| + pre.Path, pre.SHA256 = path, hex.EncodeToString(sum[:]) |
| + return pre, nil |
| +} |
| + |
| +// materialize stands for release.MaterializeHeldUnder: blocked (the edge |
| +// unit cannot write the version store) or, through its work, live/<host> |
| +// pointed at the pinned version. |
| +func (f *installFixture) materialize(ctx context.Context, _ release.Store, optDir string, rt registry.Route, version string, _ *release.ProofGate, work hostwrite.Work, _ func(func() error) error) (*release.Manifest, error) { |
| + if f.stageErr != nil { |
| + return nil, f.stageErr |
| + } |
| + return &release.Manifest{}, work.With(ctx, hostwrite.Identity{Release: version}, func() error { return f.publishLive(rt.Account, rt.App, version) }) |
| +} |
| + |
| +func (f *installFixture) publishLive(acct, app, version string) error { |
| + // The box's version store already holds the release (bootstrap staged |
| + // it); a fresh store gets a directory for it. |
| + target := release.VersionPath(f.r.OptDir, acct, version, app) |
| + if _, err := os.Lstat(target); errors.Is(err, os.ErrNotExist) { |
| + if err := os.MkdirAll(target, 0o755); err != nil { |
| + return err |
| + } |
| + } |
| + link := release.LiveLink(f.r.OptDir, buildHost) |
| + if err := os.MkdirAll(filepath.Dir(link), 0o755); err != nil { |
| + return err |
| + } |
| + _ = os.Remove(link) |
| + return os.Symlink(target, link) |
| +} |
| + |
| +func (f *installFixture) install() (hostclaim.LocalInstall, error) { |
| + return f.a.relocationInstall(f.cfg)(f.t.Context(), f.read()) |
| +} |
| + |
| +func (f *installFixture) read() hostclaim.Observed { |
| + f.t.Helper() |
| + o, err := hostclaim.Read(f.t.Context(), f.claims, buildHost) |
| + if err != nil { |
| + f.t.Fatal(err) |
| + } |
| + return o |
| +} |
| + |
| +func (f *installFixture) paths() relocationPaths { |
| + return relocationPathsFor(f.r.DataDir, registry.Route{Host: buildHost, DBPath: provision.NestedDBPath(f.r.DataDir, buildHost), Socket: provision.NestedSocket(buildHost)}) |
| +} |
| + |
| +func (f *installFixture) effects() *RelocationEffects { |
| + return &RelocationEffects{Install: f.a.relocationInstall(f.cfg), VerifyLocal: f.a.relocationVerifyLocal(f.cfg), Receipt: f.a.relocationReceipt(f.cfg)} |
| +} |
| + |
| +// Findings 3 and 5, Review Focus 2: from an empty target, the install mints |
| +// the held row first (the census initialises), makes the instance |
| +// directory through main's PrepareState, restores files and env into the |
| +// nested paths, imports the wake state (timers owned by the row), renders |
| +// the held config and stages the pinned release, with the flags on. |
| +func TestRelocationInstallFromAnEmptyTargetThroughTheProductionCensus(t *testing.T) { |
| + f := newInstallFixture(t, nil) |
| + ctx := t.Context() |
| + l, err := f.install() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + stamp := f.obs.Claim.Holder.Stamp |
| + row, err := f.reg.Get(buildHost) |
| + if err != nil || row == nil { |
| + t.Fatalf("row: %v", err) |
| + } |
| + if row.Status != "relocating" || row.HoldOp != "rel-1" || row.DBPath != provision.NestedDBPath(f.r.DataDir, buildHost) || row.ClaimOwnership.ClaimStamp != stamp { |
| + t.Fatalf("row %+v", row) |
| + } |
| + timers, err := f.reg.TimersFor(buildHost) |
| + if err != nil || len(timers) != 2 { |
| + t.Fatalf("timers %+v %v", timers, err) |
| + } |
| + for _, tm := range timers { |
| + if tm.ClaimOwner != row.ClaimOwnership.ClaimOwner { |
| + t.Fatalf("timer %s is not owned by the row: %+v", tm.Name, tm) |
| + } |
| + } |
| + p := f.paths() |
| + for rel, want := range map[string]string{"public/index.html": "<h1>shop</h1>", "uploads/a/b.png": "png-bytes"} { |
| + got, err := os.ReadFile(filepath.Join(p.FilesDir, filepath.FromSlash(rel))) |
| + if err != nil || string(got) != want { |
| + t.Fatalf("%s: %q %v", rel, got, err) |
| + } |
| + } |
| + if env, err := os.ReadFile(p.EnvPath); err != nil || string(env) != installEnv { |
| + t.Fatalf("env %q %v", env, err) |
| + } |
| + if live, err := release.LiveVersion(f.r.OptDir, buildHost); err != nil || live != "v1" { |
| + t.Fatalf("live %q %v", live, err) |
| + } |
| + path, _ := relocationInstallPath(f.r.DataDir, "rel-1") |
| + raw, err := os.ReadFile(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.Sum256(raw) |
| + if l.Box != "dst" || l.Enrollment != f.actor.Enrollment || l.InstallStamp != stamp || l.JournalSHA256 != hex.EncodeToString(sum[:]) || l.FilesManifestVersion != 0 { |
| + t.Fatalf("receipt %+v", l) |
| + } |
| + again, err := f.install() |
| + if err != nil || again != l { |
| + t.Fatalf("a second install: %+v %v", again, err) |
| + } |
| + if ok, err := f.a.relocationVerifyLocal(f.cfg)(ctx, f.read(), l); err != nil || !ok { |
| + t.Fatalf("verify: %v %v", ok, err) |
| + } |
| +} |
| + |
| +// Rev 3 finding 3, ruling 57: the instance directory is main's typed path |
| +// entry, intended then materialized with its device and inode, so the |
| +// probe's own PrepareState accepts it as its own. |
| +func TestRelocationInstallMaterializesTheInstanceDirectoryTyped(t *testing.T) { |
| + f := newInstallFixture(t, nil) |
| + if _, err := f.install(); err != nil { |
| + t.Fatal(err) |
| + } |
| + h := f.read().Claim.Holder |
| + in, err := f.inv.ReadInstallation(t.Context(), buildHost, h.Incarnation, h.Stamp) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + dir := provision.InstanceDir(f.r.DataDir, buildHost) |
| + rel, _ := filepath.Rel(f.r.DataDir, dir) |
| + info, err := os.Stat(dir) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + found := false |
| + for _, e := range in.Entries { |
| + if e.Kind == "path" && e.ManagedRoot == "data" && e.RelativePath == rel { |
| + found = e.Materialization == "materialized" && e.Inode == inodeOf(t, info) |
| + } |
| + } |
| + if !found { |
| + t.Fatalf("no materialized path entry for %s: %+v", rel, in.Entries) |
| + } |
| + // The probe's own PrepareState finds it as its own. |
| + work, err := relocationInstallSource(f.cfg.Source, f.reg).SelectAt(t.Context(), buildHost, relocationActionInstall, hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.a.Wake.Roles.PrepareState(t.Context(), f.read(), work, ClaimLaunchInputs{DBPath: provision.NestedDBPath(f.r.DataDir, buildHost)}); err != nil { |
| + t.Fatalf("PrepareState refused the install's directory: %v", err) |
| + } |
| +} |
| + |
| +// O3: the env file is P's EnvItem byte for byte, its comments, spacing and |
| +// unterminated last line included; nothing is minted. |
| +func TestRelocationInstallKeepsThePublishedEnvByteForByte(t *testing.T) { |
| + f := newInstallFixture(t, nil) |
| + if _, err := f.install(); err != nil { |
| + t.Fatal(err) |
| + } |
| + p := f.paths() |
| + if env, err := os.ReadFile(p.EnvPath); err != nil || string(env) != installEnv { |
| + t.Fatalf("env %q %v", env, err) |
| + } |
| + if _, err := os.Stat(filepath.Join(p.FilesDir, hostrelocate.InstanceKeyPath)); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("an instance key was minted: %v", err) |
| + } |
| +} |
| + |
| +// Astra round 3 finding 5: a deleted EnvItem is no env file. A repeated |
| +// install (a crash before its journal) removes a stray env file at the |
| +// path and writes none. |
| +func TestRelocationInstallRemovesAStaleEnvForADeletedItem(t *testing.T) { |
| + f := newInstallFixture(t, map[string]string{}) |
| + o := f.read() |
| + key := hostfiles.ManifestKey(o.Claim.Holder.Location + "/files/p1/") |
| + raw, etag, err := f.files.Get(t.Context(), key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var m hostfiles.Manifest |
| + if err := json.Unmarshal(raw, &m); err != nil { |
| + t.Fatal(err) |
| + } |
| + m.Platform[hostfiles.EnvItem] = hostfiles.Entry{Deleted: true, Seq: 4} |
| + raw, _ = json.Marshal(m) |
| + if _, err := f.files.Store.Put(t.Context(), key, raw, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.install(); err != nil { |
| + t.Fatal(err) |
| + } |
| + p := f.paths() |
| + if _, err := os.Stat(p.EnvPath); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("an env file was written for a deleted item: %v", err) |
| + } |
| + // A crash before the journal, and an env file at the path meanwhile. |
| + journal, _ := relocationInstallPath(f.r.DataDir, "rel-1") |
| + if err := os.Remove(journal); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(p.EnvPath, []byte("STALE=1\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := f.install(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := os.Stat(p.EnvPath); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("a stale env survived a deleted item: %v", err) |
| + } |
| +} |
| + |
| +// Ruling 44 and K25: nothing region HA leaves under the data root names the |
| +// host outside the host's own installation, except main's own shared paths |
| +// and moves' held-stage relay (census-opaque, N11). The build's scratch |
| +// left by a crash is removed by the install. |
| +func TestNoRelocationFileNamesTheHostOutsideItsInstallation(t *testing.T) { |
| + f := newInstallFixture(t, nil) |
| + scratch := filepath.Join(f.cfg.WorkDir, "rel-1", buildHost+".db") |
| + if err := os.MkdirAll(filepath.Dir(scratch), 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(scratch, []byte("left by a crash"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.stageErr = release.ErrStageBlocked |
| + if _, err := f.install(); !errors.Is(err, hostrelocate.ErrNotYet) { |
| + t.Fatalf("blocked stage: %v", err) |
| + } |
| + f.stageErr = nil |
| + if _, err := f.install(); err != nil { |
| + t.Fatal(err) |
| + } |
| + inst := provision.InstanceDir(f.r.DataDir, buildHost) |
| + allowed := []string{inst, filepath.Join(f.r.DataDir, "hostguard"), filepath.Join(f.r.DataDir, ".hostexec"), filepath.Join(f.r.DataDir, ".hostclaims"), |
| + release.HeldStageDir(f.r.DataDir), release.HeldStagedDir(f.r.DataDir)} |
| + err := filepath.WalkDir(f.r.DataDir, func(path string, d fs.DirEntry, err error) error { |
| + if err != nil || !strings.Contains(path, buildHost) { |
| + return err |
| + } |
| + for _, a := range allowed { |
| + if path == a || strings.HasPrefix(path, a+string(filepath.Separator)) { |
| + return nil |
| + } |
| + } |
| + t.Errorf("%s names the host outside its installation", path) |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ops, err := os.ReadDir(filepath.Join(f.r.DataDir, "relocate", "ops", "rel-1")) |
| + if err != nil || len(ops) != 1 || ops[0].Name() != "install.json" { |
| + t.Fatalf("relocate/ops/rel-1: %v %v", ops, err) |
| + } |
| + if _, err := os.Stat(scratch); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("the build's scratch survived the install: %v", err) |
| + } |
| +} |
| + |
| +// Finding 5, M3: MaterializeHeld is never called; a blocked stage leaves |
| +// root's relay request at install_release (never the route's version) and |
| +// the receipt waits for live/<host>. |
| +func TestRelocationInstallStagesThroughTheClaimPathWithClaimsOn(t *testing.T) { |
| + f := newInstallFixture(t, nil) |
| + f.stageErr = release.ErrStageBlocked |
| + if _, err := f.install(); !errors.Is(err, hostrelocate.ErrNotYet) { |
| + t.Fatalf("got %v", err) |
| + } |
| + raw, err := os.ReadFile(filepath.Join(release.HeldStageDir(f.r.DataDir), buildHost+".json")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var req release.HeldStageRequest |
| + if err := json.Unmarshal(raw, &req); err != nil || req.Version != "v1" || req.Op != "rel-1" || req.Box != "dst" || req.Host != buildHost || req.App != "app" { |
| + t.Fatalf("request %+v %v", req, err) |
| + } |
| + if _, ok, _ := f.a.relocationReceipt(f.cfg)(t.Context(), f.read()); ok { |
| + t.Fatal("a receipt before the release is live") |
| + } |
| + // Root's pass serves the request. |
| + if err := f.publishLive("acct", "app", "v1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if l, err := f.install(); err != nil || l.InstallStamp == 0 { |
| + t.Fatalf("after the stage: %+v %v", l, err) |
| + } |
| +} |
| + |
| +// Finding 10: every install write runs main's Local first. With the |
| +// execution lease lost after the install work and before the render, the |
| +// held-config selection refuses: no config, no request, no journal, and |
| +// the claim is not written. |
| +func TestRelocationInstallStopsWhenTheLeaseIsLost(t *testing.T) { |
| + f := newInstallFixture(t, nil) |
| + f.cfg.render = func(ctx context.Context, s release.Store, k *ecdh.PrivateKey, d string, rt registry.Route, op, box string, work func(appconfig.HeldRender) (hostwrite.Work, error)) (appconfig.HeldRender, error) { |
| + f.localErr = execlease.ErrLost |
| + return f.render(ctx, s, k, d, rt, op, box, work) |
| + } |
| + before := f.read() |
| + if _, err := f.install(); !errors.Is(err, execlease.ErrLost) { |
| + t.Fatalf("got %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(provision.InstanceDir(f.r.DataDir, buildHost), "config.env")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("a config was written: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(release.HeldStageDir(f.r.DataDir), buildHost+".json")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("a stage request was written: %v", err) |
| + } |
| + if _, ok, _ := f.a.relocationReceipt(f.cfg)(t.Context(), f.read()); ok { |
| + t.Fatal("a journal was written") |
| + } |
| + // park's RecordInventory wrote the claim's inventory once, before; the |
| + // lost lease wrote nothing more. |
| + after := f.read() |
| + if after.Claim.Gen != before.Claim.Gen+1 || after.Claim.Inventory["dst"].Layout != "nested" { |
| + t.Fatalf("the claim moved: gen %d -> %d", before.Claim.Gen, after.Claim.Gen) |
| + } |
| +} |
| + |
| +// O3' (instance-key plan rev 3): when the pin carries the key by an |
| +// adoption record, a configuration that no longer delivers that key |
| +// refuses the install before anything launches. |
| +func TestRelocationInstallRefusesAKeyChangedSinceThePin(t *testing.T) { |
| + const configured = "configured-key" |
| + for _, tc := range []struct { |
| + name string |
| + config string |
| + want error |
| + }{ |
| + {"the configured key the pin names", configured, nil}, |
| + {"a key changed since the pin", "another-key", hostrelocate.ErrRefused}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + f := newInstallFixture(t, map[string]string{hostfiles.EnvItem: "CARLOS_ADMIN_TOKEN=t\n"}) |
| + f.configs = map[string]string{"CARLOS_INSTANCE_KEY": tc.config} |
| + repinWithKey(t, f.buildFixture, &hostrelocate.KeyPin{Source: instancekey.SourceConfig, SHA256: instancekey.Digest(configured)}) |
| + _, err := f.install() |
| + if !errors.Is(err, tc.want) { |
| + t.Fatalf("got %v, want %v", err, tc.want) |
| + } |
| + if tc.want != nil { |
| + if _, ok, _ := f.a.relocationReceipt(f.cfg)(t.Context(), f.read()); ok { |
| + t.Fatal("a refused key left a receipt") |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// repinWithKey rewrites the fixture's pin object with an instance key pin |
| +// and journals its new digest, as the pinner would have. |
| +func repinWithKey(t *testing.T, f *buildFixture, key *hostrelocate.KeyPin) { |
| + t.Helper() |
| + ctx := t.Context() |
| + o, err := hostclaim.Read(ctx, f.claims, buildHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var ref hostclaim.RecoveryPin |
| + if err := json.Unmarshal([]byte(o.Claim.Op.Journal["pin"]), &ref); err != nil { |
| + t.Fatal(err) |
| + } |
| + pin, err := hostrelocate.ReadPin(ctx, f.claims, ref) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pin.InstanceKey = key |
| + raw, _ := json.Marshal(pin) |
| + ref.Key += ".keyed" |
| + if _, err := f.claims.Put(ctx, ref.Key, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.Sum256(raw) |
| + ref.SHA256 = hex.EncodeToString(sum[:]) |
| + refRaw, _ := json.Marshal(ref) |
| + if f.obs, err = hostclaim.Update(ctx, f.claims, o, func(c *hostclaim.Claim) error { c.Op.Journal["pin"] = string(refRaw); return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// Ruling 52 and finding 13: a damaged install is repaired under its active |
| +// stamp, in installed and in probing before P; a retired stamp refuses and |
| +// is never reactivated. |
| +func TestRelocationEnsureLocalRefusesARetiredStamp(t *testing.T) { |
| + f := newInstallFixture(t, nil) |
| + ctx := t.Context() |
| + l, err := f.install() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + receipt, _ := json.Marshal(l) |
| + if f.obs, err = hostclaim.Update(ctx, f.claims, f.read(), func(c *hostclaim.Claim) error { c.Op.Journal["local_install"] = string(receipt); return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + e := f.effects() |
| + file := filepath.Join(f.paths().FilesDir, "public", "index.html") |
| + for _, phase := range []string{hostclaim.PhaseInstalled, hostclaim.PhaseRelocatingProbing} { |
| + o, err := hostclaim.Update(ctx, f.claims, f.read(), func(c *hostclaim.Claim) error { c.Op.Phase = phase; return nil }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Remove(file); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := e.EnsureLocal(ctx, o); err != nil { |
| + t.Fatalf("%s: %v", phase, err) |
| + } |
| + if got, err := os.ReadFile(file); err != nil || string(got) != "<h1>shop</h1>" { |
| + t.Fatalf("%s: not restored: %q %v", phase, got, err) |
| + } |
| + } |
| + if err := f.a.Guard.RetireInstallation(ctx, buildHost, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Remove(file); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := e.EnsureLocal(ctx, f.read()); !errors.Is(err, hostrelocate.ErrRefused) { |
| + t.Fatalf("a retired stamp was repaired: %v", err) |
| + } |
| + if _, err := os.Stat(file); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("a retired stamp's install was written: %v", err) |
| + } |
| + if c, err := hostguard.Capture(f.a.Guard.DataDir, buildHost); err == nil && c.Stamp == l.InstallStamp { |
| + t.Fatalf("the retired stamp was reactivated: %+v", c) |
| + } |
| +} |
| + |
| +func TestRelocationVerifyLocalReadsTheInstallBack(t *testing.T) { |
| + f := newInstallFixture(t, nil) |
| + ctx := t.Context() |
| + l, err := f.install() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + verify := f.a.relocationVerifyLocal(f.cfg) |
| + if ok, err := verify(ctx, f.read(), l); err != nil || !ok { |
| + t.Fatalf("after the install: %v %v", ok, err) |
| + } |
| + other := l |
| + other.InstallStamp++ |
| + if ok, err := verify(ctx, f.read(), other); err != nil || ok { |
| + t.Fatalf("another receipt verified: %v %v", ok, err) |
| + } |
| + if err := os.Remove(filepath.Join(f.paths().FilesDir, "uploads", "a", "b.png")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ok, err := verify(ctx, f.read(), l); err != nil || ok { |
| + t.Fatalf("a missing file verified: %v %v", ok, err) |
| + } |
| +} |
| + |
| +func TestRelocationReceiptIsTheDurableJournal(t *testing.T) { |
| + f := newInstallFixture(t, nil) |
| + ctx := t.Context() |
| + receipt := f.a.relocationReceipt(f.cfg) |
| + if _, ok, err := receipt(ctx, f.read()); ok || err != nil { |
| + t.Fatalf("a receipt before the install: %v %v", ok, err) |
| + } |
| + l, err := f.install() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, ok, err := receipt(ctx, f.read()); err != nil || !ok || got != l { |
| + t.Fatalf("receipt %+v %v %v", got, ok, err) |
| + } |
| + path, _ := relocationInstallPath(f.r.DataDir, "rel-1") |
| + if err := os.WriteFile(path, []byte(`{"v":1,"host":"`), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ok, err := receipt(ctx, f.read()); ok || err != nil { |
| + t.Fatalf("a damaged journal: %v %v", ok, err) |
| + } |
| +} |
| + |
| +// Rev 3 finding 2, ruling 62: Participant.Step over the real effects |
| +// records local_install on the first pass (the install wrote the claim's |
| +// inventory, so the receipt is recorded on a fresh read), then enters |
| +// probing on the next, with no conflict loop. |
| +func TestRelocationInstallRecordsItsReceiptThroughParticipantStep(t *testing.T) { |
| + f := newInstallFixture(t, nil) |
| + e := f.effects() |
| + e.Participation = func(context.Context, hostclaim.Observed) (execlease.State, error) { |
| + return execlease.State{Binding: execlease.Binding{Host: buildHost, Incarnation: f.obs.Claim.Holder.Incarnation, Op: "rel-1"}}, nil |
| + } |
| + p := hostrelocate.Participant{Claims: f.claims, Box: "dst", Enrollment: f.actor.Enrollment, Effects: e, Now: time.Now} |
| + o, err := p.Step(t.Context(), buildHost) |
| + if err != nil || o.Claim.Op.Journal["local_install"] == "" || o.Claim.Op.Phase != hostclaim.PhaseInstalled { |
| + t.Fatalf("first pass: %v %+v", err, o.Claim.Op.Journal) |
| + } |
| + o, err = p.Step(t.Context(), buildHost) |
| + if err != nil { |
| + t.Fatalf("second pass: %v", err) |
| + } |
| + if o.Claim.Op.Phase != hostclaim.PhaseRelocatingProbing { |
| + t.Fatalf("second pass: %s", o.Claim.Op.Phase) |
| + } |
| +} |
| + |
| +func inodeOf(t *testing.T, info os.FileInfo) uint64 { |
| + t.Helper() |
| + st, ok := info.Sys().(*syscall.Stat_t) |
| + if !ok { |
| + t.Fatal("no stat") |
| + } |
| + return st.Ino |
| +} |
diff --git a/internal/host/relocationinstall_test.go b/internal/host/relocationinstall_test.go| new file mode 100644 |
| index 000000000..31bd60a61 |
| --- /dev/null |
| +++ b/internal/host/relocationinstall_test.go |
| @@ -0,0 +1,61 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| +) |
| + |
| +// Finding 10, ruling 46: the install policy admits only the install |
| +// actions of this box's relocating op at installed or probing before P, |
| +// at the install stamp, with no other op's hold, and refuses a stale |
| +// capture. |
| +func TestRelocationInstallPolicyIsTyped(t *testing.T) { |
| + hold := "" |
| + held := func(context.Context, string) (string, error) { return hold, nil } |
| + policy := relocationInstallPolicy(held, relocationPolicy("dst")) |
| + c := &hostclaim.Claim{Host: "shop.example", Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 9}, |
| + Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "op1", Phase: hostclaim.PhaseInstalled, Exec: "dst", Participants: []string{"src", "dst"}, Journal: map[string]string{"install_stamp": "9", "prepared": "P1"}}} |
| + sel := hostbox.Selection{Host: "shop.example", Action: relocationActionInstall, Box: "dst", Op: "op1", Captured: hostguard.Captured{Incarnation: "inc", Stamp: 9}} |
| + for _, action := range []string{relocationActionInstall, relocationActionHeldConfig, relocationActionHeldRelease} { |
| + s := sel |
| + s.Action = action |
| + if err := policy(context.Background(), c, s, hostwrite.Identity{}); err != nil { |
| + t.Fatalf("%s: %v", action, err) |
| + } |
| + } |
| + hold = "op1" |
| + if err := policy(context.Background(), c, sel, hostwrite.Identity{}); err != nil { |
| + t.Fatalf("its own hold: %v", err) |
| + } |
| + for name, tweak := range map[string]func(*hostclaim.Claim, *hostbox.Selection){ |
| + "a probe action": func(_ *hostclaim.Claim, s *hostbox.Selection) { s.Action = "probe" }, |
| + "installing": func(c *hostclaim.Claim, _ *hostbox.Selection) { c.Op.Phase = hostclaim.PhaseInstalling }, |
| + "P opened": func(c *hostclaim.Claim, _ *hostbox.Selection) { c.Op.Journal["prepared_opened"] = "P1" }, |
| + "another executor": func(c *hostclaim.Claim, _ *hostbox.Selection) { c.Op.Exec = "src" }, |
| + "a stale capture": func(_ *hostclaim.Claim, s *hostbox.Selection) { s.Captured.Stamp = 8 }, |
| + "lost": func(c *hostclaim.Claim, _ *hostbox.Selection) { c.Lost = []string{"dst"} }, |
| + "not a participant": func(c *hostclaim.Claim, _ *hostbox.Selection) { |
| + c.Op.Participants = []string{"src"} |
| + }, |
| + "stuck": func(c *hostclaim.Claim, _ *hostbox.Selection) { c.Op.Journal["stuck"] = "x" }, |
| + } { |
| + raw, _ := json.Marshal(c) |
| + var copy hostclaim.Claim |
| + _ = json.Unmarshal(raw, ©) |
| + s := sel |
| + tweak(©, &s) |
| + if err := policy(context.Background(), ©, s, hostwrite.Identity{}); err == nil { |
| + t.Fatalf("%s: admitted", name) |
| + } |
| + } |
| + hold = "another-op" |
| + if err := policy(context.Background(), c, sel, hostwrite.Identity{}); err == nil { |
| + t.Fatal("another op's hold admitted") |
| + } |
| +} |
diff --git a/internal/hostbox/reconcile_adapter.go b/internal/hostbox/reconcile_adapter.go| index ceb403ebc..60a92d6d2 100644 |
| --- a/internal/hostbox/reconcile_adapter.go |
| +++ b/internal/hostbox/reconcile_adapter.go |
| @@ -86,77 +86,125 @@ func (a ReconcileAdapter) Install(ctx context.Context, rec provision.ClaimRecord |
| if captured.Incarnation != inc { |
| return ErrChanged |
| } |
| - 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) |
| + 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 |
| } |
| - if err != nil { |
| - return err |
| + observed, e := hostclaim.Read(ctx, a.Reconciler.Claims, rec.Host) |
| + if e != nil { |
| + return e |
| } |
| - if !in.Complete || in.Box != a.Box || in.Enrollment != a.Enrollment || in.DirectoryRevision != a.DirectoryRevision { |
| - return ErrTypedInventory |
| + if observed.Claim == nil || observed.Claim.Holder == nil || observed.Claim.Holder.Incarnation != captured.Incarnation || observed.Claim.Holder.Stamp != captured.Stamp { |
| + return ErrChanged |
| } |
| - 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 e = a.Census.Initialize(ctx, observed, selected); e != nil { |
| + return e |
| } |
| - 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 |
| + 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 |
| } |
| - row, err := a.Registry.Get(rec.Host) |
| - if err != nil { |
| + } |
| + if entry < 0 { |
| + var id [16]byte |
| + if _, err = rand.Read(id[:]); 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 { |
| + 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) |
| + } |
| + 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 || row.ClaimOwnership != owner { |
| - return registry.ErrClaimOwnership |
| + if row != nil { |
| + if row.ClaimOwnership != owner || row.HoldOp != op || !registry.IsHold(row.Status) { |
| + return registry.ErrClaimOwnership |
| + } |
| + return nil |
| } |
| - in.Entries[entry].Materialization = "materialized" |
| - return a.Inventory.WriteStopInventory(ctx, in) |
| + 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 |
diff --git a/internal/hostbox/reconcile_adapter_mint_test.go b/internal/hostbox/reconcile_adapter_mint_test.go| new file mode 100644 |
| index 000000000..98d112679 |
| --- /dev/null |
| +++ b/internal/hostbox/reconcile_adapter_mint_test.go |
| @@ -0,0 +1,134 @@ |
| +//go:build unix |
| + |
| +package hostbox |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// N4: a relocation target's held row is minted through the same typed |
| +// inventory as reconcile's own (ownedRow): on an empty installation the |
| +// census initialises, the route entry is recorded and materialized, and |
| +// the row is held by the op with its ClaimOwnership bound to the captured |
| +// stamp. A second call is idempotent. Another incarnation and a flat route |
| +// refuse with ErrChanged; a host-named file under the data root refuses |
| +// with ErrTypedInventory before any row is written. |
| +func TestMintHeldBindsTheRowToTheInstallStamp(t *testing.T) { |
| + type mint struct { |
| + f admissionFixture |
| + reg *registry.Registry |
| + inv *StopInventoryDB |
| + adapter ReconcileAdapter |
| + rec provision.ClaimRecord |
| + captured hostguard.Captured |
| + } |
| + setup := func(t *testing.T) mint { |
| + t.Helper() |
| + f := newAdmissionFixture(t, false) |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + inv, err := OpenStopInventoryDB(filepath.Join(t.TempDir(), "inventory.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { inv.Close() }) |
| + launches, err := OpenLaunchJournal(filepath.Join(t.TempDir(), "launches.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { launches.Close() }) |
| + census := &EmptyInstallationCensus{Inventory: inv, Registry: reg, Launches: launches, Box: "box", Enrollment: "en", DirectoryRevision: "1", |
| + Roots: map[string]string{"data": f.data, "opt": t.TempDir(), "units": t.TempDir()}, SharedPaths: map[string][]string{"data": {"hostguard"}}} |
| + adapter := ReconcileAdapter{Registry: reg, Inventory: inv, Census: census, Box: "box", Enrollment: "en", DirectoryRevision: "1", |
| + Reconciler: Reconciler{Claims: f.store, Box: "box", Region: "ie", Guard: hostguard.Guard{DataDir: f.data, LockDir: t.TempDir(), Claims: hostguard.StoreReader{Store: f.store}}}} |
| + record := provision.Record{Schema: 1, Host: f.host, Slug: "shop", App: "app", Account: f.account, Region: "ie", Channel: "stable", CreatedAt: time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC)} |
| + rec := provision.ClaimRecord{Host: f.host, Key: f.key, Family: "instance", Account: f.account, App: "app", CreatedAt: record.CreatedAt, Value: record, |
| + Route: provision.RouteFor(record, f.data, "", true)} |
| + return mint{f: f, reg: reg, inv: inv, adapter: adapter, rec: rec, captured: hostguard.Captured{Incarnation: f.holder.Incarnation, Stamp: f.holder.Stamp}} |
| + } |
| + ctx := t.Context() |
| + t.Run("from an empty installation", func(t *testing.T) { |
| + m := setup(t) |
| + if err := m.adapter.MintHeld(ctx, m.rec, m.captured, "relocating", "op-1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + row, err := m.reg.Get(m.f.host) |
| + if err != nil || row == nil { |
| + t.Fatalf("row: %+v %v", row, err) |
| + } |
| + sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", m.f.host, m.captured.Incarnation, m.captured.Stamp, "box"))) |
| + if row.Status != "relocating" || row.HoldOp != "op-1" || row.ClaimOwnership.ClaimStamp != m.captured.Stamp || row.ClaimOwnership.ClaimOwner != hex.EncodeToString(sum[:]) || |
| + row.ClaimOwnership.ClaimIncarnation != m.captured.Incarnation || !provision.NestedPaths(m.f.data, *row) { |
| + t.Fatalf("row %+v", row) |
| + } |
| + in, err := m.inv.ReadInstallation(ctx, m.f.host, m.captured.Incarnation, m.captured.Stamp) |
| + if err != nil || !in.Complete { |
| + t.Fatalf("inventory: %+v %v", in, err) |
| + } |
| + materialized := 0 |
| + for _, e := range in.Entries { |
| + if e.Kind == "route" && e.RelativePath == m.f.host && e.Materialization == "materialized" && e.RowIdentity == row.ClaimOwnership.ClaimRowID { |
| + materialized++ |
| + } |
| + } |
| + if materialized != 1 { |
| + t.Fatalf("route entries: %+v", in.Entries) |
| + } |
| + if err := m.adapter.MintHeld(ctx, m.rec, m.captured, "relocating", "op-1"); err != nil { |
| + t.Fatalf("a second mint: %v", err) |
| + } |
| + again, err := m.reg.Get(m.f.host) |
| + if err != nil || *again != *row { |
| + t.Fatalf("the second mint changed the row: %+v %v", again, err) |
| + } |
| + if err := m.adapter.MintHeld(ctx, m.rec, m.captured, "relocating", "op-2"); !errors.Is(err, registry.ErrClaimOwnership) { |
| + t.Fatalf("another op's mint over a held row: %v", err) |
| + } |
| + }) |
| + t.Run("another incarnation", func(t *testing.T) { |
| + m := setup(t) |
| + m.captured.Incarnation = "other" |
| + if err := m.adapter.MintHeld(ctx, m.rec, m.captured, "relocating", "op-1"); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("got %v", err) |
| + } |
| + }) |
| + t.Run("a flat route", func(t *testing.T) { |
| + m := setup(t) |
| + m.rec.Route = provision.RouteFor(m.rec.Value.(provision.Record), m.f.data, "", false) |
| + if err := m.adapter.MintHeld(ctx, m.rec, m.captured, "relocating", "op-1"); !errors.Is(err, ErrChanged) { |
| + t.Fatalf("got %v", err) |
| + } |
| + }) |
| + t.Run("not a hold status", func(t *testing.T) { |
| + m := setup(t) |
| + if err := m.adapter.MintHeld(ctx, m.rec, m.captured, "ready", "op-1"); !errors.Is(err, ErrUnsupported) { |
| + t.Fatalf("got %v", err) |
| + } |
| + }) |
| + t.Run("a host-named file under the data root", func(t *testing.T) { |
| + m := setup(t) |
| + if err := os.WriteFile(filepath.Join(m.f.data, m.f.host+".env"), []byte("X=1\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := m.adapter.MintHeld(ctx, m.rec, m.captured, "relocating", "op-1"); !errors.Is(err, ErrTypedInventory) { |
| + t.Fatalf("got %v", err) |
| + } |
| + if row, err := m.reg.Get(m.f.host); err != nil || row != nil { |
| + t.Fatalf("a row was written: %+v %v", row, err) |
| + } |
| + }) |
| +} |
diff --git a/internal/hostfiles/present.go b/internal/hostfiles/present.go| new file mode 100644 |
| index 000000000..a178d7af2 |
| --- /dev/null |
| +++ b/internal/hostfiles/present.go |
| @@ -0,0 +1,70 @@ |
| +package hostfiles |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "errors" |
| + "io" |
| + "os" |
| + "path/filepath" |
| +) |
| + |
| +// Present reports, read-only, whether dir and envPath hold exactly what m |
| +// names (region HA ruling 52, VerifyLocal): every live, non-skipped, |
| +// non-torn entry present as a regular file at its size and sha256, and |
| +// the env file a regular file at its platform entry's size and sha256, or |
| +// absent when m carries none or a deleted one. Extra files are not |
| +// checked: the app may write after P's opening, and Present is called |
| +// only before it. |
| +func Present(dir, envPath string, m Manifest) (bool, error) { |
| + for rel, e := range m.Entries { |
| + if e.Deleted || e.Torn || e.Skipped != "" { |
| + continue |
| + } |
| + ok, err := presentFile(filepath.Join(dir, filepath.FromSlash(rel)), e.Size, e.SHA256) |
| + if err != nil || !ok { |
| + return false, err |
| + } |
| + } |
| + // The env file is the platform entry's exact bytes (Astra round 2 |
| + // finding 9): a regular file at its size and digest; a deleted or |
| + // absent entry means no env file at all. |
| + e, wantEnv := m.Platform[EnvItem] |
| + if wantEnv && e.Deleted { |
| + wantEnv = false |
| + } |
| + if !wantEnv { |
| + _, err := os.Lstat(envPath) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return true, nil |
| + } |
| + return false, err |
| + } |
| + if e.Torn || e.Skipped != "" || e.SHA256 == "" { |
| + return false, nil |
| + } |
| + return presentFile(envPath, e.Size, e.SHA256) |
| +} |
| + |
| +func presentFile(path string, size int64, sum string) (bool, error) { |
| + info, err := os.Lstat(path) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return false, nil |
| + } |
| + if err != nil { |
| + return false, err |
| + } |
| + if !info.Mode().IsRegular() || info.Size() != size { |
| + return false, nil |
| + } |
| + f, err := os.Open(path) |
| + if err != nil { |
| + return false, err |
| + } |
| + defer f.Close() |
| + h := sha256.New() |
| + if _, err := io.Copy(h, f); err != nil { |
| + return false, err |
| + } |
| + return hex.EncodeToString(h.Sum(nil)) == sum, nil |
| +} |
diff --git a/internal/hostfiles/present_test.go b/internal/hostfiles/present_test.go| new file mode 100644 |
| index 000000000..ba287596f |
| --- /dev/null |
| +++ b/internal/hostfiles/present_test.go |
| @@ -0,0 +1,128 @@ |
| +package hostfiles |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +// Astra round 2 finding 9: after a good install, a truncated env, another |
| +// key, a directory or a symlink in its place is not present; a deleted |
| +// entry wants no env file. |
| +func TestPresentReadsTheEnvBack(t *testing.T) { |
| + env := []byte("CARLOS_ADMIN_TOKEN=t\nCARLOS_INSTANCE_KEY=k1\n") |
| + sum := sha256.Sum256(env) |
| + m := Manifest{V: ManifestVersion, Entries: map[string]Entry{}, Platform: map[string]Entry{EnvItem: {Object: "o", SHA256: hex.EncodeToString(sum[:]), Size: int64(len(env)), Seq: 1}}} |
| + for name, tc := range map[string]struct { |
| + make func(path string) error |
| + want bool |
| + }{ |
| + "the published bytes": {func(p string) error { return os.WriteFile(p, env, 0o600) }, true}, |
| + "truncated": {func(p string) error { return os.WriteFile(p, env[:10], 0o600) }, false}, |
| + "another key, same size": {func(p string) error { |
| + other := append([]byte(nil), env...) |
| + other[len(other)-2] = '2' |
| + return os.WriteFile(p, other, 0o600) |
| + }, false}, |
| + "a directory": {func(p string) error { return os.Mkdir(p, 0o700) }, false}, |
| + "a symlink to the bytes": {func(p string) error { |
| + real := p + ".real" |
| + if err := os.WriteFile(real, env, 0o600); err != nil { |
| + return err |
| + } |
| + return os.Symlink(real, p) |
| + }, false}, |
| + "absent": {func(string) error { return nil }, false}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "shop.example.env") |
| + if err := tc.make(path); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ok, err := Present(t.TempDir(), path, m); err != nil || ok != tc.want { |
| + t.Fatalf("Present = %v, %v; want %v", ok, err, tc.want) |
| + } |
| + }) |
| + } |
| + deleted := m |
| + deleted.Platform = map[string]Entry{EnvItem: {Deleted: true, Seq: 2}} |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "shop.example.env") |
| + if ok, err := Present(dir, path, deleted); err != nil || !ok { |
| + t.Fatalf("a deleted entry with no env file: %v %v", ok, err) |
| + } |
| + if err := os.WriteFile(path, env, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ok, _ := Present(dir, path, deleted); ok { |
| + t.Fatal("a deleted entry accepted an env file") |
| + } |
| +} |
| + |
| +// Ruling 52: Present reads every live entry back at its size and digest; |
| +// deleted, torn and skipped entries are not looked for; a missing, short, |
| +// changed or non-regular file is not present; extra files are not checked. |
| +func TestPresentReadsTheManifestBack(t *testing.T) { |
| + entry := func(body string) Entry { |
| + sum := sha256.Sum256([]byte(body)) |
| + return Entry{Object: "o", SHA256: hex.EncodeToString(sum[:]), Size: int64(len(body)), Seq: 1} |
| + } |
| + m := Manifest{V: ManifestVersion, Platform: map[string]Entry{}, Entries: map[string]Entry{ |
| + "public/a.txt": entry("alpha"), |
| + "b/c/d.bin": entry("delta"), |
| + "gone.txt": {Deleted: true, Seq: 2}, |
| + "torn.db": {Torn: true, Seq: 2}, |
| + "skipped/big": {Skipped: "too large", Seq: 2}, |
| + }} |
| + write := func(t *testing.T, dir string, files map[string]string) { |
| + t.Helper() |
| + for rel, body := range files { |
| + p := filepath.Join(dir, filepath.FromSlash(rel)) |
| + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + env := filepath.Join(t.TempDir(), "shop.example.env") // no EnvItem: absence wanted |
| + for name, tc := range map[string]struct { |
| + files map[string]string |
| + mutate func(t *testing.T, dir string) |
| + want bool |
| + }{ |
| + "every live entry": {map[string]string{"public/a.txt": "alpha", "b/c/d.bin": "delta"}, nil, true}, |
| + "plus files the app wrote": {map[string]string{"public/a.txt": "alpha", "b/c/d.bin": "delta", "extra": "x"}, nil, true}, |
| + "one missing": {map[string]string{"public/a.txt": "alpha"}, nil, false}, |
| + "one short": {map[string]string{"public/a.txt": "alph", "b/c/d.bin": "delta"}, nil, false}, |
| + "same size, other bytes": {map[string]string{"public/a.txt": "alphA", "b/c/d.bin": "delta"}, nil, false}, |
| + "a directory in its place": {map[string]string{"b/c/d.bin": "delta"}, func(t *testing.T, dir string) { |
| + if err := os.MkdirAll(filepath.Join(dir, "public", "a.txt"), 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, false}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + dir := t.TempDir() |
| + write(t, dir, tc.files) |
| + if tc.mutate != nil { |
| + tc.mutate(t, dir) |
| + } |
| + if ok, err := Present(dir, env, m); err != nil || ok != tc.want { |
| + t.Fatalf("Present = %v, %v; want %v", ok, err, tc.want) |
| + } |
| + }) |
| + } |
| + dir := t.TempDir() |
| + write(t, dir, map[string]string{"public/a.txt": "alpha", "b/c/d.bin": "delta"}) |
| + if err := os.WriteFile(env, []byte("X=1\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ok, err := Present(dir, env, m); err != nil || ok { |
| + t.Fatalf("an env file with no EnvItem was accepted: %v %v", ok, err) |
| + } |
| +} |
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go| index 70b3b0509..0aa7c7d81 100644 |
| --- a/internal/provision/reconcile.go |
| +++ b/internal/provision/reconcile.go |
| @@ -402,7 +402,7 @@ func (cfg ReconcileConfig) inScope(region, pool string, everywhere bool) bool { |
| return !recordOnFleet && !boxOnFleet |
| } |
| -// routeFor is the one answer to "what route does this record describe". The |
| +// RouteFor is the one answer to "what route does this record describe". The |
| // mint path builds from it and the adoption match test (design §4.2) |
| // compares against it — one function, so a shape the reconciler would mint |
| // and a shape it will adopt can never drift apart. |
| @@ -422,7 +422,7 @@ func (cfg ReconcileConfig) inScope(region, pool string, everywhere bool) bool { |
| // move a flat row to. Only the mint branch passes true, from |
| // ReconcileConfig.Layout; the drift and adoption comparisons derive flat |
| // and judge a nested row by NestedPaths. |
| -func routeFor(rec Record, dataDir, seed string, nested bool) registry.Route { |
| +func RouteFor(rec Record, dataDir, seed string, nested bool) registry.Route { |
| rt := registry.Route{ |
| Host: rec.Host, Slug: rec.Slug, Kind: rec.KindOrDefault(), Unit: rec.Unit, |
| App: rec.App, Account: rec.Account, Pool: rec.Pool, Region: rec.Region, |
| @@ -1137,7 +1137,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // exact route this branch hardcoded before they existed — that is |
| // the additivity rule, pinned by TestRecordWireShapeIsAdditive and |
| // TestReconcileMints. |
| - nr := routeFor(rec, cfg.DataDir, seed, nested) |
| + nr := RouteFor(rec, cfg.DataDir, seed, nested) |
| // A box that cannot provide this instance's config must not |
| // admit it: it would start without its secrets, and a keyed |
| // sibling would be left with a route that lost the race to one |
| @@ -1255,7 +1255,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // by design (the layout pass moved them), so when that is the |
| // only difference the line says so, rather than pointing an |
| // operator at `carlos remove`. |
| - want := routeFor(rec, cfg.DataDir, seed, false) |
| + want := RouteFor(rec, cfg.DataDir, seed, false) |
| unitDrift := rec.Unit != "" && want.Unit != existing.Unit |
| if want.Kind != existing.Kind || unitDrift || |
| want.Socket != existing.Socket || want.DBPath != existing.DBPath { |
| @@ -1282,7 +1282,7 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg |
| // A hand-added row on a nested box (`carlos add` there mints |
| // nested too) matches the record at the nested paths; the row's |
| // own shape says which derivation to compare against. |
| - want := routeFor(rec, cfg.DataDir, seed, NestedPaths(cfg.DataDir, existing)) |
| + want := RouteFor(rec, cfg.DataDir, seed, NestedPaths(cfg.DataDir, existing)) |
| if reason := shapeMismatch(existing, want); reason != "" { |
| log.Printf("provision: %s exists but is not the route this record describes (%s) — leaving it alone", |
| rec.Host, reason) |
| @@ -1450,7 +1450,7 @@ func mintHeld(ctx context.Context, reg *registry.Registry, cfg ReconcileConfig, |
| "CARLOS_INBOUND=none (no-inbound) and cannot serve routed instance traffic", rec.Host, rec.App, rec.Account) |
| return |
| } |
| - nr := routeFor(rec, cfg.DataDir, seed, false) |
| + nr := RouteFor(rec, cfg.DataDir, seed, false) |
| nr.Status, nr.HoldOp = status, op |
| if cfg.ConfigGate != nil { |
| if err := cfg.ConfigGate(ctx, nr); err != nil { |
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go| index 51e53e7c6..6179706b5 100644 |
| --- a/internal/provision/reconcile_test.go |
| +++ b/internal/provision/reconcile_test.go |
| @@ -1072,7 +1072,7 @@ func TestReconcileConvergesLegacyAddrToAddrs(t *testing.T) { |
| // (shapeMismatch is the adoption gate AND the drift test — the two failures |
| // the un-cleared addr caused, asserted at their source). |
| seed := seedJSON(&Manifest{Seed: []string{"seed"}}) // setup()'s manifest |
| - if reason := shapeMismatch(after, routeFor(rec, cfg.DataDir, seed, false)); reason != "" { |
| + if reason := shapeMismatch(after, RouteFor(rec, cfg.DataDir, seed, false)); reason != "" { |
| t.Errorf("converged row still diverges from its record (%s) — the pass would log this every tick and refuse re-adoption", reason) |
| } |
| @@ -2655,10 +2655,10 @@ func TestEverywhereClassAgreesAcrossRecordAndRow(t *testing.T) { |
| t.Fatalf("fixture is not a shape this pass would ever see: %v", err) |
| } |
| fromRecord := everywhereClass(f.rec) |
| - fromRow := everywhereRoute(routeFor(f.rec, "/data", `["seed"]`, false)) |
| + fromRow := everywhereRoute(RouteFor(f.rec, "/data", `["seed"]`, false)) |
| if fromRecord != fromRow { |
| t.Fatalf("class disagrees across the mint and removal gates: everywhereClass = %v, "+ |
| - "everywhereRoute(routeFor(...)) = %v — a record this box mints must be a row it can retire", |
| + "everywhereRoute(RouteFor(...)) = %v — a record this box mints must be a row it can retire", |
| fromRecord, fromRow) |
| } |
| }) |
| @@ -2674,7 +2674,7 @@ func TestEverywhereClassAgreesAcrossRecordAndRow(t *testing.T) { |
| func TestRouteForCarriesAddrsAndHealth(t *testing.T) { |
| rec := Record{Host: "m.example.com", App: "web", Account: "bab", Channel: "stable", |
| Kind: "service", Addrs: []string{"10.0.0.1:3000", "10.0.0.2:3000"}} |
| - rt := routeFor(rec, "/data", "seed", false) |
| + rt := RouteFor(rec, "/data", "seed", false) |
| if rt.Addrs != "10.0.0.1:3000,10.0.0.2:3000" { |
| t.Fatalf("Addrs = %q", rt.Addrs) |
| } |
| @@ -2687,7 +2687,7 @@ func TestRouteForCarriesAddrsAndHealth(t *testing.T) { |
| } |
| // Legacy single-addr record: byte-identical route to before this change. |
| old := Record{Host: "s.example.com", App: "web", Account: "bab", Channel: "stable", Kind: "service", Addr: "10.0.0.9:3000"} |
| - ort := routeFor(old, "/data", "seed", false) |
| + ort := RouteFor(old, "/data", "seed", false) |
| if ort.Addrs != "" || ort.Health != "" || ort.Addr != "10.0.0.9:3000" { |
| t.Fatalf("legacy record's route changed shape: %+v", ort) |
| } |
| @@ -2703,7 +2703,7 @@ func TestRouteForMaterializesPartialHealthDefaults(t *testing.T) { |
| rec := Record{Host: "m.example.com", App: "web", Account: "bab", Channel: "stable", |
| Kind: "service", Addrs: []string{"10.0.0.1:3000", "10.0.0.2:3000"}, |
| Health: &Health{Path: "/healthz"}} |
| - rt := routeFor(rec, "/data", "seed", false) |
| + rt := RouteFor(rec, "/data", "seed", false) |
| var h Health |
| if err := json.Unmarshal([]byte(rt.Health), &h); err != nil { |
| t.Fatalf("health JSON = %q (%v)", rt.Health, err) |
| @@ -2851,11 +2851,11 @@ func TestReconcileUnsetInboundMintsAsBefore(t *testing.T) { |
| // onto the route, byte for byte — no derivation, unlike Hibernate/StatusNamed. |
| func TestRouteForCarriesSteering(t *testing.T) { |
| rec := Record{Host: "m.example.com", App: "web", Account: "bab", Channel: "stable", Steering: "latency"} |
| - if got := routeFor(rec, "/data", "seed", false).Steering; got != "latency" { |
| + if got := RouteFor(rec, "/data", "seed", false).Steering; got != "latency" { |
| t.Fatalf("Steering = %q, want latency", got) |
| } |
| unsteered := Record{Host: "s.example.com", App: "web", Account: "bab", Channel: "stable"} |
| - if got := routeFor(unsteered, "/data", "seed", false).Steering; got != "" { |
| + if got := RouteFor(unsteered, "/data", "seed", false).Steering; got != "" { |
| t.Fatalf("Steering = %q, want empty for a record that never mentioned it", got) |
| } |
| } |
| @@ -3207,7 +3207,7 @@ func TestReconcileHoldsParticipantRows(t *testing.T) { |
| } |
| // The ie box: the source's moving row, held by the move. |
| - ieRow := routeFor(acmeRecord(sqid), "/data", `["seed"]`, false) |
| + ieRow := RouteFor(acmeRecord(sqid), "/data", `["seed"]`, false) |
| ieRow.Status, ieRow.HoldOp = "moving", "mv_acme" |
| ie := regWith(t, ieRow) |
| keep := true |
| @@ -3307,7 +3307,7 @@ func TestReconcileHoldsAnExistingRow(t *testing.T) { |
| if err := CreateRecord(ctx, s, rec); err != nil { |
| t.Fatal(err) |
| } |
| - owned := routeFor(rec, "/data", `["seed"]`, false) |
| + owned := RouteFor(rec, "/data", `["seed"]`, false) |
| cases := []struct { |
| name string |
| row registry.Route |
| @@ -3408,7 +3408,7 @@ func TestReconcileHoldRereadsTheRecordBeforeHoldingARow(t *testing.T) { |
| m.State = MoveCommitted |
| committed.Move = &m |
| cs := &changingStore{Store: s, key: RecordKey(rec.Account, rec.App, rec.Host), first: mustJSON(t, rec), later: mustJSON(t, committed)} |
| - reg := regWith(t, routeFor(rec, "/data", `["seed"]`, false)) // the target's row, opened |
| + reg := regWith(t, RouteFor(rec, "/data", `["seed"]`, false)) // the target's row, opened |
| var wrote holdLog |
| cfg := ReconcileConfig{Region: "uk", Pools: []string{"tito"}, DataDir: "/data", HoldWritten: wrote.written, |
| Hold: func(r Record) (string, string, bool) { return "landing", r.Move.ID, r.Move.State == MoveLanding }} |
| @@ -3434,7 +3434,7 @@ func TestReconcileHoldWriteIsRefusedWhenAnotherOpTookTheRow(t *testing.T) { |
| if err := CreateRecord(ctx, s, rec); err != nil { |
| t.Fatal(err) |
| } |
| - reg := regWith(t, routeFor(rec, "/data", `["seed"]`, false)) // unheld at the listing |
| + reg := regWith(t, RouteFor(rec, "/data", `["seed"]`, false)) // unheld at the listing |
| var wrote holdLog |
| cfg := ReconcileConfig{Region: "uk", Pools: []string{"tito"}, DataDir: "/data", HoldWritten: wrote.written, |
| Hold: func(r Record) (string, string, bool) { return "landing", r.Move.ID, r.Move.State == MoveLanding }} |
| @@ -3475,7 +3475,7 @@ func TestReconcileHoldWriteIsSerialisedWithTheMoveOpening(t *testing.T) { |
| if err := CreateRecord(ctx, s, rec); err != nil { |
| t.Fatal(err) |
| } |
| - reg := regWith(t, routeFor(rec, "/data", `["seed"]`, false)) |
| + reg := regWith(t, RouteFor(rec, "/data", `["seed"]`, false)) |
| var wrote holdLog |
| cfg := ReconcileConfig{Region: "uk", Pools: []string{"tito"}, DataDir: "/data", HoldWritten: wrote.written, |
| Hold: func(r Record) (string, string, bool) { return "landing", r.Move.ID, r.Move.State == MoveLanding }} |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index 091d2a804..efae303b5 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -1161,7 +1161,7 @@ AND routes.claim_owner=excluded.claim_owner AND routes.claim_row_id=excluded.cla |
| // HoldStatuses are the row statuses the edge never serves, ticks refuse and |
| // no ordinary wake reaches (moves spec §5.2). |
| -var HoldStatuses = map[string]bool{"moving": true, "landing": true, "restoring": true, "releasing": true} |
| +var HoldStatuses = map[string]bool{"moving": true, "landing": true, "restoring": true, "releasing": true, "relocating": true} |
| // IsHold reports whether status is one of HoldStatuses. |
| func IsHold(status string) bool { return HoldStatuses[status] } |
diff --git a/internal/registry/relocating_test.go b/internal/registry/relocating_test.go| new file mode 100644 |
| index 000000000..7cd528e88 |
| --- /dev/null |
| +++ b/internal/registry/relocating_test.go |
| @@ -0,0 +1,39 @@ |
| +package registry |
| + |
| +import ( |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +// Region HA ruling 31: "relocating" is a hold status. The edge never |
| +// serves it and ticks refuse it (IsHold); SetHold accepts it; the hold is |
| +// released only under its own op. |
| +func TestRelocatingIsAHoldStatus(t *testing.T) { |
| + if !IsHold("relocating") { |
| + t.Fatal("relocating is not a hold") |
| + } |
| + reg, err := Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + if err := reg.Upsert(Route{Host: "shop.example", Status: "ready", Socket: "/run/shop.sock"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetHold("shop.example", "relocating", "op-1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if changed, err := reg.ReleaseHold("shop.example", "op-2"); err != nil || changed { |
| + t.Fatalf("another op released the hold: %v %v", changed, err) |
| + } |
| + row, err := reg.Get("shop.example") |
| + if err != nil || row.Status != "relocating" || row.HoldOp != "op-1" { |
| + t.Fatalf("row %+v %v", row, err) |
| + } |
| + if changed, err := reg.ReleaseHold("shop.example", "op-1"); err != nil || !changed { |
| + t.Fatalf("its own op did not release the hold: %v %v", changed, err) |
| + } |
| + if row, _ := reg.Get("shop.example"); row.Status != "ready" || row.HoldOp != "" { |
| + t.Fatalf("after release: %+v", row) |
| + } |
| +} |