host: the relocation owner set: wake inputs over main's prepared publication, an op-keyed attempt journal that root reads with region HA on, the admission hooks and the probe attempt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+2047
−0
internal/host/claimlaunch_production.go+11 −0internal/host/claimowners.go+12 −0internal/host/relocationassembly.go+1 −0internal/host/relocationattempts.go+362 −0internal/host/relocationattempts_test.go+168 −0internal/host/relocationowners.go+213 −0internal/host/relocationowners_test.go+643 −0internal/host/relocationwake.go+432 −0internal/host/relocationwake_test.go+205 −0
diff --git a/internal/host/claimlaunch_production.go b/internal/host/claimlaunch_production.go| index f122c3dc6..913704088 100644 |
| --- a/internal/host/claimlaunch_production.go |
| +++ b/internal/host/claimlaunch_production.go |
| @@ -3,12 +3,14 @@ package host |
| import ( |
| "context" |
| "net/http" |
| + "os" |
| "os/exec" |
| "path/filepath" |
| "strings" |
| "github.com/carlosframework/platform/internal/controlsock" |
| "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| execlease "github.com/carlosframework/platform/internal/hostexec" |
| "github.com/carlosframework/platform/internal/instancectl" |
| ) |
| @@ -80,10 +82,19 @@ func claimLaunchFor(root ClaimRootHTTP, actor func(context.Context) (hostbox.Act |
| } |
| launch := &ClaimLaunchRoot{ValidateExecutable: claimStagedExecutable(&instancectl.Server{OptDir: roots["opt"], HostApp: instanceRegistryHostApp(nil)}), DataRoot: roots["data"], Clock: execlease.SystemClock{}, Authenticate: root.Stop.Authenticate, Guard: coordinator.Guard, Actor: actor, Journal: root.Launches, Units: units, |
| Kernel: linuxClaimLaunchKernel{OptRoot: roots["opt"], CgroupRoot: "/sys/fs/cgroup", ProcRoot: "/proc"}} |
| + if regionHAEnabled(claimLaunchEnv) { |
| + // Claim Plan 11 "Region HA integration and rollout": the root-owned |
| + // authoritative attempt reader for kind relocating (region HA ruling |
| + // 48). Off, Attempts stays empty and the kind is refused as before. |
| + launch.Attempts = map[string]claimLaunchAttemptReader{hostclaim.KindRelocating: relocationAttemptReader{DataDir: roots["data"]}} |
| + } |
| root.Stop.Coordinator.Kernel = claimUnitStopKernel{Inner: coordinator.Kernel, Journal: root.Launches, Units: units} |
| return ClaimRootWithLaunch{Root: root, Launch: launch}, nil |
| } |
| +// claimLaunchEnv is the process environment; tests replace it. |
| +var claimLaunchEnv = os.Getenv |
| + |
| func claimLaunchAbs(p string) bool { return filepath.IsAbs(p) && filepath.Clean(p) == p } |
| func claimStagedExecutable(server *instancectl.Server) func(string, string, string) error { |
diff --git a/internal/host/claimowners.go b/internal/host/claimowners.go| index 81f797b73..c93b8d0c7 100644 |
| --- a/internal/host/claimowners.go |
| +++ b/internal/host/claimowners.go |
| @@ -490,6 +490,13 @@ func (s *claimServingOwners) PublishFenced(ctx context.Context, o hostclaim.Obse |
| if err := s.serving(o); err != nil { |
| return ClaimPublicationReceipt{}, err |
| } |
| + return s.publishFresh(ctx, o, in, pub) |
| +} |
| + |
| +// publishFresh is PublishFenced after its kind check, unchanged: the one |
| +// fresh-generation marker writer, which region HA's later probe attempts |
| +// also use (rev 2, ruling 47). |
| +func (s *claimServingOwners) publishFresh(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs, pub ClaimPublication) (ClaimPublicationReceipt, error) { |
| if pub.Sync == nil || pub.Check == nil { |
| return ClaimPublicationReceipt{}, hostwrite.ErrUnavailable |
| } |
| @@ -617,6 +624,11 @@ func (s *claimServingOwners) publicationEvidence(ctx context.Context, o hostclai |
| if err := s.serving(o); err != nil { |
| return err |
| } |
| + return s.freshEvidence(ctx, o) |
| +} |
| + |
| +// freshEvidence is publicationEvidence after its kind check, unchanged. |
| +func (s *claimServingOwners) freshEvidence(ctx context.Context, o hostclaim.Observed) error { |
| h := o.Claim.Holder |
| s.mu.Lock() |
| receipt, ok := s.published[claimPublishedKey(o.Host, h.Session)] |
diff --git a/internal/host/relocationassembly.go b/internal/host/relocationassembly.go| index 2278e3b0c..9db05306f 100644 |
| --- a/internal/host/relocationassembly.go |
| +++ b/internal/host/relocationassembly.go |
| @@ -131,6 +131,7 @@ func (a ClaimAssembly) RelocationEffects(cfg RelocationAssemblyConfig, ack func( |
| Install: a.relocationInstall(cfg), |
| VerifyLocal: a.relocationVerifyLocal(cfg), |
| Receipt: a.relocationReceipt(cfg), |
| + Admit: a.relocationAdmit(cfg), |
| } |
| } |
diff --git a/internal/host/relocationattempts.go b/internal/host/relocationattempts.go| new file mode 100644 |
| index 000000000..9f142057c |
| --- /dev/null |
| +++ b/internal/host/relocationattempts.go |
| @@ -0,0 +1,362 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strconv" |
| + "sync" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// relocationAttemptJournal is one operation's probe journal, at |
| +// <DataDir>/relocate/ops/<op>/attempts.json (ruling 41, keyed by op per |
| +// ruling 44), written whole and renamed. The claim's probe_attempts.<box> |
| +// is the record attempt k; the journal binds each admitted session to one |
| +// k, keeps each k's first app start, and each k's current start. Every |
| +// field has its own JSON name (finding 14). |
| +type relocationAttemptJournal struct { |
| + V int `json:"v"` |
| + Host string `json:"host"` |
| + Op string `json:"op"` |
| + Opened map[string]time.Time `json:"opened"` // "k" -> the attempt's first app start |
| + Sessions map[string]int `json:"sessions"` // "session" -> k |
| + Starts map[string]relocationStart `json:"starts"` // "k" -> the attempt's current start |
| +} |
| + |
| +// relocationStart is one start's reservation, bound to the admitted |
| +// session and generation root's reader validates (Plan 11: the exact |
| +// operation, phase, generation, stamp and session). |
| +type relocationStart struct { |
| + Session uint64 `json:"session"` |
| + Generation string `json:"generation"` |
| + Stamp uint64 `json:"stamp"` |
| + N int `json:"n"` |
| + Attempt hostclaim.WakeRetryAttempt `json:"attempt"` |
| + State string `json:"state"` // reserved, failed, healthy |
| +} |
| + |
| +func relocationAttemptsPath(dataDir, op string) (string, error) { |
| + dir, err := relocationOpDir(dataDir, op) |
| + if err != nil { |
| + return "", err |
| + } |
| + return filepath.Join(dir, "attempts.json"), nil |
| +} |
| + |
| +func readRelocationAttempts(dataDir, host, op string) (relocationAttemptJournal, error) { |
| + path, err := relocationAttemptsPath(dataDir, op) |
| + if err != nil { |
| + return relocationAttemptJournal{}, err |
| + } |
| + raw, err := os.ReadFile(path) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return relocationAttemptJournal{V: 1, Host: host, Op: op, Opened: map[string]time.Time{}, Sessions: map[string]int{}, Starts: map[string]relocationStart{}}, nil |
| + } |
| + if err != nil { |
| + return relocationAttemptJournal{}, err |
| + } |
| + var j relocationAttemptJournal |
| + if err := json.Unmarshal(raw, &j); err != nil || j.V != 1 || j.Host != host || j.Op != op || j.Opened == nil || j.Sessions == nil || j.Starts == nil { |
| + return relocationAttemptJournal{}, fmt.Errorf("%w: relocation attempt journal %s", hostbox.ErrChanged, op) |
| + } |
| + return j, nil |
| +} |
| + |
| +func (j relocationAttemptJournal) write(dataDir string) error { |
| + path, err := relocationAttemptsPath(dataDir, j.Op) |
| + if err != nil { |
| + return err |
| + } |
| + raw, err := json.Marshal(j) |
| + if err != nil { |
| + return err |
| + } |
| + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { |
| + return err |
| + } |
| + return writeFileAtomic(path, raw, 0o600) |
| +} |
| + |
| +// relocationProbeAttempts is region HA's OperationAttempts for a probe |
| +// admitted under relocating/probing (claim Plan 11 R1, D2), modelled on |
| +// movesProbeAttempts without the record. Every method refuses |
| +// (hostbox.ErrChanged) once the claim no longer names the admitted writer |
| +// at probing, unstuck, with k = probe_attempts.<box> in 1..MaxProbeAttempts. |
| +type relocationProbeAttempts struct { |
| + Claims release.Store |
| + DataDir string |
| + Box string |
| + NewID func() string |
| + Now func() time.Time |
| + Settle, Ready time.Duration |
| + mu sync.Mutex |
| + current map[string]boundAttempt // host\x00session |
| +} |
| + |
| +var _ OperationAttempts = (*relocationProbeAttempts)(nil) |
| + |
| +func (p *relocationProbeAttempts) bound(ctx context.Context, s OperationStart) (int, error) { |
| + if p == nil || p.Claims == nil || p.DataDir == "" || p.Box == "" || p.NewID == nil || p.Now == nil { |
| + return 0, hostwrite.ErrUnavailable |
| + } |
| + a := s.Admitted |
| + if a.Claim == nil || a.Claim.Holder == nil || a.Claim.Op.Kind != hostclaim.KindRelocating || !s.Policy.Probe { |
| + return 0, hostbox.ErrUnsupported |
| + } |
| + h := a.Claim.Holder |
| + if s.Owner.Host != a.Host || s.Owner.Session != h.Session || s.Owner.Stamp != h.Stamp || s.Owner.Incarnation != h.Incarnation || s.Owner.Box != p.Box { |
| + return 0, hostbox.ErrChanged |
| + } |
| + fresh, err := hostclaim.Read(ctx, p.Claims, a.Host) |
| + if err != nil { |
| + return 0, err |
| + } |
| + if !claimSameWriter(a, fresh, hostclaim.SessionContinues) || fresh.Claim.Op.Phase != hostclaim.PhaseRelocatingProbing || fresh.Claim.Op.Journal["stuck"] != "" || fresh.Claim.Op.Exec != p.Box { |
| + return 0, hostbox.ErrChanged |
| + } |
| + k, err := strconv.Atoi(fresh.Claim.Op.Journal["probe_attempts."+p.Box]) |
| + if err != nil || k < 1 || k > hostclaim.MaxProbeAttempts { |
| + return 0, hostbox.ErrChanged |
| + } |
| + return k, nil |
| +} |
| + |
| +func relocationSessionKey(s OperationStart) string { |
| + return s.Admitted.Host + "\x00" + strconv.FormatUint(s.Owner.Session, 10) |
| +} |
| + |
| +// bind checks, and with write records, the session's record attempt. |
| +func (j relocationAttemptJournal) bind(session uint64, k int, write bool) error { |
| + key := strconv.FormatUint(session, 10) |
| + got, ok := j.Sessions[key] |
| + switch { |
| + case ok && got == k: |
| + return nil |
| + case ok: |
| + return fmt.Errorf("%w: session %d is bound to attempt %d, not %d", hostbox.ErrChanged, session, got, k) |
| + case !write: |
| + return fmt.Errorf("%w: session %d is bound to no attempt", hostbox.ErrChanged, session) |
| + } |
| + j.Sessions[key] = k |
| + return nil |
| +} |
| + |
| +func (p *relocationProbeAttempts) OpenProbe(ctx context.Context, s OperationStart, proposed time.Time) (time.Time, error) { |
| + k, err := p.bound(ctx, s) |
| + if err != nil { |
| + return time.Time{}, err |
| + } |
| + if proposed.IsZero() { |
| + return time.Time{}, hostwrite.ErrUnavailable |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + j, err := readRelocationAttempts(p.DataDir, s.Admitted.Host, s.Admitted.Claim.Op.ID) |
| + if err != nil { |
| + return time.Time{}, err |
| + } |
| + if err := j.bind(s.Owner.Session, k, true); err != nil { |
| + return time.Time{}, err |
| + } |
| + key := strconv.Itoa(k) |
| + opened, ok := j.Opened[key] |
| + if !ok { |
| + opened = proposed.UTC() |
| + j.Opened[key] = opened |
| + } |
| + return opened, j.write(p.DataDir) |
| +} |
| + |
| +func (p *relocationProbeAttempts) Reserve(ctx context.Context, s OperationStart, n int) error { |
| + k, err := p.bound(ctx, s) |
| + if err != nil { |
| + return err |
| + } |
| + digest, err := ClaimLaunchDigest(s.Inputs) |
| + if err != nil { |
| + return err |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + j, err := readRelocationAttempts(p.DataDir, s.Admitted.Host, s.Admitted.Claim.Op.ID) |
| + if err != nil { |
| + return err |
| + } |
| + if err := j.bind(s.Owner.Session, k, n == 0); err != nil { |
| + return err |
| + } |
| + key := strconv.Itoa(k) |
| + st, ok := j.Starts[key] |
| + same := ok && st.Session == s.Owner.Session |
| + h := s.Admitted.Claim.Holder |
| + switch { |
| + case n < 0: |
| + return hostbox.ErrChanged |
| + case n == 0 && same: |
| + if st.N != 0 || st.State != probeReserved || !sameStartOwner(st.Attempt, s, digest) || st.Generation != h.Generation || st.Stamp != h.Stamp { |
| + return hostbox.ErrChanged |
| + } |
| + case n == 0: |
| + st = relocationStart{Session: s.Owner.Session, Generation: h.Generation, Stamp: h.Stamp, State: probeReserved} |
| + default: |
| + if !same || st.N != n-1 || st.State != probeFailed || st.Attempt.LaunchInputs != digest { |
| + return hostbox.ErrChanged |
| + } |
| + st = relocationStart{Session: s.Owner.Session, Generation: h.Generation, Stamp: h.Stamp, N: n, State: probeReserved} |
| + } |
| + if st.Attempt.ID == "" { |
| + o := s.Owner |
| + st.Attempt = hostclaim.WakeRetryAttempt{ID: p.NewID(), Box: o.Box, Enrollment: o.Enrollment, BootID: o.BootID, ControllerID: o.ControllerID, Session: o.Session, InvocationID: p.NewID(), LaunchInputs: digest} |
| + if st.Attempt.ID == "" || st.Attempt.InvocationID == "" || st.Attempt.ID == st.Attempt.InvocationID { |
| + return hostwrite.ErrUnavailable |
| + } |
| + j.Starts[key] = st |
| + } |
| + if err := j.write(p.DataDir); err != nil { |
| + return err |
| + } |
| + if p.current == nil { |
| + p.current = map[string]boundAttempt{} |
| + } |
| + p.current[relocationSessionKey(s)] = boundAttempt{k: k, attempt: st.Attempt} |
| + return nil |
| +} |
| + |
| +func (p *relocationProbeAttempts) CurrentAttempt(s OperationStart) hostclaim.WakeRetryAttempt { |
| + if p == nil { |
| + return hostclaim.WakeRetryAttempt{} |
| + } |
| + p.mu.Lock() |
| + cur, ok := p.current[relocationSessionKey(s)] |
| + p.mu.Unlock() |
| + if !ok { |
| + return hostclaim.WakeRetryAttempt{} |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) |
| + defer cancel() |
| + if k, err := p.bound(ctx, s); err != nil || k != cur.k { |
| + return hostclaim.WakeRetryAttempt{} |
| + } |
| + return cur.attempt |
| +} |
| + |
| +func (p *relocationProbeAttempts) Failed(ctx context.Context, s OperationStart, n int) error { |
| + recovery, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) |
| + defer cancel() |
| + return p.settle(recovery, s, n, probeFailed) |
| +} |
| + |
| +func (p *relocationProbeAttempts) settle(ctx context.Context, s OperationStart, n int, state string) error { |
| + k, err := p.bound(ctx, s) |
| + if err != nil { |
| + return err |
| + } |
| + p.mu.Lock() |
| + defer p.mu.Unlock() |
| + j, err := readRelocationAttempts(p.DataDir, s.Admitted.Host, s.Admitted.Claim.Op.ID) |
| + if err != nil { |
| + return err |
| + } |
| + if err := j.bind(s.Owner.Session, k, false); err != nil { |
| + return err |
| + } |
| + key := strconv.Itoa(k) |
| + st, ok := j.Starts[key] |
| + cur := p.current[relocationSessionKey(s)] |
| + if !ok || st.Session != s.Owner.Session || st.N != n || st.State != probeReserved || cur.k != k || st.Attempt != cur.attempt { |
| + return hostbox.ErrChanged |
| + } |
| + st.State = state |
| + j.Starts[key] = st |
| + return j.write(p.DataDir) |
| +} |
| + |
| +// Healthy is movesProbeAttempts.Healthy's settle and second request |
| +// (moves §7.7), which the relocation probe shares (spec §5.8). |
| +func (p *relocationProbeAttempts) Healthy(ctx context.Context, s OperationStart, n int, cmd *exec.Cmd, again func(context.Context) error) error { |
| + if _, err := p.bound(ctx, s); err != nil { |
| + return err |
| + } |
| + if again == nil || !probeAlive(cmd) { |
| + return hostbox.ErrChanged |
| + } |
| + settle, ready := p.Settle, p.Ready |
| + if settle <= 0 { |
| + settle = time.Duration(moves.SettleSeconds * float64(time.Second)) |
| + } |
| + if ready <= 0 { |
| + ready = time.Duration(moves.ProbeSeconds * float64(time.Second)) |
| + } |
| + timer := time.NewTimer(settle) |
| + select { |
| + case <-ctx.Done(): |
| + timer.Stop() |
| + return ctx.Err() |
| + case <-timer.C: |
| + } |
| + if !probeAlive(cmd) { |
| + return fmt.Errorf("%w: the app exited during the settle", hostbox.ErrChanged) |
| + } |
| + asked, cancel := context.WithTimeout(ctx, ready) |
| + err := again(asked) |
| + cancel() |
| + if err != nil { |
| + return err |
| + } |
| + if !probeAlive(cmd) { |
| + return fmt.Errorf("%w: the app exited after the second request", hostbox.ErrChanged) |
| + } |
| + return p.settle(ctx, s, n, probeHealthy) |
| +} |
| + |
| +// relocationAttemptReader is root's authoritative attempt reader for kind |
| +// relocating (ruling 48; claim Plan 11 "install and test root-owned |
| +// authoritative attempt readers"): it reads the operation's durable |
| +// journal and returns the reserved start only for exactly the observed |
| +// op at probing, its executor's attempt k, the holder's session, |
| +// generation and stamp (the journaled install stamp). Root's authorize |
| +// then binds the reservation, the launch-input digest and the peer. |
| +type relocationAttemptReader struct{ DataDir string } |
| + |
| +func (r relocationAttemptReader) ReadAttempt(_ context.Context, o hostclaim.Observed) (hostclaim.WakeRetryAttempt, error) { |
| + c := o.Claim |
| + if r.DataDir == "" || c == nil || c.Holder == nil || c.Op.Kind != hostclaim.KindRelocating || c.Op.Phase != hostclaim.PhaseRelocatingProbing || c.Op.Journal["stuck"] != "" { |
| + return hostclaim.WakeRetryAttempt{}, hostbox.ErrChanged |
| + } |
| + h := c.Holder |
| + // P's opening keeps the install stamp; a later cold attempt runs at |
| + // its own admission's stamp (generation.go: !KeepStamp sets stamp to |
| + // the session), which the journaled start must name (Astra round 2 |
| + // finding 4). Main's claimLaunchAuthority still pins every relocating |
| + // launch to install_stamp until claim ask C11. |
| + if h.Generation == c.Op.Journal["prepared"] && c.Op.Journal["install_stamp"] != strconv.FormatUint(h.Stamp, 10) { |
| + return hostclaim.WakeRetryAttempt{}, hostbox.ErrChanged |
| + } |
| + k, err := strconv.Atoi(c.Op.Journal["probe_attempts."+c.Op.Exec]) |
| + if err != nil || k < 1 || k > hostclaim.MaxProbeAttempts { |
| + return hostclaim.WakeRetryAttempt{}, hostbox.ErrChanged |
| + } |
| + j, err := readRelocationAttempts(r.DataDir, o.Host, c.Op.ID) |
| + if err != nil { |
| + return hostclaim.WakeRetryAttempt{}, err |
| + } |
| + if j.Sessions[strconv.FormatUint(h.Session, 10)] != k { |
| + return hostclaim.WakeRetryAttempt{}, hostbox.ErrChanged |
| + } |
| + st, ok := j.Starts[strconv.Itoa(k)] |
| + if !ok || st.State != probeReserved || st.Session != h.Session || st.Generation != h.Generation || st.Stamp != h.Stamp || st.Attempt.Session != h.Session || st.Attempt.Box != c.Op.Exec { |
| + return hostclaim.WakeRetryAttempt{}, hostbox.ErrChanged |
| + } |
| + return st.Attempt, nil |
| +} |
diff --git a/internal/host/relocationattempts_test.go b/internal/host/relocationattempts_test.go| new file mode 100644 |
| index 000000000..698b70f8f |
| --- /dev/null |
| +++ b/internal/host/relocationattempts_test.go |
| @@ -0,0 +1,168 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// Finding 14: every journal field has its own JSON name and survives a |
| +// round trip; a journal for another host or op is refused. |
| +func TestRelocationAttemptJournalRoundTrips(t *testing.T) { |
| + dd := t.TempDir() |
| + j, err := readRelocationAttempts(dd, "shop.example", "op1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + j.Opened["1"] = time.Date(2026, 10, 10, 9, 0, 0, 0, time.UTC) |
| + j.Sessions["7"] = 1 |
| + j.Starts["1"] = relocationStart{Session: 7, Generation: "P1", Stamp: 9, State: probeReserved, Attempt: hostclaim.WakeRetryAttempt{ID: "a", InvocationID: "b", Session: 7, Box: "dst"}} |
| + if err := j.write(dd); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _ := os.ReadFile(filepath.Join(dd, "relocate", "ops", "op1", "attempts.json")) |
| + for _, key := range []string{`"host":"shop.example"`, `"op":"op1"`} { |
| + if !strings.Contains(string(raw), key) { |
| + t.Fatalf("%s missing from %s", key, raw) |
| + } |
| + } |
| + got, err := readRelocationAttempts(dd, "shop.example", "op1") |
| + if err != nil || got.Starts["1"] != j.Starts["1"] || got.Sessions["7"] != 1 || !got.Opened["1"].Equal(j.Opened["1"]) { |
| + t.Fatalf("round trip: %+v %v", got, err) |
| + } |
| + if _, err := readRelocationAttempts(dd, "other.example", "op1"); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("another host's journal was read: %v", err) |
| + } |
| +} |
| + |
| +// Finding 7: root's reader returns the reserved start only for the exact |
| +// op at probing, the executor's attempt k, session, generation and stamp. |
| +func TestRootReadsTheRelocationAttemptJournal(t *testing.T) { |
| + dd := t.TempDir() |
| + j, _ := readRelocationAttempts(dd, "shop.example", "op1") |
| + j.Sessions["7"] = 1 |
| + attempt := hostclaim.WakeRetryAttempt{ID: "a", InvocationID: "b", Session: 7, Box: "dst"} |
| + j.Starts["1"] = relocationStart{Session: 7, Generation: "P1", Stamp: 9, State: probeReserved, Attempt: attempt} |
| + if err := j.write(dd); err != nil { |
| + t.Fatal(err) |
| + } |
| + claim := func() *hostclaim.Claim { |
| + return &hostclaim.Claim{Host: "shop.example", Holder: &hostclaim.Holder{Incarnation: "inc", Stamp: 9, Session: 7, Generation: "P1", WriterBox: "dst"}, |
| + Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "op1", Phase: hostclaim.PhaseRelocatingProbing, Exec: "dst", Journal: map[string]string{"install_stamp": "9", "prepared": "P1", "probe_attempts.dst": "1"}}} |
| + } |
| + r := relocationAttemptReader{DataDir: dd} |
| + got, err := r.ReadAttempt(context.Background(), hostclaim.Observed{Host: "shop.example", Claim: claim()}) |
| + if err != nil || got != attempt { |
| + t.Fatalf("read %+v %v", got, err) |
| + } |
| + // Astra round 2 finding 4: a later cold attempt runs at its own |
| + // admission's stamp; the reader takes the journaled start at that |
| + // stamp (root's claimLaunchAuthority pin is claim ask C11). |
| + j2, _ := readRelocationAttempts(dd, "shop.example", "op1") |
| + j2.Sessions["14"] = 2 |
| + later := hostclaim.WakeRetryAttempt{ID: "c", InvocationID: "d", Session: 14, Box: "dst"} |
| + j2.Starts["2"] = relocationStart{Session: 14, Generation: "g2", Stamp: 14, State: probeReserved, Attempt: later} |
| + if err := j2.write(dd); err != nil { |
| + t.Fatal(err) |
| + } |
| + second := claim() |
| + second.Holder.Stamp, second.Holder.Session, second.Holder.Generation = 14, 14, "g2" |
| + second.Op.Journal["probe_attempts.dst"] = "2" |
| + second.Op.Journal["prepared_opened"] = "P1" |
| + if got, err := r.ReadAttempt(context.Background(), hostclaim.Observed{Host: "shop.example", Claim: second}); err != nil || got != later { |
| + t.Fatalf("second attempt: %+v %v", got, err) |
| + } |
| + for name, change := range map[string]func(*hostclaim.Claim){ |
| + "another session": func(c *hostclaim.Claim) { c.Holder.Session = 8 }, |
| + "another generation": func(c *hostclaim.Claim) { c.Holder.Generation = "g2" }, |
| + "another stamp": func(c *hostclaim.Claim) { c.Holder.Stamp = 10; c.Op.Journal["install_stamp"] = "10" }, |
| + "P at a stamp not the install stamp": func(c *hostclaim.Claim) { c.Op.Journal["install_stamp"] = "8" }, |
| + "a later attempt": func(c *hostclaim.Claim) { c.Op.Journal["probe_attempts.dst"] = "2" }, |
| + "installed": func(c *hostclaim.Claim) { c.Op.Phase = hostclaim.PhaseInstalled }, |
| + "stuck": func(c *hostclaim.Claim) { c.Op.Journal["stuck"] = "x" }, |
| + } { |
| + c := claim() |
| + change(c) |
| + if _, err := r.ReadAttempt(context.Background(), hostclaim.Observed{Host: "shop.example", Claim: c}); err == nil { |
| + t.Fatalf("%s: read", name) |
| + } |
| + } |
| +} |
| + |
| +// Finding 4: a relocation target's route is the nested layout, and the |
| +// files and env paths are the instance directory's. |
| +func TestRelocationRouteIsNestedAndPathsAgree(t *testing.T) { |
| + dd := "/data" |
| + rt := provision.RouteFor(provision.Record{Host: "shop.example", App: "shop", Account: "acct", Channel: "stable"}, dd, "", true) |
| + if rt.DBPath != provision.NestedDBPath(dd, "shop.example") || rt.Socket != provision.NestedSocket("shop.example") { |
| + t.Fatalf("not nested: %+v", rt) |
| + } |
| + p := relocationPathsFor(dd, rt) |
| + dir := provision.InstanceDir(dd, "shop.example") |
| + if p.FilesDir != filepath.Join(dir, "shop.example") || p.EnvPath != filepath.Join(dir, "shop.example.env") { |
| + t.Fatalf("paths %+v", p) |
| + } |
| + if _, err := claimIsolatedPaths(map[string]string{"data": dd}, "shop.example", ClaimLaunchInputs{DBPath: rt.DBPath, Limit: cgroup.Limit{MemoryMax: cgroup.TierDefault.MemoryMax}}); err != nil { |
| + t.Fatalf("the isolated launch refuses the minted route: %v", err) |
| + } |
| +} |
| + |
| +// Finding 7, ruling 48: root installs the relocation attempt reader only |
| +// with region HA on; its launch capability answers for kind relocating |
| +// only then. |
| +func TestRootInstallsTheRelocationReaderOnlyWithRegionHA(t *testing.T) { |
| + journal, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "launch.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer journal.Close() |
| + inner := stopKernelFunc(func(context.Context, hostguard.FenceTarget, StopInventory, DrainAcknowledgement) (StopKernelEvidence, error) { |
| + return StopKernelEvidence{}, nil |
| + }) |
| + root := ClaimRootHTTP{Launches: journal, Stop: ClaimStopHTTP{Authenticate: func(context.Context, net.Conn) (StopPeer, error) { return StopPeer{}, nil }, |
| + Coordinator: ClaimStopCoordinator{Kernel: inner, Guard: hostguard.Guard{DataDir: "/data", Claims: &launchClaims{}}}}} |
| + actor := func(context.Context) (hostbox.Actor, error) { return hostbox.Actor{}, nil } |
| + saved := claimLaunchEnv |
| + t.Cleanup(func() { claimLaunchEnv = saved }) |
| + q := hostbox.RoleLaunchRequest{V: hostbox.RoleLaunchV, Op: hostbox.RoleLaunchCapability, Kind: hostclaim.KindRelocating} |
| + for _, on := range []bool{true, false} { |
| + value := "" |
| + if on { |
| + value = "1" |
| + } |
| + claimLaunchEnv = func(k string) string { |
| + if k == "CARLOS_REGION_HA" { |
| + return value |
| + } |
| + return "" |
| + } |
| + wrapped, err := claimLaunchFor(root, actor, map[string]string{"opt": "/opt/carlos", "data": "/data"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + reader, installed := wrapped.Launch.Attempts[hostclaim.KindRelocating].(relocationAttemptReader) |
| + if installed != on || (on && reader.DataDir != "/data") || (!on && wrapped.Launch.Attempts != nil) { |
| + t.Fatalf("region HA %v: attempts %+v", on, wrapped.Launch.Attempts) |
| + } |
| + f := newLaunchFixture(t, servingOp()) |
| + f.root.Attempts = wrapped.Launch.Attempts |
| + _, err = f.root.Serve(t.Context(), f.peer, q) |
| + if on && err != nil { |
| + t.Fatalf("the relocating capability with region HA on: %v", err) |
| + } |
| + if !on && !errors.Is(err, hostbox.ErrUnsupported) { |
| + t.Fatalf("the relocating capability with region HA off: %v", err) |
| + } |
| + } |
| +} |
diff --git a/internal/host/relocationowners.go b/internal/host/relocationowners.go| new file mode 100644 |
| index 000000000..3656574a8 |
| --- /dev/null |
| +++ b/internal/host/relocationowners.go |
| @@ -0,0 +1,213 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "os" |
| + "slices" |
| + "sync" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostrelocate" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// RelocationSource is region HA's ClaimProductionRelocationSource (claim |
| +// Plan 11 R1, R8), registered once at agent startup (Task 10) and opened |
| +// by the production provider only with CARLOS_REGION_HA on. Open returns |
| +// the complete owner set and two refusals (rulings 37, 39), or, while |
| +// moves' C8 provider is not bound (dependency C8, ruling 53), the zero |
| +// owner set: relocating stays refused exactly as before Plan 11. |
| +type RelocationSource struct { |
| + DataDir string |
| + Directory hostactors.Directory |
| + // FilesStore opens the data bucket as a release.Store (ruling 50); |
| + // production is release.NewS3Store. |
| + FilesStore func(ctx context.Context, bucket string) (release.Store, error) |
| + |
| + mu sync.Mutex |
| + serving ClaimServingSources |
| +} |
| + |
| +var _ ClaimProductionRelocationSource = (*RelocationSource)(nil) |
| + |
| +// SetServingSources hands the source the serving launch, markers and the |
| +// C8 provider before Open (claimproduction.go, inside the region HA gate). |
| +func (s *RelocationSource) SetServingSources(src ClaimServingSources) { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + s.serving = src |
| +} |
| + |
| +func (s *RelocationSource) Open(ctx context.Context, store release.Store, data lease.VersionedStore, actor hostbox.Actor) (ClaimRelocationOwners, []hostbox.PolicyRefusal, error) { |
| + if s == nil { |
| + return ClaimRelocationOwners{}, nil, hostwrite.ErrUnavailable |
| + } |
| + s.mu.Lock() |
| + src := s.serving |
| + s.mu.Unlock() |
| + if store == nil || data == nil || actor.Box == "" || actor.Bucket == "" || actor.Region == "" || s.DataDir == "" || s.FilesStore == nil { |
| + return ClaimRelocationOwners{}, nil, hostwrite.ErrUnavailable |
| + } |
| + if src.Files == nil { |
| + // C8 unbound (ruling 53): no owners, no refusals. The dispatcher |
| + // keeps relocating refused and Task 10's pass stays off. |
| + log.Printf("region HA: relocation owners not opened: the moves C8 files-manifest provider is not bound") |
| + return ClaimRelocationOwners{}, nil, nil |
| + } |
| + if src.Launch == nil || src.Markers == nil { |
| + return ClaimRelocationOwners{}, nil, hostwrite.ErrUnavailable |
| + } |
| + files, err := s.FilesStore(ctx, actor.Bucket) |
| + if err != nil { |
| + return ClaimRelocationOwners{}, nil, err |
| + } |
| + releases := StopReceiptReleases{Receipts: RelocationReleaseReceipts{Store: store, Directory: s.Directory}} |
| + inputs := &RelocationWakeInputs{Launch: src.Launch, FilesManifest: src.Files, Markers: src.Markers, Claims: store, FilesStore: files, |
| + Data: hostbox.RequestStore{Store: data, Check: func(context.Context) error { return nil }}, Bucket: actor.Bucket, Region: actor.Region, Box: actor.Box, |
| + NewID: newClaimID, Now: time.Now, Releases: releases} |
| + attempts := &relocationProbeAttempts{Claims: store, DataDir: s.DataDir, Box: actor.Box, NewID: newClaimID, Now: time.Now} |
| + owners := ClaimRelocationOwners{ |
| + BeforeAdmission: relocationBeforeAdmission(actor.Box, releases), |
| + AfterAdmission: relocationAfterAdmission(s.DataDir, actor.Box), |
| + ClaimKindOwners: ClaimKindOwners{Inputs: inputs, AdmissionEvidence: inputs.AdmissionEvidence, PublicationEvidence: inputs.PublicationEvidence}, |
| + Operations: attempts, |
| + Policy: relocationPolicy(actor.Box), |
| + } |
| + return owners, []hostbox.PolicyRefusal{refuseStaleStamp, refuseLostBox}, nil |
| +} |
| + |
| +func (s *RelocationSource) Close(context.Context) error { return nil } |
| + |
| +// newClaimID is the 16-byte hex id claimconstruction.go's newID makes. |
| +func newClaimID() string { |
| + var b [16]byte |
| + if _, err := rand.Read(b[:]); err != nil { |
| + return "" |
| + } |
| + return hex.EncodeToString(b[:]) |
| +} |
| + |
| +func refuseStaleStamp(_ context.Context, c *hostclaim.Claim, s hostbox.Selection, _ hostwrite.Identity) error { |
| + if c != nil && c.Op.Kind == hostclaim.KindRelocating && (c.Holder == nil || s.Captured.Incarnation != c.Holder.Incarnation || s.Captured.Stamp != c.Holder.Stamp) { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| +} |
| + |
| +func refuseLostBox(_ context.Context, c *hostclaim.Claim, s hostbox.Selection, _ hostwrite.Identity) error { |
| + if c != nil && slices.Contains(c.Lost, s.Box) { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| +} |
| + |
| +// relocationBeforeAdmission verifies the source's release (a drain's |
| +// journaled release at its receipt) or its fence (a failover) before the |
| +// target's first admission; a later attempt's predecessor is this box's |
| +// own session, which main's Stop ended. |
| +func relocationBeforeAdmission(box string, releases hostclaim.ReleaseAuthority) func(context.Context, hostclaim.Observed) error { |
| + return func(ctx context.Context, prior hostclaim.Observed) error { |
| + c := prior.Claim |
| + if c == nil || c.Holder == nil || c.Op.Kind != hostclaim.KindRelocating || c.Op.Exec != box { |
| + return hostbox.ErrChanged |
| + } |
| + if hostclaim.PreparedOpened(c) { |
| + return nil |
| + } |
| + var rel hostclaim.Release |
| + if json.Unmarshal([]byte(c.Op.Journal["release"]), &rel) == nil && rel.ReleasedBy != "" { |
| + return releases.VerifyRelease(ctx, *c, rel) |
| + } |
| + src := c.Op.Intent["source_box"] |
| + if slices.Contains(c.Lost, src) && c.Op.Journal["fence."+src] != "" { |
| + return nil |
| + } |
| + return fmt.Errorf("%w: no release and no fence for %s", hostrelocate.ErrRefused, src) |
| + } |
| +} |
| + |
| +// relocationAfterAdmission verifies the target's installation after the |
| +// admission: the local install receipt is this box's, as journaled, and |
| +// P's opening keeps install_stamp. |
| +func relocationAfterAdmission(dataDir, box string) func(context.Context, hostclaim.Observed, hostclaim.Observed) error { |
| + return func(_ context.Context, prior, admitted hostclaim.Observed) error { |
| + c, n := prior.Claim, admitted.Claim |
| + if c == nil || n == nil || n.Holder == nil || n.Op.ID != c.Op.ID || n.Op.Exec != box || n.Holder.WriterBox != box { |
| + return hostbox.ErrChanged |
| + } |
| + var l hostclaim.LocalInstall |
| + if json.Unmarshal([]byte(n.Op.Journal["local_install"]), &l) != nil || l.Box != box || l.InstallStamp == 0 { |
| + return hostbox.ErrChanged |
| + } |
| + path, err := relocationInstallPath(dataDir, n.Op.ID) |
| + if err != nil { |
| + return err |
| + } |
| + raw, err := os.ReadFile(path) |
| + if err != nil { |
| + return err |
| + } |
| + if sum := sha256.Sum256(raw); hex.EncodeToString(sum[:]) != l.JournalSHA256 { |
| + return hostbox.ErrChanged |
| + } |
| + if n.Holder.Generation == n.Op.Journal["prepared"] && n.Holder.Stamp != l.InstallStamp { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| + } |
| +} |
| + |
| +// relocationAdmit is one probe attempt: the activator's ProbeWake with the |
| +// probe deadline and, on P's prepared opening, P's install manifest. |
| +func (a ClaimAssembly) relocationAdmit(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed, bool) (uint64, bool, error) { |
| + return func(ctx context.Context, o hostclaim.Observed, certified bool) (uint64, bool, error) { |
| + if cfg.Activator == nil || cfg.Versions == nil || o.Claim == nil || o.Claim.Holder == nil { |
| + return 0, false, ErrClaimStopUnavailable |
| + } |
| + c := o.Claim |
| + if certified != hostrelocate.CertifiedFor(c) { |
| + return 0, false, fmt.Errorf("%w: the participant and the owners disagree on certification", hostrelocate.ErrRefused) |
| + } |
| + since, err := time.Parse(time.RFC3339Nano, c.Op.Journal["probing_since"]) |
| + if err != nil { |
| + return 0, false, err |
| + } |
| + pc := activator.ProbeConfig{Host: o.Host, Op: c.Op.ID, HoldOp: c.Op.ID, Deadline: since.Add(hostrelocate.ProbeDeadline)} |
| + if !hostclaim.PreparedOpened(c) { |
| + auth, err := preparedAuthority(c) |
| + if err != nil { |
| + return 0, false, err |
| + } |
| + if auth.InstallManifest != "" { |
| + im, _, err := readInstallManifest(ctx, cfg.Versions, cfg.Bucket, moves.GenerationPrefix(c.Holder.Location, auth.Generation)) |
| + if err != nil { |
| + return 0, false, err |
| + } |
| + pc.Prepared = &im |
| + } |
| + } |
| + res, err := cfg.Activator.ProbeWake(ctx, pc) |
| + switch { |
| + case err == nil: |
| + return res.Session, true, nil |
| + case errors.Is(err, activator.ErrProbeFailed), errors.Is(err, activator.ErrAttemptOver): |
| + return 0, false, nil |
| + default: |
| + return 0, false, err |
| + } |
| + } |
| +} |
diff --git a/internal/host/relocationowners_test.go b/internal/host/relocationowners_test.go| new file mode 100644 |
| index 000000000..3d24a6ba5 |
| --- /dev/null |
| +++ b/internal/host/relocationowners_test.go |
| @@ -0,0 +1,643 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/genselect" |
| + "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/hostrelocate" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/lease/leasetest" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/superfly/ltx" |
| +) |
| + |
| +const ( |
| + roHost = "shop.example" |
| + roOp = "rel-1" |
| + roP = "P1" |
| +) |
| + |
| +var roProbe = activator.StartPolicy{MaxStarts: 5, Window: 30 * time.Second, Probe: true} |
| + |
| +// roReleases is the release authority the owners verify a drain's release |
| +// with; it records every call. |
| +type roReleases struct { |
| + calls int |
| + err error |
| +} |
| + |
| +func (r *roReleases) VerifyRelease(context.Context, hostclaim.Claim, hostclaim.Release) error { |
| + r.calls++ |
| + return r.err |
| +} |
| + |
| +// roFiles is moves' C8 provider: it records each Establish under the work |
| +// it is given. |
| +type roFiles struct{ established []string } |
| + |
| +func (f *roFiles) Establish(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs, work hostwrite.Work) error { |
| + return work.With(ctx, in.Identity, func() error { |
| + f.established = append(f.established, fmt.Sprintf("%s@%d", o.Host, o.Claim.Holder.Session)) |
| + return nil |
| + }) |
| +} |
| + |
| +// roFixture is a relocation target at relocating/probing, P built and not |
| +// opened: the claim as the participant's ToProbing leaves it, P's snapshot, |
| +// anchor and install manifest in the versioned bucket, P's prepared files |
| +// manifest in the data bucket, and the wake inputs bound as RelocationSource |
| +// binds them. |
| +type roFixture struct { |
| + t *testing.T |
| + claims *release.DirStore |
| + files *release.DirStore |
| + bucket *leasetest.VersionedBucket |
| + releases *roReleases |
| + c8 *roFiles |
| + inputs *RelocationWakeInputs |
| + route registry.Route |
| + manifest moves.InstallManifest |
| + seq int |
| + stamp uint64 |
| +} |
| + |
| +type roOptions struct { |
| + empty bool // an evidenced empty pin: no snapshot, no install manifest |
| + failover bool // no journaled release; the source lost and fenced |
| +} |
| + |
| +func newROFixture(t *testing.T, o roOptions) *roFixture { |
| + t.Helper() |
| + ctx := t.Context() |
| + f := &roFixture{t: t, claims: release.NewDirStore(t.TempDir()), files: release.NewDirStore(t.TempDir()), bucket: leasetest.NewVersionedBucket(), releases: &roReleases{}, c8: &roFiles{}} |
| + f.bucket.AddBucket("data") |
| + created := time.Date(2026, 10, 7, 1, 0, 0, 0, time.UTC) |
| + enrollment := strings.Repeat("a", 32) |
| + nh := hostclaim.NewHolder{Family: "instance", Key: "apps/acct/app/instances/" + roHost + ".json", Account: "acct", App: "app", Host: roHost, Region: "ie", RecordRegion: "ie", CreatedAt: created} |
| + body := []byte(`{"host":"` + roHost + `","account":"acct","app":"app","region":"ie","created_at":"2026-10-07T01:00:00Z"}`) |
| + obs, err := hostclaim.Read(ctx, f.claims, roHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, tr := range []func(*hostclaim.Claim) error{ |
| + hostclaim.BeginCreateOn(nh, body, "c1", "console", created, hostclaim.ExecutorSelection{Box: "src", Enrollment: enrollment}), |
| + hostclaim.CompleteCreate("c1", "src"), |
| + hostclaim.AdmitWriter(hostclaim.Admission{Box: "src", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true}), |
| + } { |
| + if obs, err = hostclaim.Update(ctx, f.claims, obs, tr); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + h := obs.Claim.Holder |
| + prefix := moves.GenerationPrefix(h.Location, roP) |
| + auth := hostclaim.PreparedAuthority{Generation: roP, SourceFilesSeq: 5} |
| + ref := hostclaim.RecoveryPin{Key: "control/relocations/" + roHost + "/" + roOp + "/pin-1.json", SHA256: strings.Repeat("e", 64), Generation: "g1", FilesSeq: 5, Release: "v1"} |
| + if o.empty { |
| + ref.Empty = true |
| + } else { |
| + ref.MaxTXID = 42 |
| + snap, anchor := moves.SnapshotKey(prefix, 42), moves.AnchorKey(prefix, 42) |
| + f.manifest = moves.InstallManifest{V: 1, Op: roOp, Host: roHost, N: 42, SnapshotKey: snap, SnapshotVersion: f.bucket.Put("data", snap, ltxHeader(t, 1, 42)), SnapshotCRC64: 7, |
| + AnchorKey: anchor, AnchorVersion: f.bucket.Put("data", anchor, ltxHeader(t, 42, 42)), IntegrityCheck: "ok", DBSHA256: strings.Repeat("d", 64)} |
| + raw, _ := json.Marshal(f.manifest) |
| + f.bucket.Put("data", moves.InstallManifestKey(prefix), raw) |
| + sum := sha256.Sum256(raw) |
| + auth.InstallManifest, auth.SHA256, auth.SnapshotTXID = moves.InstallManifestKey(prefix), hex.EncodeToString(sum[:]), 42 |
| + } |
| + pm, _ := json.Marshal(hostfiles.Manifest{V: hostfiles.ManifestVersion, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}}) |
| + if _, err := f.files.Put(ctx, hostfiles.ManifestKey(h.Location+"/files/"+roP+"/"), pm, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + pmSum := sha256.Sum256(pm) |
| + auth.FilesManifestSHA256 = hex.EncodeToString(pmSum[:]) |
| + if obs, err = hostclaim.Update(ctx, f.claims, obs, func(c *hostclaim.Claim) error { |
| + f.stamp = c.Gen + 1 |
| + c.Holder.Stamp = f.stamp |
| + auth.InstallStamp = f.stamp |
| + stamp := strconv.FormatUint(f.stamp, 10) |
| + journal := map[string]string{"pin": string(mustJSON(t, ref)), "prepared": roP, "install_stamp": stamp, "install_release": "v1", |
| + "prepared_authority": string(mustJSON(t, auth)), "probing_since": created.Add(time.Hour).Format(time.RFC3339Nano), |
| + "exec_ack.src": `{"op":"rel-1"}`, "exec_ack.dst": `{"op":"rel-1"}`, |
| + "local_install": string(mustJSON(t, hostclaim.LocalInstall{Box: "dst", Enrollment: enrollment, InstallStamp: f.stamp, JournalSHA256: strings.Repeat("c", 64)}))} |
| + if !o.failover { |
| + journal["release"] = string(mustJSON(t, hostclaim.Release{ReleasedBy: "src", Enrollment: enrollment, Generation: c.Holder.Generation, Session: c.Holder.Session, FinalTXID: ref.MaxTXID, ManifestSeq: 5, Receipt: "r", ReceiptSHA256: strings.Repeat("f", 64)})) |
| + } else { |
| + c.Lost = []string{"src"} |
| + journal["fence.src"] = "control/hosts/" + roHost + "/fences/src.json" |
| + } |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: roOp, Phase: hostclaim.PhaseRelocatingProbing, Exec: "dst", Participants: []string{"src", "dst"}, |
| + Intent: map[string]string{"region": "ie", "source_box": "src", "target_box": "dst", "target_enrollment": enrollment}, Journal: journal} |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.route = registry.Route{Host: roHost, Kind: "instance", App: "app", Account: "acct", Status: "relocating", HoldOp: roOp, DBPath: provision.NestedDBPath("/data", roHost), Socket: provision.NestedSocket(roHost)} |
| + f.inputs = &RelocationWakeInputs{ |
| + Launch: func(context.Context, registry.Route) (ClaimServingLaunch, error) { |
| + return ClaimServingLaunch{Identity: hostwrite.Identity{Config: "sha256:config", Release: "sha256:release"}, Executable: "/opt/carlos/live/" + roHost, ExecutableSHA256: strings.Repeat("b", 64), Version: "v1"}, nil |
| + }, |
| + FilesManifest: f.c8, Markers: plan9D5Markers{f.bucket}, Claims: f.claims, FilesStore: f.files, Data: f.bucket, Bucket: "data", Region: "ie", Box: "dst", |
| + NewID: func() string { f.seq++; return fmt.Sprintf("fresh%d", f.seq) }, Now: func() time.Time { return created.Add(2 * time.Hour) }, Releases: f.releases} |
| + return f |
| +} |
| + |
| +func ltxHeader(t *testing.T, min, max uint64) []byte { |
| + t.Helper() |
| + body, err := (<x.Header{Version: ltx.Version, Flags: ltx.HeaderFlagNoChecksum, PageSize: 4096, MinTXID: ltx.TXID(min), MaxTXID: ltx.TXID(max)}).MarshalBinary() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return body |
| +} |
| + |
| +func (f *roFixture) read() hostclaim.Observed { |
| + f.t.Helper() |
| + o, err := hostclaim.Read(f.t.Context(), f.claims, roHost) |
| + if err != nil { |
| + f.t.Fatal(err) |
| + } |
| + return o |
| +} |
| + |
| +func (f *roFixture) update(tr func(*hostclaim.Claim) error) hostclaim.Observed { |
| + f.t.Helper() |
| + o, err := hostclaim.Update(f.t.Context(), f.claims, f.read(), tr) |
| + if err != nil { |
| + f.t.Fatal(err) |
| + } |
| + return o |
| +} |
| + |
| +// admit counts the attempt and applies the selected admission with the |
| +// claim's own CAS, as the participant and the wake do. |
| +func (f *roFixture) admit(o hostclaim.Observed, in ClaimLaunchInputs) hostclaim.Observed { |
| + f.t.Helper() |
| + counted, err := hostclaim.Update(f.t.Context(), f.claims, o, hostclaim.BeginProbeAttempt(roOp, "dst")) |
| + if err != nil { |
| + f.t.Fatal(err) |
| + } |
| + in.Admission.ObservedSession, in.Admission.ObservedGeneration = counted.Claim.Holder.Session, counted.Claim.Holder.Generation |
| + admitted, err := hostclaim.Update(f.t.Context(), f.claims, counted, hostclaim.AdmitWriter(in.Admission)) |
| + if err != nil { |
| + f.t.Fatal(err) |
| + } |
| + return admitted |
| +} |
| + |
| +// C4, ruling 47: P's first opening is cold into P, keeping the install |
| +// stamp, prepared from P's snapshot, and certified only by this |
| +// operation's verified release; a failover's opening is not certified; an |
| +// empty pin's opening is empty, with no baseline. |
| +func TestRelocationSelectCertifiesOnlyPsOpeningAfterItsRelease(t *testing.T) { |
| + ctx := t.Context() |
| + t.Run("a drain's release", func(t *testing.T) { |
| + f := newROFixture(t, roOptions{}) |
| + o := f.read() |
| + in, err := f.inputs.Select(ctx, f.route, o, roProbe) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + h := o.Claim.Holder |
| + want := hostclaim.Admission{ObservedOpID: roOp, ObservedPhase: hostclaim.PhaseRelocatingProbing, ObservedSession: h.Session, ObservedGeneration: h.Generation, |
| + Box: "dst", Mode: "cold", NewGeneration: roP, KeepStamp: true, PredecessorCertified: true, Region: "ie", PredecessorRegion: "ie", RemoteMaxTXID: 42} |
| + if !reflect.DeepEqual(in.Admission, want) { |
| + t.Fatalf("admission:\n got %+v\nwant %+v", in.Admission, want) |
| + } |
| + if !in.Prepared || in.Empty || in.Baseline == nil || in.Baseline.Generation != roP || in.Baseline.MaxTXID != 42 || f.releases.calls != 1 { |
| + t.Fatalf("prepared=%v empty=%v baseline=%+v release checks=%d", in.Prepared, in.Empty, in.Baseline, f.releases.calls) |
| + } |
| + if in.DBPath != f.route.DBPath || in.Version != "v1" || len(in.Roles) != 1 || in.Roles[0] != "main" || in.ReadyPath != "/" { |
| + t.Fatalf("launch inputs %+v", in) |
| + } |
| + f.releases.err = errors.New("the receipt does not verify") |
| + if _, err := f.inputs.Select(ctx, f.route, o, roProbe); !errors.Is(err, f.releases.err) { |
| + t.Fatalf("an unverified release certified: %v", err) |
| + } |
| + }) |
| + t.Run("a failover", func(t *testing.T) { |
| + f := newROFixture(t, roOptions{failover: true}) |
| + in, err := f.inputs.Select(ctx, f.route, f.read(), roProbe) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if in.Admission.PredecessorCertified || !in.Admission.KeepStamp || in.Admission.NewGeneration != roP || f.releases.calls != 0 { |
| + t.Fatalf("failover admission %+v (release checks %d)", in.Admission, f.releases.calls) |
| + } |
| + }) |
| + t.Run("an empty pin", func(t *testing.T) { |
| + f := newROFixture(t, roOptions{empty: true}) |
| + in, err := f.inputs.Select(ctx, f.route, f.read(), roProbe) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !in.Empty || in.Prepared || in.Baseline != nil || in.Admission.NewGeneration != roP || !in.Admission.KeepStamp || in.Admission.RemoteMaxTXID != 0 { |
| + t.Fatalf("empty opening %+v", in) |
| + } |
| + if err := f.inputs.EmptyAuthority(ctx, f.read(), in); err != nil { |
| + t.Fatalf("the evidenced empty pin: %v", err) |
| + } |
| + }) |
| +} |
| + |
| +// P is opened once. After its opening every attempt is a fresh generation |
| +// from the predecessor, keeping no stamp, never certified, and evidence for |
| +// P's opening is refused on the opened claim. |
| +func TestRelocationSelectNeverReopensP(t *testing.T) { |
| + f := newROFixture(t, roOptions{}) |
| + ctx := t.Context() |
| + first, err := f.inputs.Select(ctx, f.route, f.read(), roProbe) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + opened := f.admit(f.read(), first) |
| + if !hostclaim.PreparedOpened(opened.Claim) || opened.Claim.Holder.Stamp != f.stamp { |
| + t.Fatalf("P's opening: %+v", opened.Claim.Holder) |
| + } |
| + again, err := f.inputs.Select(ctx, f.route, opened, roProbe) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + a := again.Admission |
| + if a.NewGeneration != "fresh1" || a.KeepStamp || a.PredecessorCertified || again.Prepared || a.ObservedGeneration != roP || a.ObservedSession != opened.Claim.Holder.Session { |
| + t.Fatalf("the later attempt %+v prepared=%v", a, again.Prepared) |
| + } |
| + if err := f.inputs.AdmissionEvidence(ctx, opened, again.Admission, again.Baseline); err != nil { |
| + t.Fatalf("the later attempt's evidence: %v", err) |
| + } |
| + reopen := again.Admission |
| + reopen.NewGeneration, reopen.KeepStamp = roP, true |
| + if err := f.inputs.AdmissionEvidence(ctx, opened, reopen, first.Baseline); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("P reopened: %v", err) |
| + } |
| +} |
| + |
| +// The inputs serve only this box's relocation probe, of the pinned |
| +// release, at a nested route. |
| +func TestRelocationSelectRefusesEverythingButThisBoxsProbe(t *testing.T) { |
| + ctx := t.Context() |
| + for name, tc := range map[string]struct { |
| + claim func(*hostclaim.Claim) error |
| + policy activator.StartPolicy |
| + route func(*registry.Route) |
| + launch string |
| + want error |
| + }{ |
| + "installed": {claim: func(c *hostclaim.Claim) error { c.Op.Phase = hostclaim.PhaseInstalled; return nil }, policy: roProbe, want: hostrelocate.ErrRefused}, |
| + "another executor": {claim: func(c *hostclaim.Claim) error { c.Op.Exec, c.Op.Intent["target_box"] = "src", "src"; return nil }, policy: roProbe, want: hostrelocate.ErrRefused}, |
| + "stuck": {claim: func(c *hostclaim.Claim) error { c.Op.Journal["stuck"] = "an operator"; return nil }, policy: roProbe, want: hostrelocate.ErrRefused}, |
| + "not a probe": {policy: activator.StartPolicy{MaxStarts: 5, Window: time.Minute}, want: hostrelocate.ErrRefused}, |
| + "another release": {policy: roProbe, launch: "v2", want: hostrelocate.ErrRefused}, |
| + "a relative database": {policy: roProbe, route: func(rt *registry.Route) { rt.DBPath = "shop.db" }, want: hostbox.ErrUnsupported}, |
| + "another host": {policy: roProbe, route: func(rt *registry.Route) { rt.Host = "other.example" }, want: hostbox.ErrUnsupported}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + f := newROFixture(t, roOptions{}) |
| + if tc.claim != nil { |
| + f.update(tc.claim) |
| + } |
| + rt := f.route |
| + if tc.route != nil { |
| + tc.route(&rt) |
| + } |
| + if tc.launch != "" { |
| + launch := f.inputs.Launch |
| + f.inputs.Launch = func(ctx context.Context, rt registry.Route) (ClaimServingLaunch, error) { |
| + l, err := launch(ctx, rt) |
| + l.Version = tc.launch |
| + return l, err |
| + } |
| + } |
| + if _, err := f.inputs.Select(ctx, rt, f.read(), tc.policy); !errors.Is(err, tc.want) { |
| + t.Fatalf("got %v, want %v", err, tc.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Ruling 47: P's opening is bound to its observation, its certified |
| +// baseline (P's snapshot at the pin's N) and P's prepared files manifest by |
| +// the digest prepared_authority journals, read again. |
| +func TestRelocationAdmissionEvidenceBindsTheBaselineAndThePreparedManifest(t *testing.T) { |
| + ctx := t.Context() |
| + f := newROFixture(t, roOptions{}) |
| + o := f.read() |
| + in, err := f.inputs.Select(ctx, f.route, o, roProbe) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.inputs.AdmissionEvidence(ctx, o, in.Admission, in.Baseline); err != nil { |
| + t.Fatal(err) |
| + } |
| + for name, mutate := range map[string]func(*hostclaim.Admission, *genselect.Plan){ |
| + "a stale baseline": func(a *hostclaim.Admission, p *genselect.Plan) { p.MaxTXID, a.RemoteMaxTXID = 41, 41 }, |
| + "another generation": func(_ *hostclaim.Admission, p *genselect.Plan) { p.Generation = "g1" }, |
| + "stamp not kept": func(a *hostclaim.Admission, _ *genselect.Plan) { a.KeepStamp = false }, |
| + "not certified": func(a *hostclaim.Admission, _ *genselect.Plan) { a.PredecessorCertified = false }, |
| + "another box": func(a *hostclaim.Admission, _ *genselect.Plan) { a.Box = "src" }, |
| + "sealed coverage": func(a *hostclaim.Admission, _ *genselect.Plan) { a.Coverage = "sealed" }, |
| + "another region": func(a *hostclaim.Admission, _ *genselect.Plan) { a.Region = "uk" }, |
| + "another session": func(a *hostclaim.Admission, _ *genselect.Plan) { a.ObservedSession++ }, |
| + "warm": func(a *hostclaim.Admission, _ *genselect.Plan) { a.Mode = "warm" }, |
| + "lost writes claimed": func(a *hostclaim.Admission, _ *genselect.Plan) { a.AdditionalWritesLost = []hostclaim.WritesLost{{}} }, |
| + } { |
| + a, p := in.Admission, *in.Baseline |
| + mutate(&a, &p) |
| + if err := f.inputs.AdmissionEvidence(ctx, o, a, &p); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + } |
| + // P's files manifest replaced after the build: the digest no longer |
| + // matches the authority. |
| + key := hostfiles.ManifestKey(o.Claim.Holder.Location + "/files/" + roP + "/") |
| + _, etag, err := f.files.Get(ctx, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + other, _ := json.Marshal(hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 0, Entries: map[string]hostfiles.Entry{"x": {Seq: 1, Deleted: true}}, Platform: map[string]hostfiles.Entry{}}) |
| + if _, err := f.files.Put(ctx, key, other, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.inputs.AdmissionEvidence(ctx, o, in.Admission, in.Baseline); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("another files manifest: %v", err) |
| + } |
| + // An empty pin's opening has no baseline; a non-empty one must. |
| + e := newROFixture(t, roOptions{empty: true}) |
| + eo := e.read() |
| + ein, err := e.inputs.Select(ctx, e.route, eo, roProbe) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := e.inputs.AdmissionEvidence(ctx, eo, ein.Admission, nil); err != nil { |
| + t.Fatalf("the empty opening: %v", err) |
| + } |
| + if err := f.inputs.AdmissionEvidence(ctx, o, in.Admission, nil); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("a non-empty opening without its baseline: %v", err) |
| + } |
| +} |
| + |
| +// Ruling 41: one admitted session is bound to one record attempt k (the |
| +// claim's probe_attempts.<box>); the attempt's first app start is kept |
| +// across restarts; a start is reserved, settled and re-reserved under the |
| +// same session; a session bound to k is refused once the record moves on. |
| +// The journal is keyed by op (ruling 44). |
| +func TestRelocationProbeAttemptsBindOneSessionPerRecordAttempt(t *testing.T) { |
| + ctx := t.Context() |
| + f := newROFixture(t, roOptions{}) |
| + in, err := f.inputs.Select(ctx, f.route, f.read(), roProbe) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + admitted := f.admit(f.read(), in) |
| + h := admitted.Claim.Holder |
| + dataDir := t.TempDir() |
| + now := time.Date(2026, 10, 7, 4, 0, 0, 0, time.UTC) |
| + p := &relocationProbeAttempts{Claims: f.claims, DataDir: dataDir, Box: "dst", NewID: f.inputs.NewID, Now: func() time.Time { return now }} |
| + owner := hostguard.FenceTarget{Region: "ie", Host: roHost, Incarnation: h.Incarnation, Stamp: h.Stamp, Session: h.Session, Box: "dst", Enrollment: strings.Repeat("a", 32), BootID: "boot", ControllerID: "ctl"} |
| + s := OperationStart{Admitted: admitted, Policy: roProbe, Inputs: in, Owner: owner} |
| + opened, err := p.OpenProbe(ctx, s, now) |
| + if err != nil || !opened.Equal(now) { |
| + t.Fatalf("open %v %v", opened, err) |
| + } |
| + if again, err := p.OpenProbe(ctx, s, now.Add(time.Minute)); err != nil || !again.Equal(now) { |
| + t.Fatalf("a restart moved the attempt's first start: %v %v", again, err) |
| + } |
| + if err := p.Reserve(ctx, s, 0); err != nil { |
| + t.Fatal(err) |
| + } |
| + a := p.CurrentAttempt(s) |
| + if a.ID == "" || a.Session != h.Session || a.Box != "dst" || a.ControllerID != "ctl" { |
| + t.Fatalf("the reservation %+v", a) |
| + } |
| + if got, err := (relocationAttemptReader{DataDir: dataDir}).ReadAttempt(ctx, admitted); err != nil || got != a { |
| + t.Fatalf("root reads %+v %v", got, err) |
| + } |
| + if err := p.Reserve(ctx, s, 0); err != nil || p.CurrentAttempt(s) != a { |
| + t.Fatalf("a repeated reservation: %v", err) |
| + } |
| + if err := p.Reserve(ctx, s, 1); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("a restart before the first start settled: %v", err) |
| + } |
| + if err := p.Failed(ctx, s, 0); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.Reserve(ctx, s, 1); err != nil { |
| + t.Fatalf("the restart after a failed start: %v", err) |
| + } |
| + if b := p.CurrentAttempt(s); b.Session != h.Session || b.LaunchInputs != a.LaunchInputs { |
| + t.Fatalf("the restart %+v", b) |
| + } |
| + path, _ := relocationAttemptsPath(dataDir, roOp) |
| + if strings.Contains(path, roHost) || filepath.Base(filepath.Dir(path)) != roOp { |
| + t.Fatalf("the journal path %s", path) |
| + } |
| + // The record attempt moves on with the same session admitted: the |
| + // session is bound to attempt 1 and refused for attempt 2. |
| + moved := f.update(func(c *hostclaim.Claim) error { c.Op.Journal["probe_attempts.dst"] = "2"; return nil }) |
| + s.Admitted = moved |
| + if err := p.Reserve(ctx, s, 0); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("one session, two record attempts: %v", err) |
| + } |
| + // Another session than the admitted writer is refused before any journal. |
| + other := OperationStart{Admitted: admitted, Policy: roProbe, Inputs: in, Owner: owner} |
| + other.Owner.Session++ |
| + if _, err := p.OpenProbe(ctx, other, now); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("another session: %v", err) |
| + } |
| +} |
| + |
| +// The admission hooks: before P's first admission the source's release is |
| +// verified at its receipt (a drain) or its fence is journaled with the |
| +// source lost (a failover); after it, this box's install journal is the |
| +// receipt's and P keeps the install stamp. |
| +func TestRelocationAdmissionHooksVerifyReleaseAndInstall(t *testing.T) { |
| + ctx := t.Context() |
| + releases := &roReleases{} |
| + before := relocationBeforeAdmission("dst", releases) |
| + f := newROFixture(t, roOptions{}) |
| + if err := before(ctx, f.read()); err != nil || releases.calls != 1 { |
| + t.Fatalf("a drain's release: %v (%d checks)", err, releases.calls) |
| + } |
| + releases.err = errors.New("no receipt") |
| + if err := before(ctx, f.read()); !errors.Is(err, releases.err) { |
| + t.Fatalf("an unverified release: %v", err) |
| + } |
| + g := newROFixture(t, roOptions{failover: true}) |
| + if err := before(ctx, g.read()); err != nil { |
| + t.Fatalf("a fenced failover: %v", err) |
| + } |
| + unfenced := g.update(func(c *hostclaim.Claim) error { delete(c.Op.Journal, "fence.src"); return nil }) |
| + if err := before(ctx, unfenced); !errors.Is(err, hostrelocate.ErrRefused) { |
| + t.Fatalf("neither release nor fence: %v", err) |
| + } |
| + if err := relocationBeforeAdmission("other", releases)(ctx, f.read()); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("another box: %v", err) |
| + } |
| + |
| + // After: the install journal at the receipt's digest. |
| + dataDir := t.TempDir() |
| + after := relocationAfterAdmission(dataDir, "dst") |
| + in, err := f.inputs.Select(ctx, f.route, f.read(), roProbe) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + prior := f.read() |
| + path, _ := relocationInstallPath(dataDir, roOp) |
| + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + journal := []byte(`{"v":1,"host":"shop.example","op":"rel-1"}`) |
| + if err := os.WriteFile(path, journal, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.Sum256(journal) |
| + f.update(func(c *hostclaim.Claim) error { |
| + var l hostclaim.LocalInstall |
| + _ = json.Unmarshal([]byte(c.Op.Journal["local_install"]), &l) |
| + l.JournalSHA256 = hex.EncodeToString(sum[:]) |
| + c.Op.Journal["local_install"] = string(mustJSON(t, l)) |
| + return nil |
| + }) |
| + admitted := f.admit(f.read(), in) |
| + if err := after(ctx, prior, admitted); err != nil { |
| + t.Fatalf("after P's opening: %v", err) |
| + } |
| + if err := os.WriteFile(path, []byte(`{"v":1}`), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := after(ctx, prior, admitted); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("another install journal: %v", err) |
| + } |
| + if err := relocationAfterAdmission(dataDir, "src")(ctx, prior, admitted); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("another box's admission: %v", err) |
| + } |
| +} |
| + |
| +// relocationProbeActivator is the activator's probe and fence, recorded. |
| +type relocationProbeActivator struct { |
| + probes []activator.ProbeConfig |
| + result activator.ProbeResult |
| + err error |
| +} |
| + |
| +func (a *relocationProbeActivator) ProbeWake(_ context.Context, pc activator.ProbeConfig) (activator.ProbeResult, error) { |
| + a.probes = append(a.probes, pc) |
| + return a.result, a.err |
| +} |
| + |
| +// The probe attempt never fences: a fence asked of this double fails the |
| +// test's call with an error naming it, and lifts nothing. |
| +func (a *relocationProbeActivator) FenceAndStopAs(context.Context, string, uint64, string) (*activator.StopEvidence, error) { |
| + return nil, errors.New("the probe attempt asked for a fence") |
| +} |
| + |
| +func (a *relocationProbeActivator) LiftFenceAs(string, uint64, string) bool { return false } |
| + |
| +// One probe attempt is one ProbeWake, bounded by the probe deadline, |
| +// carrying P's install manifest on P's opening; a failed or overdue |
| +// attempt is not passed, and any other error is returned. |
| +func TestRelocationAdmitRunsOneProbeAttempt(t *testing.T) { |
| + ctx := t.Context() |
| + f := newROFixture(t, roOptions{}) |
| + act := &relocationProbeActivator{result: activator.ProbeResult{Session: 99}} |
| + admit := ClaimAssembly{}.relocationAdmit(RelocationAssemblyConfig{Activator: act, Versions: f.bucket, Bucket: "data"}) |
| + o := f.read() |
| + session, passed, err := admit(ctx, o, true) |
| + if err != nil || !passed || session != 99 || len(act.probes) != 1 { |
| + t.Fatalf("passed: %d %v %v", session, passed, err) |
| + } |
| + pc := act.probes[0] |
| + since, _ := time.Parse(time.RFC3339Nano, o.Claim.Op.Journal["probing_since"]) |
| + if pc.Host != roHost || pc.Op != roOp || pc.HoldOp != roOp || !pc.Deadline.Equal(since.Add(hostrelocate.ProbeDeadline)) || pc.Prepared == nil || *pc.Prepared != f.manifest { |
| + t.Fatalf("probe config %+v", pc) |
| + } |
| + for _, failed := range []error{activator.ErrProbeFailed, activator.ErrAttemptOver} { |
| + act.err = failed |
| + if _, passed, err := admit(ctx, o, true); passed || err != nil { |
| + t.Fatalf("%v: passed=%v %v", failed, passed, err) |
| + } |
| + } |
| + act.err = errors.New("the activator is closing") |
| + if _, _, err := admit(ctx, o, true); !errors.Is(err, act.err) { |
| + t.Fatalf("another error: %v", err) |
| + } |
| + if _, _, err := admit(ctx, o, false); !errors.Is(err, hostrelocate.ErrRefused) { |
| + t.Fatalf("certification disagreed: %v", err) |
| + } |
| + if n := len(act.probes); n != 4 { |
| + t.Fatalf("%d probes, want 4", n) |
| + } |
| +} |
| + |
| +// Rulings 37 and 39: with moves' C8 provider bound, Open returns the |
| +// complete owner set and the two refusals; the relocating dispatch binds. |
| +// Without the region's bucket or a store constructor it is unavailable, |
| +// and a nil source is refused before it is locked. |
| +func TestRelocationSourceOpensACompleteOwnerSet(t *testing.T) { |
| + ctx := t.Context() |
| + var opened []string |
| + src := &RelocationSource{DataDir: t.TempDir(), FilesStore: func(_ context.Context, bucket string) (release.Store, error) { |
| + opened = append(opened, bucket) |
| + return release.NewDirStore(t.TempDir()), nil |
| + }} |
| + src.SetServingSources(ClaimServingSources{ |
| + Launch: func(context.Context, registry.Route) (ClaimServingLaunch, error) { return ClaimServingLaunch{}, nil }, |
| + Markers: plan9D5Markers{leasetest.NewVersionedBucket()}, |
| + Files: &roFiles{}, |
| + }) |
| + actor := hostbox.Actor{Box: "dst", Bucket: "data-ie", Region: "ie"} |
| + owners, refusals, err := src.Open(ctx, release.NewDirStore(t.TempDir()), &fakeVersioned{}, actor) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if owners.zero() || !owners.complete() || owners.Operations == nil || owners.Policy == nil || owners.BeforeAdmission == nil || owners.AfterAdmission == nil || len(refusals) != 2 { |
| + t.Fatalf("owners %+v refusals %d", owners, len(refusals)) |
| + } |
| + if len(opened) != 1 || opened[0] != "data-ie" { |
| + t.Fatalf("the files store was opened for %v", opened) |
| + } |
| + if bound, err := claimRelocationC1(plan11Env("1", "1"), true, owners); !bound || err != nil { |
| + t.Fatalf("the relocating dispatch did not bind: %v %v", bound, err) |
| + } |
| + if _, _, err := src.Open(ctx, release.NewDirStore(t.TempDir()), &fakeVersioned{}, hostbox.Actor{Box: "dst", Region: "ie"}); !errors.Is(err, hostwrite.ErrUnavailable) { |
| + t.Fatalf("no bucket: %v", err) |
| + } |
| + var none *RelocationSource |
| + if _, _, err := none.Open(ctx, release.NewDirStore(t.TempDir()), &fakeVersioned{}, actor); !errors.Is(err, hostwrite.ErrUnavailable) { |
| + t.Fatalf("a nil source: %v", err) |
| + } |
| +} |
| + |
| +// Finding 11 (C8): without moves' files-manifest provider the source opens |
| +// no owners, and the dispatcher keeps relocating refused. |
| +func TestRelocationStaysUnboundWithoutTheC8Provider(t *testing.T) { |
| + src := &RelocationSource{DataDir: t.TempDir(), FilesStore: func(context.Context, string) (release.Store, error) { return release.NewDirStore(t.TempDir()), nil }} |
| + src.SetServingSources(ClaimServingSources{Launch: func(context.Context, registry.Route) (ClaimServingLaunch, error) { return ClaimServingLaunch{}, nil }, Markers: nil}) |
| + owners, refusals, err := src.Open(context.Background(), release.NewDirStore(t.TempDir()), &fakeVersioned{}, hostbox.Actor{Box: "dst", Bucket: "data-ie", Region: "ie"}) |
| + if err != nil || !owners.zero() || refusals != nil { |
| + t.Fatalf("opened without C8: %+v %v %v", owners, refusals, err) |
| + } |
| + bound, err := claimRelocationC1(plan11Env("1", "1"), true, owners) |
| + if bound || err != nil { |
| + t.Fatalf("relocating bound without C8: %v %v", bound, err) |
| + } |
| +} |
diff --git a/internal/host/relocationwake.go b/internal/host/relocationwake.go| new file mode 100644 |
| index 000000000..d13df9d55 |
| --- /dev/null |
| +++ b/internal/host/relocationwake.go |
| @@ -0,0 +1,432 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "reflect" |
| + "strings" |
| + "sync" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/genselect" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostfiles" |
| + "github.com/carlosframework/platform/internal/hostrelocate" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// RelocationWakeInputs is the relocating kind's ClaimWakeInputs (claim |
| +// Plan 11 R1). Field names are distinct (finding 14): FilesManifest is |
| +// moves' C8 provider, FilesStore the data bucket as a release.Store. |
| +type RelocationWakeInputs struct { |
| + Launch func(context.Context, registry.Route) (ClaimServingLaunch, error) |
| + FilesManifest ClaimFilesManifest |
| + Markers ClaimMarkerStore |
| + Claims release.Store |
| + FilesStore release.Store |
| + Data genselect.Reader |
| + Bucket string |
| + Region string |
| + Box string |
| + NewID func() string |
| + Now func() time.Time |
| + // Releases verifies a journaled release at its receipt (stage A's |
| + // StopReceiptReleases over main's Complete). |
| + Releases hostclaim.ReleaseAuthority |
| + |
| + mu sync.Mutex |
| + fences map[string]hostwrite.Work // host\x00session: the admitted spawn selection Materialize ran under |
| + published map[string]ClaimPublicationReceipt // host\x00session: P's prepared marker |
| + fresh *claimServingOwners // a fresh generation's marker: main's writer (ruling 47) |
| +} |
| + |
| +var ( |
| + _ ClaimWakeInputs = (*RelocationWakeInputs)(nil) |
| + _ ClaimFencedPublication = (*RelocationWakeInputs)(nil) |
| +) |
| + |
| +func (r *RelocationWakeInputs) valid() bool { |
| + return r != nil && r.Launch != nil && r.FilesManifest != nil && r.Markers != nil && r.Claims != nil && r.FilesStore != nil && r.Data != nil && |
| + r.Bucket != "" && r.Region != "" && r.Box != "" && r.NewID != nil && r.Now != nil && r.Releases != nil |
| +} |
| + |
| +func (r *RelocationWakeInputs) probe(o hostclaim.Observed, p activator.StartPolicy) error { |
| + if !r.valid() { |
| + return hostwrite.ErrUnavailable |
| + } |
| + c := o.Claim |
| + if o.Absent || c == nil || c.Holder == nil || c.Op.Kind != hostclaim.KindRelocating || c.Op.Phase != hostclaim.PhaseRelocatingProbing || c.Op.Exec != r.Box || !p.Probe || c.Op.Journal["stuck"] != "" { |
| + return fmt.Errorf("%w: not this box's relocation probe", hostrelocate.ErrRefused) |
| + } |
| + return nil |
| +} |
| + |
| +// freshOwners is main's serving publisher bound to this box and region, |
| +// used only through its kind-free halves (publishFresh, freshEvidence). |
| +func (r *RelocationWakeInputs) freshOwners() *claimServingOwners { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + if r.fresh == nil { |
| + r.fresh = &claimServingOwners{Data: r.Data, Bucket: r.Bucket, Region: r.Region, Box: r.Box, NewID: r.NewID, Now: r.Now, Sources: ClaimServingSources{Markers: r.Markers}} |
| + } |
| + return r.fresh |
| +} |
| + |
| +func (r *RelocationWakeInputs) request(o hostclaim.Observed) genselect.Request { |
| + return genselect.Request{Bucket: r.Bucket, Region: r.Region, Host: o.Host, Holder: *o.Claim.Holder} |
| +} |
| + |
| +func preparedAuthority(c *hostclaim.Claim) (hostclaim.PreparedAuthority, error) { |
| + var auth hostclaim.PreparedAuthority |
| + if json.Unmarshal([]byte(c.Op.Journal["prepared_authority"]), &auth) != nil || auth.Generation == "" || auth.Generation != c.Op.Journal["prepared"] { |
| + return hostclaim.PreparedAuthority{}, hostbox.ErrChanged |
| + } |
| + return auth, nil |
| +} |
| + |
| +// Select (§5.8 "The probe's admission"): the first attempt opens P with |
| +// KeepStamp, certified only by this operation's verified release (C4); |
| +// it is a prepared admission (Prepared, main's pre-replication marker) |
| +// when P has a snapshot, and an empty one otherwise (ruling 47). Every |
| +// later attempt is cold into a fresh generation from the predecessor. |
| +func (r *RelocationWakeInputs) Select(ctx context.Context, rt registry.Route, o hostclaim.Observed, p activator.StartPolicy) (ClaimLaunchInputs, error) { |
| + if err := r.probe(o, p); err != nil { |
| + return ClaimLaunchInputs{}, err |
| + } |
| + c, h := o.Claim, o.Claim.Holder |
| + if rt.Host != o.Host || !strings.HasPrefix(rt.DBPath, "/") { |
| + return ClaimLaunchInputs{}, hostbox.ErrUnsupported |
| + } |
| + l, err := r.Launch(ctx, rt) |
| + if err != nil { |
| + return ClaimLaunchInputs{}, err |
| + } |
| + if l.Identity.Config == "" || l.Identity.Release == "" || l.Executable == "" || len(l.ExecutableSHA256) != 64 || l.Version == "" { |
| + return ClaimLaunchInputs{}, hostwrite.ErrUnavailable |
| + } |
| + if l.Version != c.Op.Journal["install_release"] { |
| + return ClaimLaunchInputs{}, fmt.Errorf("%w: the staged release %s is not the pinned %s", hostrelocate.ErrRefused, l.Version, c.Op.Journal["install_release"]) |
| + } |
| + prepared := c.Op.Journal["prepared"] |
| + in := ClaimLaunchInputs{Roles: []string{"main"}, Identity: l.Identity, Executable: l.Executable, ExecutableSHA256: l.ExecutableSHA256, Args: append([]string(nil), l.Args...), Env: append([]string(nil), l.Env...), |
| + DBPath: rt.DBPath, Version: l.Version, Limit: l.Limit, ReadyPath: "/"} |
| + adm := hostclaim.Admission{ObservedOpID: c.Op.ID, ObservedPhase: hostclaim.PhaseRelocatingProbing, ObservedSession: h.Session, ObservedGeneration: h.Generation, |
| + Box: r.Box, Mode: "cold", Region: r.Region, PredecessorRegion: r.Region} |
| + if n := len(h.Admissions); n > 0 { |
| + adm.PredecessorRegion = h.Admissions[n-1].Region |
| + } |
| + if !hostclaim.PreparedOpened(c) { |
| + certified := hostrelocate.CertifiedFor(c) |
| + if certified { |
| + var rel hostclaim.Release |
| + if json.Unmarshal([]byte(c.Op.Journal["release"]), &rel) != nil { |
| + return ClaimLaunchInputs{}, hostbox.ErrChanged |
| + } |
| + if err := r.Releases.VerifyRelease(ctx, *c, rel); err != nil { |
| + return ClaimLaunchInputs{}, err |
| + } |
| + } |
| + auth, err := preparedAuthority(c) |
| + if err != nil { |
| + return ClaimLaunchInputs{}, err |
| + } |
| + adm.NewGeneration, adm.KeepStamp, adm.PredecessorCertified = prepared, true, certified |
| + if auth.InstallManifest == "" { |
| + // An evidenced empty pin opened as P: no snapshot, so no root |
| + // until the first transaction; main's fenced publisher waits |
| + // for it (ErrClaimRootPending). |
| + in.Empty = true |
| + in.Admission = adm |
| + return in, nil |
| + } |
| + im, _, err := readInstallManifest(ctx, r.Data, r.Bucket, moves.GenerationPrefix(h.Location, prepared)) |
| + if err != nil { |
| + return ClaimLaunchInputs{}, err |
| + } |
| + plan, err := genselect.SelectPrepared(ctx, r.Data, r.request(o), genselect.PreparedPin{Generation: prepared, SnapshotKey: im.SnapshotKey, SnapshotVersion: im.SnapshotVersion, SnapshotTXID: im.N, SnapshotCRC64: im.SnapshotCRC64}) |
| + if err != nil { |
| + return ClaimLaunchInputs{}, err |
| + } |
| + in.Prepared = true |
| + adm.RemoteMaxTXID, in.Baseline = plan.MaxTXID, plan |
| + in.Admission = adm |
| + return in, nil |
| + } |
| + adm.NewGeneration = r.NewID() |
| + if !claimFreshGeneration(h, adm.NewGeneration) { |
| + return ClaimLaunchInputs{}, hostwrite.ErrUnavailable |
| + } |
| + plan, err := genselect.Select(ctx, r.Data, r.request(o)) |
| + switch { |
| + case errors.Is(err, genselect.ErrEmpty): |
| + in.Empty = true |
| + case err != nil: |
| + return ClaimLaunchInputs{}, err |
| + default: |
| + adm.RemoteMaxTXID, in.Baseline = plan.MaxTXID, plan |
| + } |
| + in.Admission = adm |
| + return in, nil |
| +} |
| + |
| +// Materialize, under the admitted spawn selection: the held identities |
| +// proved, C8 establishes the files manifest (Establish publishes |
| +// <host>.env's exact bytes before it returns, O1), the selection is |
| +// recorded as the session's fence, and on P's prepared opening P's marker |
| +// is published under that fence (main's prepared-publication contract, |
| +// ruling 47). The held render and release ran at the local install. |
| +func (r *RelocationWakeInputs) Materialize(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs, work hostwrite.Work) error { |
| + if err := r.probe(o, activator.StartPolicy{Probe: true}); err != nil { |
| + return err |
| + } |
| + if work == nil || in.Identity.Config == "" || in.Identity.Release == "" { |
| + return hostwrite.ErrUnavailable |
| + } |
| + fence := writerFence(work, in.Identity) |
| + if err := fence(ctx); err != nil { |
| + return err |
| + } |
| + if err := r.FilesManifest.Establish(ctx, o, in, work); err != nil { |
| + return err |
| + } |
| + h := o.Claim.Holder |
| + r.mu.Lock() |
| + if r.fences == nil { |
| + r.fences = map[string]hostwrite.Work{} |
| + } |
| + r.fences[claimPublishedKey(o.Host, h.Session)] = work |
| + r.mu.Unlock() |
| + if !in.Prepared { |
| + return nil |
| + } |
| + receipt, err := r.preparedMarker(ctx, o, fence) |
| + if err != nil { |
| + return err |
| + } |
| + r.record(o.Host, h.Session, receipt) |
| + return nil |
| +} |
| + |
| +func (r *RelocationWakeInputs) record(host string, session uint64, receipt ClaimPublicationReceipt) { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + if r.published == nil { |
| + r.published = map[string]ClaimPublicationReceipt{} |
| + } |
| + r.published[claimPublishedKey(host, session)] = receipt |
| +} |
| + |
| +// preparedMarker establishes P's verified marker under fence: install.json |
| +// is the journaled prepared authority's bytes, and the location's |
| +// generation.json names P with the build's pin (left as it is when it |
| +// already does) or supersedes the current marker by its exact ETag. |
| +func (r *RelocationWakeInputs) preparedMarker(ctx context.Context, o hostclaim.Observed, fence func(context.Context) error) (ClaimPublicationReceipt, error) { |
| + c, h := o.Claim, o.Claim.Holder |
| + auth, err := preparedAuthority(c) |
| + if err != nil { |
| + return ClaimPublicationReceipt{}, err |
| + } |
| + bounded, cancel := context.WithTimeout(ctx, hostbox.RequestLimit) |
| + defer cancel() |
| + im, raw, err := readInstallManifest(bounded, r.Data, r.Bucket, moves.GenerationPrefix(h.Location, auth.Generation)) |
| + if err != nil { |
| + return ClaimPublicationReceipt{}, err |
| + } |
| + sum := sha256.Sum256(raw) |
| + if hex.EncodeToString(sum[:]) != auth.SHA256 || im.Host != o.Host || im.N != auth.SnapshotTXID || h.Generation != auth.Generation { |
| + return ClaimPublicationReceipt{}, fmt.Errorf("%w: install.json is not the prepared authority's", hostbox.ErrChanged) |
| + } |
| + root := genselect.File{Key: im.SnapshotKey, VersionID: im.SnapshotVersion, Level: 9, MinTXID: 1, MaxTXID: im.N} |
| + current, err := (&movesProbeOwners{Data: r.Data, Bucket: r.Bucket}).currentMarker(bounded, h) // moves' reader, one implementation |
| + if err != nil { |
| + return ClaimPublicationReceipt{}, err |
| + } |
| + expected := "" |
| + if current != nil { |
| + got, err := r.Data.GetVersion(bounded, r.Bucket, markerKey(h), current.ID) |
| + if err != nil { |
| + return ClaimPublicationReceipt{}, err |
| + } |
| + var marker genselect.Marker |
| + if err := json.Unmarshal(got, &marker); err != nil { |
| + return ClaimPublicationReceipt{}, err |
| + } |
| + if marker.Op == auth.Generation { |
| + if marker.Incarnation != h.Incarnation || marker.Host != o.Host || marker.Box != r.Box || marker.ManifestSHA256 != auth.SHA256 { |
| + return ClaimPublicationReceipt{}, hostbox.ErrChanged |
| + } |
| + return ClaimPublicationReceipt{MarkerVersion: current.ID, Root: root}, nil |
| + } |
| + expected = current.ETag |
| + } |
| + body, err := json.Marshal(genselect.Marker{Op: auth.Generation, Incarnation: h.Incarnation, Host: o.Host, Box: r.Box, InstalledAt: r.Now().UTC(), ManifestSHA256: auth.SHA256, Pin: manifestPin(im)}) |
| + if err != nil { |
| + return ClaimPublicationReceipt{}, err |
| + } |
| + if err := fence(bounded); err != nil { |
| + return ClaimPublicationReceipt{}, err |
| + } |
| + version, err := r.Markers.PutMarker(bounded, r.Bucket, markerKey(h), body, expected, fence) |
| + if err != nil { |
| + return ClaimPublicationReceipt{}, err |
| + } |
| + if version == "" { |
| + return ClaimPublicationReceipt{}, hostwrite.ErrUnavailable |
| + } |
| + return ClaimPublicationReceipt{MarkerVersion: version, Root: root}, nil |
| +} |
| + |
| +// EmptyAuthority: P's opening is empty only with an evidenced empty pin; |
| +// a later attempt over an empty predecessor is an ordinary empty cold |
| +// admission (a complete listing of the location holds nothing). |
| +func (r *RelocationWakeInputs) EmptyAuthority(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs) error { |
| + if err := r.probe(o, activator.StartPolicy{Probe: true}); err != nil { |
| + return err |
| + } |
| + if !in.Empty || in.Baseline != nil || in.Prepared { |
| + return hostbox.ErrChanged |
| + } |
| + if in.Admission.NewGeneration == o.Claim.Op.Journal["prepared"] { |
| + var ref hostclaim.RecoveryPin |
| + if json.Unmarshal([]byte(o.Claim.Op.Journal["pin"]), &ref) != nil || !ref.Empty { |
| + return fmt.Errorf("%w: no evidenced empty pin", hostrelocate.ErrRefused) |
| + } |
| + return nil |
| + } |
| + versions, err := r.Data.ListVersions(ctx, r.Bucket, strings.TrimSuffix(o.Claim.Holder.Location, "/")+"/") |
| + if err != nil { |
| + return err |
| + } |
| + if len(versions) != 0 { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| +} |
| + |
| +// Publish is main's pre-replication call for a prepared admission |
| +// (ClaimWakeAssembly s.Replicate when in.Prepared). It finds, or writes, |
| +// P's marker under the fence Materialize recorded for the admitted session |
| +// and records the receipt; without that fence it refuses. Any other call |
| +// is refused: a fresh generation is published fenced (PublishFenced). |
| +func (r *RelocationWakeInputs) Publish(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs) error { |
| + if err := r.probe(o, activator.StartPolicy{Probe: true}); err != nil { |
| + return err |
| + } |
| + h := o.Claim.Holder |
| + if !in.Prepared || in.Admission.NewGeneration != o.Claim.Op.Journal["prepared"] || h.Generation != in.Admission.NewGeneration { |
| + return hostbox.ErrChanged |
| + } |
| + r.mu.Lock() |
| + work := r.fences[claimPublishedKey(o.Host, h.Session)] |
| + r.mu.Unlock() |
| + if work == nil { |
| + return fmt.Errorf("%w: no admitted writer's fence for session %d", hostbox.ErrChanged, h.Session) |
| + } |
| + receipt, err := r.preparedMarker(ctx, o, writerFence(work, in.Identity)) |
| + if err != nil { |
| + return err |
| + } |
| + r.record(o.Host, h.Session, receipt) |
| + return nil |
| +} |
| + |
| +// PublishFenced is main's fresh-generation marker writer, for a later |
| +// attempt's generation and an empty P's opening (ruling 47). |
| +func (r *RelocationWakeInputs) PublishFenced(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs, pub ClaimPublication) (ClaimPublicationReceipt, error) { |
| + if err := r.probe(o, activator.StartPolicy{Probe: true}); err != nil { |
| + return ClaimPublicationReceipt{}, err |
| + } |
| + if in.Prepared { |
| + return ClaimPublicationReceipt{}, hostbox.ErrChanged |
| + } |
| + return r.freshOwners().publishFresh(ctx, o, in, pub) |
| +} |
| + |
| +// AdmissionEvidence binds a relocation probe admission to its observation |
| +// and to the certified baseline, read again: on P's opening the exact |
| +// prepared pin and P's files manifest by digest (prepared_authority); on a |
| +// later attempt the predecessor's fresh baseline. |
| +func (r *RelocationWakeInputs) AdmissionEvidence(ctx context.Context, o hostclaim.Observed, in hostclaim.Admission, baseline *genselect.Plan) error { |
| + if err := r.probe(o, activator.StartPolicy{Probe: true}); err != nil { |
| + return err |
| + } |
| + c, h := o.Claim, o.Claim.Holder |
| + if in.ObservedOpID != c.Op.ID || in.ObservedPhase != hostclaim.PhaseRelocatingProbing || in.ObservedSession != h.Session || in.ObservedGeneration != h.Generation || in.Box != r.Box || in.Mode != "cold" || in.Coverage != "" || len(in.AdditionalWritesLost) != 0 || in.Region != r.Region { |
| + return hostbox.ErrChanged |
| + } |
| + prepared := c.Op.Journal["prepared"] |
| + if in.NewGeneration == prepared { |
| + if !in.KeepStamp || hostclaim.PreparedOpened(c) || in.PredecessorCertified != hostrelocate.CertifiedFor(c) { |
| + return hostbox.ErrChanged |
| + } |
| + auth, err := preparedAuthority(c) |
| + if err != nil { |
| + return err |
| + } |
| + raw, _, err := r.FilesStore.Get(ctx, hostfiles.ManifestKey(h.Location+"/files/"+prepared+"/")) |
| + if err != nil { |
| + return err |
| + } |
| + if sum := sha256.Sum256(raw); hex.EncodeToString(sum[:]) != auth.FilesManifestSHA256 { |
| + return fmt.Errorf("%w: P's files manifest is not the prepared authority's", hostbox.ErrChanged) |
| + } |
| + if baseline == nil { |
| + var ref hostclaim.RecoveryPin |
| + if json.Unmarshal([]byte(c.Op.Journal["pin"]), &ref) != nil || !ref.Empty || in.RemoteMaxTXID != 0 || auth.InstallManifest != "" { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| + } |
| + if baseline.Generation != prepared || baseline.MaxTXID != in.RemoteMaxTXID || baseline.MaxTXID != auth.SnapshotTXID { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| + } |
| + if in.KeepStamp || in.PredecessorCertified || !claimFreshGeneration(h, in.NewGeneration) { |
| + return hostbox.ErrChanged |
| + } |
| + if baseline == nil { |
| + return nil // an empty predecessor, proved by EmptyAuthority |
| + } |
| + fresh, err := genselect.Select(ctx, r.Data, r.request(o)) |
| + if err != nil { |
| + return err |
| + } |
| + if fresh.Generation != baseline.Generation || fresh.MaxTXID != baseline.MaxTXID || in.RemoteMaxTXID != fresh.MaxTXID || !reflect.DeepEqual(fresh.Files, baseline.Files) { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| +} |
| + |
| +// PublicationEvidence: on P's prepared opening, this process's receipt for |
| +// the admitted session with the marker still exactly that version; on any |
| +// fresh generation, main's freshEvidence. |
| +func (r *RelocationWakeInputs) PublicationEvidence(ctx context.Context, o hostclaim.Observed) error { |
| + if err := r.probe(o, activator.StartPolicy{Probe: true}); err != nil { |
| + return err |
| + } |
| + h := o.Claim.Holder |
| + r.mu.Lock() |
| + receipt, prepared := r.published[claimPublishedKey(o.Host, h.Session)] |
| + r.mu.Unlock() |
| + if !prepared { |
| + return r.freshOwners().freshEvidence(ctx, o) |
| + } |
| + current, err := (&movesProbeOwners{Data: r.Data, Bucket: r.Bucket}).currentMarker(ctx, h) |
| + if err != nil { |
| + return err |
| + } |
| + if current == nil || current.ID != receipt.MarkerVersion || h.Generation != o.Claim.Op.Journal["prepared"] { |
| + return hostbox.ErrChanged |
| + } |
| + return nil |
| +} |
diff --git a/internal/host/relocationwake_test.go b/internal/host/relocationwake_test.go| new file mode 100644 |
| index 000000000..a18504b56 |
| --- /dev/null |
| +++ b/internal/host/relocationwake_test.go |
| @@ -0,0 +1,205 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "os/exec" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/genselect" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostfiles" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/lease/leasetest" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Finding 6, ruling 47: P's first probe publishes through the shared |
| +// assembly. The claim's own runtime (the recovery fixture's controller) |
| +// admits P's opening through the relocation dispatch, binds region HA's |
| +// attempt journal to the probe start, and Materialize, under the admitted |
| +// spawn selection, runs moves' C8 Establish and writes P's marker: P as |
| +// the marker's op, the install manifest's pin, prepared_authority's |
| +// digest. Publish (the pre-replication call) finds that marker and |
| +// records the receipt; PublicationEvidence then passes. Publish without |
| +// Materialize's fence refuses. |
| +func TestRelocationFirstProbePublishesThroughTheAssembly(t *testing.T) { |
| + f := newRecoveryFixture(t) |
| + ctx := t.Context() |
| + bucket := leasetest.NewVersionedBucket() |
| + bucket.AddBucket("data") |
| + files := release.NewDirStore(t.TempDir()) |
| + o := f.read() |
| + h := o.Claim.Holder |
| + prefix := moves.GenerationPrefix(h.Location, roP) |
| + snap, anchor := moves.SnapshotKey(prefix, 42), moves.AnchorKey(prefix, 42) |
| + im := moves.InstallManifest{V: 1, Op: roOp, Host: recoveryHost, N: 42, SnapshotKey: snap, SnapshotVersion: bucket.Put("data", snap, ltxHeader(t, 1, 42)), SnapshotCRC64: 7, |
| + AnchorKey: anchor, AnchorVersion: bucket.Put("data", anchor, ltxHeader(t, 42, 42)), IntegrityCheck: "ok", DBSHA256: strings.Repeat("d", 64)} |
| + imRaw := mustJSON(t, im) |
| + bucket.Put("data", moves.InstallManifestKey(prefix), imRaw) |
| + pm := mustJSON(t, hostfiles.Manifest{V: hostfiles.ManifestVersion, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}}) |
| + if _, err := files.Put(ctx, hostfiles.ManifestKey(h.Location+"/files/"+roP+"/"), pm, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + imSum, pmSum := sha256.Sum256(imRaw), sha256.Sum256(pm) |
| + auth := hostclaim.PreparedAuthority{Generation: roP, InstallManifest: moves.InstallManifestKey(prefix), SHA256: hex.EncodeToString(imSum[:]), SnapshotTXID: 42, SourceFilesSeq: 5, FilesManifestSHA256: hex.EncodeToString(pmSum[:])} |
| + ref := hostclaim.RecoveryPin{Key: "control/relocations/" + recoveryHost + "/" + roOp + "/pin-1.json", SHA256: strings.Repeat("e", 64), Generation: h.Generation, MaxTXID: 42, FilesSeq: 5, Release: "v1"} |
| + o, err := hostclaim.Update(ctx, f.claims, o, func(c *hostclaim.Claim) error { |
| + c.Holder.Stamp = c.Gen + 1 |
| + auth.InstallStamp = c.Holder.Stamp |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: roOp, Phase: hostclaim.PhaseRelocatingProbing, Exec: "box-1", Participants: []string{"box-0", "box-1"}, |
| + Intent: map[string]string{"region": "ie", "source_box": "box-0", "target_box": "box-1", "target_enrollment": "enrollment-1"}, |
| + Journal: map[string]string{"pin": string(mustJSON(t, ref)), "prepared": roP, "install_stamp": strconv.FormatUint(c.Holder.Stamp, 10), "install_release": "v1", |
| + "prepared_authority": string(mustJSON(t, auth)), "probing_since": f.now.Format(time.RFC3339Nano), "exec_ack.box-0": `{"op":"rel-1"}`, "exec_ack.box-1": `{"op":"rel-1"}`, |
| + "local_install": string(mustJSON(t, hostclaim.LocalInstall{Box: "box-1", Enrollment: "enrollment-1", InstallStamp: c.Holder.Stamp, JournalSHA256: strings.Repeat("c", 64)}))}} |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if o, err = hostclaim.Update(ctx, f.claims, o, hostclaim.BeginProbeAttempt(roOp, "box-1")); err != nil { |
| + t.Fatal(err) |
| + } |
| + c8 := &roFiles{} |
| + inputs := &RelocationWakeInputs{ |
| + Launch: func(context.Context, registry.Route) (ClaimServingLaunch, error) { |
| + return ClaimServingLaunch{Identity: hostwrite.Identity{Config: "sha256:config", Release: "sha256:release"}, Executable: "/usr/bin/true", ExecutableSHA256: strings.Repeat("a", 64), Version: "v1"}, nil |
| + }, |
| + FilesManifest: c8, Markers: plan9D5Markers{bucket}, Claims: f.claims, FilesStore: files, Data: bucket, Bucket: "data", Region: "ie", Box: "box-1", |
| + NewID: f.newID, Now: func() time.Time { return f.now }, Releases: &roReleases{}} |
| + attempts := &relocationProbeAttempts{Claims: f.claims, DataDir: t.TempDir(), Box: "box-1", NewID: f.newID, Now: func() time.Time { return f.now }} |
| + c := f.controller("boot-1", "controller-1", 4101, "start-1") |
| + c.wake.Inputs = ClaimOwnerDispatch{Relocation: &ClaimKindOwners{Inputs: inputs, AdmissionEvidence: inputs.AdmissionEvidence, PublicationEvidence: inputs.PublicationEvidence}} |
| + c.wake.Continue = hostclaim.SessionContinues |
| + // As claimGroupDWiring routes them once the relocation owners bind. |
| + ready := func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error { return nil } |
| + c.wake.Operations = claimOperationRouter{hostclaim.KindRelocating: {Attempts: attempts, ProbeReady: ready}} |
| + live, cancel := context.WithCancel(context.Background()) |
| + t.Cleanup(cancel) |
| + done := make(chan struct{}) |
| + go func() { <-live.Done(); close(done) }() |
| + c.wake.mu.Lock() |
| + c.wake.pending[recoveryHost] = &claimWakeAttempt{observed: o, held: recoveryHeld{}, ctx: live, cancel: cancel, done: done} |
| + c.wake.mu.Unlock() |
| + route := registry.Route{Host: recoveryHost, Kind: "instance", App: "jam", Account: "bab", Status: "relocating", HoldOp: roOp, Socket: filepath.Join(f.guard.DataDir, "app.sock"), DBPath: filepath.Join(f.guard.DataDir, "state", "data.db")} |
| + |
| + admitted, err := c.runtime.Admit(ctx, route, o, roProbe) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ah := admitted.Claim.Holder |
| + if ah.Generation != roP || ah.Stamp != o.Claim.Holder.Stamp || ah.WriterBox != "box-1" || !hostclaim.PreparedOpened(admitted.Claim) { |
| + t.Fatalf("P's opening: %+v", ah) |
| + } |
| + s, err := c.runtime.Prepare(ctx, route, admitted, roProbe) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if s.OpenProbe == nil { |
| + t.Fatal("the probe start carries no attempt journal") |
| + } |
| + if _, err := s.OpenProbe(ctx, f.now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.Reserve(ctx, 0); err != nil { |
| + t.Fatal(err) |
| + } |
| + if a := s.CurrentAttempt(); a.ID == "" || a.Session != ah.Session || a.ControllerID != "controller-1" { |
| + t.Fatalf("the reservation: %+v", a) |
| + } |
| + if err := s.Materialize(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(c8.established) != 1 { |
| + t.Fatalf("C8 established %v", c8.established) |
| + } |
| + markerKey := strings.TrimSuffix(h.Location, "/") + "/generation.json" |
| + body, ok := bucket.Current("data", markerKey) |
| + var m genselect.Marker |
| + if !ok || json.Unmarshal(body, &m) != nil || m.Op != roP || m.Box != "box-1" || m.Host != recoveryHost || m.ManifestSHA256 != auth.SHA256 || m.Pin != manifestPin(im) { |
| + t.Fatalf("P's marker: %s", body) |
| + } |
| + written := bucket.LatestID("data", markerKey) |
| + // The pre-replication publish (ClaimWakeAssembly s.Replicate's first |
| + // call for a prepared admission) finds the marker and records it. |
| + in, err := inputs.Select(ctx, route, o, roProbe) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := inputs.Publish(ctx, admitted, in); err != nil { |
| + t.Fatal(err) |
| + } |
| + if bucket.LatestID("data", markerKey) != written { |
| + t.Fatal("Publish rewrote P's marker") |
| + } |
| + if err := inputs.PublicationEvidence(ctx, admitted); err != nil { |
| + t.Fatalf("publication evidence: %v", err) |
| + } |
| + // A fresh wake inputs (another process) has no fence for the session. |
| + other := &RelocationWakeInputs{Launch: inputs.Launch, FilesManifest: c8, Markers: inputs.Markers, Claims: f.claims, FilesStore: files, Data: bucket, Bucket: "data", Region: "ie", Box: "box-1", |
| + NewID: f.newID, Now: inputs.Now, Releases: inputs.Releases} |
| + if err := other.Publish(ctx, admitted, in); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("Publish without Materialize's fence: %v", err) |
| + } |
| +} |
| + |
| +// Ruling 47: a later attempt's fresh generation, and an empty P's opening, |
| +// publish through main's fenced writer only after their first root. |
| +func TestRelocationLaterAttemptsPublishFenced(t *testing.T) { |
| + ctx := t.Context() |
| + f := newROFixture(t, roOptions{}) |
| + first, err := f.inputs.Select(ctx, f.route, f.read(), roProbe) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + opened := f.admit(f.read(), first) |
| + again, err := f.inputs.Select(ctx, f.route, opened, roProbe) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + later := f.admit(opened, again) |
| + gen := later.Claim.Holder.Generation |
| + synced := 0 |
| + pub := ClaimPublication{Sync: func(context.Context) error { synced++; return nil }, Check: func(context.Context) error { return nil }} |
| + if err := f.inputs.Publish(ctx, later, again); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("an unfenced marker for a fresh generation: %v", err) |
| + } |
| + if _, err := f.inputs.PublishFenced(ctx, later, again, pub); err == nil || synced != 1 { |
| + t.Fatalf("a marker before the root: %v synced=%d", err, synced) |
| + } |
| + root := moves.SnapshotKey(moves.GenerationPrefix(later.Claim.Holder.Location, gen), 44) |
| + f.bucket.Put("data", root, ltxHeader(t, 1, 44)) |
| + receipt, err := f.inputs.PublishFenced(ctx, later, again, pub) |
| + if err != nil || receipt.Root.Key != root || receipt.MarkerVersion == "" { |
| + t.Fatalf("publish: %+v %v", receipt, err) |
| + } |
| + if err := f.inputs.PublicationEvidence(ctx, later); err != nil { |
| + t.Fatalf("fresh evidence: %v", err) |
| + } |
| + prepared := again |
| + prepared.Prepared = true |
| + if _, err := f.inputs.PublishFenced(ctx, later, prepared, pub); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("a prepared admission through the fenced writer: %v", err) |
| + } |
| + |
| + // An empty P: no snapshot, no root until the first transaction. |
| + e := newROFixture(t, roOptions{empty: true}) |
| + ein, err := e.inputs.Select(ctx, e.route, e.read(), roProbe) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + eopened := e.admit(e.read(), ein) |
| + if _, err := e.inputs.PublishFenced(ctx, eopened, ein, pub); err == nil { |
| + t.Fatal("an empty P published a marker with no root") |
| + } |
| +} |