carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 91ed7c24433cf1b1fe5753aa2b1962a1362cd6ed parent 63ded7b
15 files changed, +1708 −91
  • internal/host/claimcensus.go +8 −1
  • internal/host/relocationassembly.go +19 −0
  • internal/host/relocationcensus_test.go +18 −9
  • internal/host/relocationheldstage_census_test.go +18 −0
  • internal/host/relocationinstall.go +447 −6
  • internal/host/relocationinstall_linux_test.go +643 −0
  • internal/host/relocationinstall_test.go +61 −0
  • internal/hostbox/reconcile_adapter.go +103 −55
  • internal/hostbox/reconcile_adapter_mint_test.go +134 −0
  • internal/hostfiles/present.go +70 −0
  • internal/hostfiles/present_test.go +128 −0
  • internal/provision/reconcile.go +6 −6
  • internal/provision/reconcile_test.go +13 −13
  • internal/registry/registry.go +1 −1
  • internal/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, &copy)
+ s := sel
+ tweak(&copy, &s)
+ if err := policy(context.Background(), &copy, 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)
+ }
+}