carlos / platform Public

host: register the moves wake source right after the parks, which opens the gate (Plan 5 Task 9b; Q1, Q15)

movesWakeSources is this box's ClaimProductionMoveSources. With both
CARLOS_HOST_CLAIM and CARLOS_MOVES_PHASE1 on (both default off), the agent
registers it right after the park providers (registerProductionMovesParks
now returns the sources it registered), and that registration is what
opens movesWakeSourceRegistered, so the parks arm in the same start as the
wake owners.

Open returns the moves owner set the claim binds under moves C1: one
movesProbeOwners (landing probe, committed wake, and the recovery owners
by kind) with the moves attempt journal as Operations, the moves loss
owner and the loss baseline validator; route, launch and held works from
the claim's move read access; Task 7b's held release adapter. It opens no
files provider: openClaimFilesManifest hands the claim's C8 provider (Task
6's registration) to the moves owners too (bindClaimMovesFiles); a second
provider is refused. View and VerifyRetirement delegate to the parks (Q15).

agent.go: the registration call after the parks, and the stale comment
(Task 1's review) now names movesWakeSourceRegistered and Task 9b.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 02cb7101508c1885472d995c73f3986cec335df6 parent 6bb9d41
11 files changed, +710 −39
  • approved_moves_copy_test.go +1 −0
  • internal/host/agent.go +9 −5
  • internal/host/claimfiles.go +5 −0
  • internal/host/movespass.go +4 −3
  • internal/host/movesprobeattempts.go +4 −2
  • internal/host/movesprobeowners.go +3 −2
  • internal/host/movesrecoveryowners.go +4 −2
  • internal/host/movessources.go +26 −20
  • internal/host/movessources_test.go +6 −5
  • internal/host/moveswakesource.go +281 −0
  • internal/host/moveswakesource_test.go +367 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index 872fa2d4c..bf361a97d 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -1215,6 +1215,7 @@ var movesScanFiles = []string{
"internal/host/movesheldlock.go",
"internal/host/movesfiles.go",
"internal/host/movesinstall.go",
+ "internal/host/moveswakesource.go",
"internal/host/moveshold.go",
"internal/host/movesland.go",
"internal/host/moveslandcommit.go",
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 1625c0ee2..dbd71b81b 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -279,13 +279,17 @@ func runAgent(args []string, buildClaims ClaimFactory) {
}
claimCtx = withClaimRuntimeResources(claimCtx, ClaimRuntimeResources{Mode: "agent", Registry: reg, Manager: cgm, DataDir: dd, OptDir: release.OptDir(), UnitsDir: "/etc/systemd/system"})
// The moves park providers (Plan 5 Task 9a) register before the claim
- // assembly reads them; only with both switches on, and behind the claim
- // Plan 12 guard, so the parks still refuse by name until it lands.
- // Task 5's expected refusals are quiet in the regional worker's log and
- // its anomalies page once (movesrefusals.go), with both switches on.
+ // assembly reads them, then the moves wake source right after them
+ // (Plan 5 Task 9b): only with both switches on. The parks refuse by
+ // name until the wake source registers, which opens the one gate
+ // (movesWakeSourceRegistered), so the parks arm in the same start as
+ // the wake owners. Task 5's expected refusals are quiet in the regional
+ // worker's log and its anomalies page once (movesrefusals.go).
movesRefusalLog := newMovesRefusals(metricsBoxLabel(os.Getenv, readMachineID()), PageOperator, log.Printf, time.Now)
- if err := registerProductionMovesParks(claimCtx, claimEntryEnv(claimCtx), claimStore(claimCtx), *cfg, productionRosterRegion(claimStore(claimCtx), os.Getenv), movesRefusalLog); err != nil {
+ if parks, err := registerProductionMovesParks(claimCtx, claimEntryEnv(claimCtx), claimStore(claimCtx), *cfg, productionRosterRegion(claimStore(claimCtx), os.Getenv), movesRefusalLog); err != nil {
log.Printf("agent: moves: the park providers are not registered: %v", err)
+ } else if err := registerProductionMovesWakeSource(claimEntryEnv(claimCtx), parks, dd); err != nil {
+ log.Printf("agent: moves: the wake source is not registered: %v", err)
}
// The C8 files manifest provider (moves Task 6) registers under the
// claim switch alone: every claim-on serving wake establishes its files
diff --git a/internal/host/claimfiles.go b/internal/host/claimfiles.go
index 88075abb6..d214b86af 100644
--- a/internal/host/claimfiles.go
+++ b/internal/host/claimfiles.go
@@ -76,6 +76,11 @@ func openClaimFilesManifest(ctx context.Context, getenv func(string) string, sou
return nil, errors.Join(hostwrite.ErrUnavailable, errors.New("host claim: the files source opened no provider"))
}
out.Serving.Files = files
+ // The moves recovery owners establish through the same provider
+ // (moves Task 8): one C8 provider per box, never a second.
+ if err := bindClaimMovesFiles(out); err != nil {
+ return nil, errors.Join(err, source.Close(ctx))
+ }
return source.Close, nil
}
diff --git a/internal/host/movespass.go b/internal/host/movespass.go
index 5da309685..53fdeb73f 100644
--- a/internal/host/movespass.go
+++ b/internal/host/movespass.go
@@ -262,7 +262,8 @@ func (w *movesWorker) mirror(ctx context.Context, dstRegion string) (lease.Mirro
// claim wiring's (newClaimWiring builds it before this): with both switches
// on, the park arms take their engine from its MoveParks (armMovesParks,
// Plan 5 Task 9a), behind the wake-source gate (movesWakeSourceRegistered,
-// Plan 5 Task 9b), which nothing in production opens yet. events is the agent's
+// Plan 5 Task 9b), which the moves wake source's registration opens, before
+// this runs, in the same agent start. events is the agent's
// rollout sink, which the move log tells the app's members through (Plan 6
// Task 3).
func armMovesPass(hib activator.Config, dataDir string, reg *registry.Registry, svc *activator.Service, eb *activator.ExecBackend, writes hostwrite.Config, providers ClaimProviders, events activator.EventSink) {
@@ -460,8 +461,8 @@ func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir strin
// MoveParks (armMovesParks, Plan 5 Task 9a, in armMovesPass), with
// park.Engine.RunHeld and the MoveParks local authorizer, and are set
// only once the wake-source gate opens (movesWakeSourceRegistered, Plan 5
- // Task 9b), so landingArmed is false in production and the landing pass
- // only logs. The held writes select their Work from writes (ruling
+ // Task 9b: the moves wake source registered with both switches on); until
+ // then landingArmed is false and the landing pass only logs. The held writes select their Work from writes (ruling
// G16; Task 7, Q2): the timers through schedule.SelectedTimers as
// held-timers (heldTimersReplacer), the render as held-config
// (heldRenderer) and the release as held-release (heldReleaseHooks).
diff --git a/internal/host/movesprobeattempts.go b/internal/host/movesprobeattempts.go
index 056158a31..4cfbbeebf 100644
--- a/internal/host/movesprobeattempts.go
+++ b/internal/host/movesprobeattempts.go
@@ -607,8 +607,10 @@ func (p *movesProbeAttempts) recoveryAttempted(ctx context.Context, rec *provisi
// (Plan 3 Task 5), over the deployment store s, its records and newID, with
// this worker's data directory and clock. Its RecoveryAttempt is the
// worker's move log hook (recoveryAttemptLogger), so every recovery probe
-// start it counts is logged (fix round 2, ruling 2). Production builds the
-// journal here once Plan 9b registers the wake source.
+// start it counts is logged (fix round 2, ruling 2). Production's journal
+// is the registered wake source's (movesWakeSources.Open, Plan 5 Task 9b),
+// built the same way before this worker is armed; it reaches this worker's
+// hook through armedMovesWorker (movesWorkerRecoveryAttempt).
func (w *movesWorker) probeAttempts(s release.Store, records MovesRecordReader, newID func() string) *movesProbeAttempts {
return &movesProbeAttempts{Claims: s, Records: records, DataDir: w.cfg.DataDir, Admitted: MovesRecordAdmittedAttempts,
NewID: newID, Now: w.cfg.Now, RecoveryAttempt: w.recoveryAttemptLogger(s)}
diff --git a/internal/host/movesprobeowners.go b/internal/host/movesprobeowners.go
index 731f68ec6..e830c0836 100644
--- a/internal/host/movesprobeowners.go
+++ b/internal/host/movesprobeowners.go
@@ -94,8 +94,9 @@ import (
// publishes it. PublicationEvidence: this process's receipt for the
// admitted session, read back.
//
-// Production: nothing registers this owner set yet. The probe is unarmed in
-// production until Plan 5 Task 9 arms the parks; the tests use fakes.
+// Production: the moves wake source (movesWakeSources, Plan 5 Task 9b)
+// opens this owner set for the claim with both switches on, and its
+// registration opens the gate that arms the parks.
type movesProbeOwners struct {
HomeRegion, FleetHomeRegion string
// Claims is the claim store (the deployment store in production).
diff --git a/internal/host/movesrecoveryowners.go b/internal/host/movesrecoveryowners.go
index 20c59d112..0da54738b 100644
--- a/internal/host/movesrecoveryowners.go
+++ b/internal/host/movesrecoveryowners.go
@@ -79,8 +79,10 @@ import (
// generation's marker after its own first root, and this process's
// receipt read back.
//
-// Production: nothing registers this owner set yet (Plan 5 Task 9b). C8 is
-// Task 6's provider; without one every recovery kind is refused, typed.
+// Production: the moves wake source (movesWakeSources, Plan 5 Task 9b)
+// registers this owner set, with both switches on. C8 is Task 6's
+// provider, the claim's (bindClaimMovesFiles); without one every recovery
+// kind is refused, typed.
type movesRecoveryOwners struct{ m *movesProbeOwners }
// recoveryKind reports whether o is a recovery the recovery owners serve.
diff --git a/internal/host/movessources.go b/internal/host/movessources.go
index c102f5de3..5d653c4ce 100644
--- a/internal/host/movessources.go
+++ b/internal/host/movessources.go
@@ -20,13 +20,14 @@ package host
// park.Engine.RunHeld over those leases (M5) and the MoveParks local
// authorizer (M9).
//
-// Production stays unarmed. Registration and arming happen only with both
-// CARLOS_MOVES_PHASE1 and CARLOS_HOST_CLAIM on. Even then one gate,
+// Registration and arming happen only with both CARLOS_MOVES_PHASE1 and
+// CARLOS_HOST_CLAIM on, and both default to off. Even then one gate,
// movesWakeSourceRegistered, stands in front of both: it opens only once
-// this box's moves source (the wake owners and the C8 files manifest
-// provider, Plan 5 Task 9b) is registered with the claim through
-// registerMovesWakeSource, and nothing in production does that yet. While it
-// is shut:
+// this box's moves source (the wake owners, Plan 5 Task 9b,
+// moveswakesource.go) is registered with the claim through
+// registerMovesWakeSource, which the agent does right after the parks. The
+// conditions production arming still needs are listed in
+// moveswakesource.go. While the gate is shut:
//
// - the registered view refuses every call with a *ClaimDependencyError
// naming Task 9b, so the claim's regional worker and its at-rest sweep
@@ -110,9 +111,9 @@ var movesWakeSource struct {
// registerMovesWakeSource registers src, this box's moves source (the wake
// owners and the C8 files manifest provider, Plan 5 Task 9b), with the claim
// (RegisterProductionClaimMoveSources), and records it for the gate. It is
-// one-shot, as the claim's slot is; any refusal records nothing. Nothing in
-// production calls it yet: Task 8 registers the source and so arms the
-// parks.
+// one-shot, as the claim's slot is; any refusal records nothing. In
+// production registerProductionMovesWakeSource calls it, with both
+// switches on, and so arms the parks (Plan 5 Task 9b).
func registerMovesWakeSource(src ClaimProductionMoveSources) error {
if src == nil {
return hostwrite.ErrUnavailable
@@ -659,35 +660,40 @@ func armMovesParks(ctx context.Context, cfg *moveCfg, providers ClaimProviders)
// on (ruling H1), over the deployment store's records, this box's data
// bucket read by exact version (lease.NewVersionedS3, through rootaws), and
// the region regionOf answers, behind the wake-source gate
-// (movesWakeSourceRegistered), which nothing in production opens yet, so the
-// registered view refuses every host. Production's
+// (movesWakeSourceRegistered): the registered view refuses every host until
+// the moves wake source registers right after it
+// (registerProductionMovesWakeSource, Plan 5 Task 9b), to which it returns
+// the sources it registered. Production's
// regionOf is productionRosterRegion: the claim's verified roster row (fix
// round 1), held by movesBoxRegion to the engine region of this box's own
// placement, CARLOS_REGION. The roster refuses a row with region "", so on
// a home box it names home otherwise than park.HomeRegion, and the view
// refuses there until the claim names home as the engine does (home-region
-// parks, part 1). Off, it registers nothing and the claim keeps its named
-// refusals. Task 5's evidence goes through refusals, which pages its
-// anomalies once (movesrefusals.go).
-func registerProductionMovesParks(ctx context.Context, getenv func(string) string, store release.Store, hib activator.Config, regionOf func(context.Context) (string, error), refusals *movesRefusals) error {
+// parks, part 1). Off, it registers nothing, returns no sources, and the
+// claim keeps its named refusals. Task 5's evidence goes through refusals,
+// which pages its anomalies once (movesrefusals.go).
+func registerProductionMovesParks(ctx context.Context, getenv func(string) string, store release.Store, hib activator.Config, regionOf func(context.Context) (string, error), refusals *movesRefusals) (movesParkSources, error) {
if !hostwrite.Enabled(getenv) || !moves.Enabled(getenv) {
- return nil
+ return movesParkSources{}, nil
}
if ctx == nil || store == nil || hib.Bucket == "" || hib.Region == "" || regionOf == nil || refusals == nil {
- return errors.Join(hostwrite.ErrUnavailable, errors.New("the moves park view needs the deployment store, the data bucket and this box's roster region"))
+ return movesParkSources{}, errors.Join(hostwrite.ErrUnavailable, errors.New("the moves park view needs the deployment store, the data bucket and this box's roster region"))
}
data, err := lease.NewVersionedS3(ctx, map[string]string{hib.Bucket: hib.Region})
if err != nil {
- return err
+ return movesParkSources{}, err
}
records := MovesRecordsFrom(store)
evidence := refusals.evidence(movesRetirementEvidence{Records: records, Now: time.Now})
region := movesBoxRegion(strings.TrimSpace(getenv("CARLOS_REGION")), cachedRegion(regionOf))
src, err := newMovesParkSources(records, data, hib.Bucket, region, evidence)
if err != nil {
- return err
+ return movesParkSources{}, err
+ }
+ if err := registerMovesParks(src); err != nil {
+ return movesParkSources{}, err
}
- return registerMovesParks(src)
+ return src, nil
}
// productionRosterRegion is this box's region as the claim knows it: the
diff --git a/internal/host/movessources_test.go b/internal/host/movessources_test.go
index 4f98fbdad..31636e093 100644
--- a/internal/host/movessources_test.go
+++ b/internal/host/movessources_test.go
@@ -727,9 +727,10 @@ func TestCommittedLaneViewNamesTheTargetWithItsMarker(t *testing.T) {
// switch off the agent registers nothing. With both on it registers without
// CARLOS_REGION: the view's region is the claim's verified roster row's
// (fix round 1, region source), resolved when the view is first asked. What
-// it registers stays shut (Q1): nothing in production registers the moves
-// wake source yet, so the claim's park view refuses every host before it
-// reads anything, and the claim's sweep is not widened.
+// it registers stays shut (Q1) until the moves wake source registers right
+// after it (registerProductionMovesWakeSource, Task 8): the parks alone
+// leave the claim's park view refusing every host before it reads anything,
+// so the claim's sweep is not widened.
func TestRegisterProductionMovesParksNeedsBothSwitches(t *testing.T) {
withMovesWakeSource(t, false)
env := func(vals map[string]string) func(string) string { return func(k string) string { return vals[k] } }
@@ -741,14 +742,14 @@ func TestRegisterProductionMovesParksNeedsBothSwitches(t *testing.T) {
"claims off": {moves.SwitchEnv: "on"},
"moves off": {"CARLOS_HOST_CLAIM": "1", moves.SwitchEnv: "off"},
} {
- if err := registerProductionMovesParks(t.Context(), env(vals), store, hib, roster, newMovesRefusals("ie-box", nil, t.Logf, time.Now)); err != nil {
+ if _, err := registerProductionMovesParks(t.Context(), env(vals), store, hib, roster, newMovesRefusals("ie-box", nil, t.Logf, time.Now)); err != nil {
t.Fatalf("%s: %v", name, err)
}
if claimMoveSources.Parks != nil {
t.Fatalf("%s: registered", name)
}
}
- if err := registerProductionMovesParks(t.Context(), env(map[string]string{"CARLOS_HOST_CLAIM": "1", moves.SwitchEnv: "on"}), store, hib, roster, newMovesRefusals("ie-box", nil, t.Logf, time.Now)); err != nil {
+ if _, err := registerProductionMovesParks(t.Context(), env(map[string]string{"CARLOS_HOST_CLAIM": "1", moves.SwitchEnv: "on"}), store, hib, roster, newMovesRefusals("ie-box", nil, t.Logf, time.Now)); err != nil {
t.Fatalf("both switches on, no CARLOS_REGION: %v", err)
}
if claimMoveSources.Parks == nil || asked != 0 {
diff --git a/internal/host/moveswakesource.go b/internal/host/moveswakesource.go
new file mode 100644
index 000000000..03c1f84a3
--- /dev/null
+++ b/internal/host/moveswakesource.go
@@ -0,0 +1,281 @@
+package host
+
+// The moves wake source (moves Plan 5 Task 9b; the rulings after claim Plan
+// 12: Q1, Q12, Q15, and the controller's Task 8 overrides). It is this box's
+// ClaimProductionMoveSources: registered with the claim at agent start,
+// right after the park providers (registerProductionMovesParks) and before
+// newClaimWiring, and only with both CARLOS_HOST_CLAIM and
+// CARLOS_MOVES_PHASE1 on. Both default to off, so nothing here turns
+// anything on.
+//
+// Registering it (registerMovesWakeSource) is what opens the one gate
+// between the moves parks and production (movesWakeSourceRegistered), so
+// the parks arm in the same agent start as the wake owners, never before.
+// With the gate open, productionClaimRegional.View, the claim's park view
+// for every host on the box, reads through the moves view: the claim's
+// at-rest sweep and every regional park chore run through it for every
+// host, not only moved ones (Q1's widened sweep; the rehearsal's evidence
+// list names it).
+//
+// - Open builds the moves owner set the claim binds under moves C1
+// (claimMovesC1): one movesProbeOwners, which serves the landing probe,
+// the committed landing wake and, dispatched by kind, the recovery
+// owners (restoring and reverting), with the moves attempt journal as
+// Operations, the moves loss owner as LossRecovery and the loss
+// baseline validator as LossSources. Its route, launch and held works
+// are the claim's move read access; its held release is Task 7b's root
+// adapter (heldReleaseProbeRelease). Open binds no files manifest
+// provider: the recovery owners take the claim's C8 provider, the one
+// Task 6's RegisterProductionClaimFiles binds for the serving owners
+// (openClaimFilesManifest, bindClaimMovesFiles). Two providers are a
+// wiring error.
+// - View and VerifyRetirement are never read by the claim (Q15): the park
+// sources registered first stay, so both delegate to those same values.
+//
+// Production arming still needs, beyond this registration:
+//
+// - both switches on (CARLOS_HOST_CLAIM, CARLOS_MOVES_PHASE1);
+// - the claim floor (hostfloor: the guard-ready running floor);
+// - the switch default: moves.DefaultEnabled stays false while copy rows
+// are pending (TestPendingCopyKeepsTheSwitchOff);
+// - on a home box, claim Plan 13's signed fleet home designation, so the
+// claim grants the @home alias (armMovesParks refuses otherwise);
+// - claim activation's #250 checklist: the files manifest backfill for
+// hosts that predate the files publisher, CARLOS_RELEASE_PUBKEY for
+// instance-control's held release route, claim Plan 14's root
+// authority, and claim Plan 15's provenance record for hosts past the
+// admission pruning window.
+
+import (
+ "context"
+ "crypto/ecdh"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/controlsock"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// movesWakeSources is this box's moves wake source (see the file comment).
+type movesWakeSources struct {
+ // Parks are the park sources registered before it: View and
+ // VerifyRetirement delegate to them (Q15).
+ Parks movesParkSources
+ // DataDir is the moves data directory: the op journals' root and the
+ // hosts' files directories, the moves worker's (movesDataDir).
+ DataDir string
+ // OptDir is /opt/carlos, where root publishes the held release.
+ OptDir string
+ // Region is this box's CARLOS_REGION, the held release request's region.
+ Region string
+ // Getenv reads CARLOS_SECRETS_KEY when the source opens.
+ Getenv func(string) string
+
+ // Seams, set by newProductionMovesWakeSources.
+ records func(release.Store) MovesRecordReader
+ markers func(ctx context.Context, bucket, region string) (ClaimMarkerStore, error)
+ relay heldReleaseRelay
+ now func() time.Time
+ newID func() string
+}
+
+var _ ClaimProductionMoveSources = (*movesWakeSources)(nil)
+
+// newProductionMovesWakeSources is the production source over parks and
+// the box's data directory.
+func newProductionMovesWakeSources(getenv func(string) string, parks movesParkSources, dataDir string) *movesWakeSources {
+ return &movesWakeSources{Parks: parks, DataDir: movesDataDir(dataDir), OptDir: release.OptDir(), Region: strings.TrimSpace(getenv("CARLOS_REGION")), Getenv: getenv,
+ records: MovesRecordsFrom, markers: newProductionClaimMarkers, relay: newHeldReleaseRootClient(controlsock.Path), now: time.Now, newID: movesWakeID}
+}
+
+// movesWakeID is a fresh generation name, as the claim's own.
+func movesWakeID() string {
+ var b [16]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return ""
+ }
+ return hex.EncodeToString(b[:])
+}
+
+// Open builds the moves owner set for the verified actor, over the claim's
+// store, its versioned data bucket and its move read access (see the file
+// comment). Anything missing refuses: the claim then refuses every wake by
+// name rather than half-wiring moves.
+func (s *movesWakeSources) Open(ctx context.Context, store release.Store, data lease.VersionedStore, actor hostbox.Actor, read ClaimMoveReadAccess) (ClaimMovesOwners, ClaimFilesManifest, error) {
+ region := park.PlacementRegion(actor.Region)
+ if s == nil || ctx == nil || store == nil || data == nil || read == nil || s.Getenv == nil || s.records == nil || s.markers == nil || s.relay == nil || s.now == nil || s.newID == nil ||
+ s.DataDir == "" || s.OptDir == "" || actor.Box == "" || actor.Bucket == "" || region == "" || actor.Cloud.Region == "" {
+ return ClaimMovesOwners{}, nil, hostwrite.ErrUnavailable
+ }
+ if a, ok := read.(*claimMoveReadAccess); ok && a == nil {
+ return ClaimMovesOwners{}, nil, hostwrite.ErrUnavailable
+ }
+ secrets := s.Getenv("CARLOS_SECRETS_KEY")
+ envKey, err := moves.ReportKeyFromSecrets(secrets)
+ if err != nil {
+ return ClaimMovesOwners{}, nil, err
+ }
+ priv, err := appconfig.ParsePrivateKey(secrets, "CARLOS_SECRETS_KEY")
+ if err != nil {
+ return ClaimMovesOwners{}, nil, err
+ }
+ markers, err := s.markers(ctx, actor.Bucket, actor.Cloud.Region)
+ if err != nil {
+ return ClaimMovesOwners{}, nil, err
+ }
+ records := s.records(store)
+ via := heldReleaseVia{Relay: s.relay, Region: s.Region}
+ owners := &movesProbeOwners{HomeRegion: actor.HomeRegion, FleetHomeRegion: actor.FleetHomeRegion, Claims: store, Records: records,
+ ReadyPath: movesReadyPath(store), Data: data, Bucket: actor.Bucket, Region: region, Box: actor.Box,
+ DataDir: s.DataDir, FilesDir: movesFilesDirFor(s.DataDir), Markers: markers,
+ Route: movesReadRoute(read),
+ Launch: movesReadLaunch(read),
+ EnvKey: envKey, Admitted: MovesRecordAdmittedAttempts,
+ Works: read.HeldWork,
+ Render: movesProbeRender(store, priv, s.DataDir, actor.Box),
+ Release: heldReleaseProbeRelease(via, store, actor.Box, s.OptDir, s.DataDir, release.NewProofGate(release.ProofPolicyPath, s.DataDir)),
+ NewID: s.newID, Now: s.now, Page: PageOperator}
+ if !owners.valid() {
+ return ClaimMovesOwners{}, nil, hostwrite.ErrUnavailable
+ }
+ attempts := &movesProbeAttempts{Claims: store, Records: records, DataDir: s.DataDir, Admitted: MovesRecordAdmittedAttempts, NewID: s.newID, Now: s.now,
+ RecoveryAttempt: movesWorkerRecoveryAttempt(store)}
+ return owners.MovesOwners(attempts), nil, nil
+}
+
+// View is the park sources' view (Q15): the claim reads the parks
+// registered first, so this is the same view.
+func (s *movesWakeSources) View(ctx context.Context, o hostclaim.Observed) (park.View, error) {
+ if s == nil || s.Parks.View == nil {
+ return park.View{}, hostwrite.ErrUnavailable
+ }
+ return s.Parks.View(ctx, o)
+}
+
+// VerifyRetirement is the park sources' evidence (Q15).
+func (s *movesWakeSources) VerifyRetirement(ctx context.Context, o hostclaim.Observed, v []lease.ObjectVersion) (string, time.Time, error) {
+ if s == nil || s.Parks.Evidence == nil {
+ return "", time.Time{}, hostwrite.ErrUnavailable
+ }
+ return s.Parks.Evidence.VerifyRetirement(ctx, o, v)
+}
+
+// Close closes nothing: Open holds no resource of its own.
+func (s *movesWakeSources) Close(context.Context) error { return nil }
+
+// movesReadyPath is the app manifest's moves.ready_path, "/" when the
+// manifest declares none; a manifest that cannot be read refuses (S1's rule,
+// probeSource).
+func movesReadyPath(store release.Store) func(ctx context.Context, account, app string) (string, error) {
+ return func(ctx context.Context, account, app string) (string, error) {
+ m, err := provision.GetManifest(ctx, store, account, app)
+ if err != nil {
+ return "", err
+ }
+ return m.ReadyPathOrDefault(), nil
+ }
+}
+
+// movesReadRoute is the claim's move read access as the owners' route:
+// each read is bounded by the claim's request limit.
+func movesReadRoute(read ClaimMoveReadAccess) func(host string) (*registry.Route, error) {
+ return func(host string) (*registry.Route, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), hostbox.RequestLimit)
+ defer cancel()
+ return read.Route(ctx, host)
+ }
+}
+
+// movesReadLaunch is the claim's move read access as the owners' launch.
+// The owners judge the launch's environment themselves (envDigest), so the
+// access's report is not used.
+func movesReadLaunch(read ClaimMoveReadAccess) func(context.Context, registry.Route) (ClaimServingLaunch, error) {
+ return func(ctx context.Context, rt registry.Route) (ClaimServingLaunch, error) {
+ l, _, err := read.Launch(ctx, rt.Host)
+ return l, err
+ }
+}
+
+// movesProbeRender is the held render under the claim for the probe and
+// recovery owners (appconfig.RenderHeldUnder, the held render authority).
+func movesProbeRender(store release.Store, priv *ecdh.PrivateKey, dataDir, box string) func(ctx context.Context, rt registry.Route, op string, work func(appconfig.HeldRender) (hostwrite.Work, error)) (appconfig.HeldRender, error) {
+ return func(ctx context.Context, rt registry.Route, op string, work func(appconfig.HeldRender) (hostwrite.Work, error)) (appconfig.HeldRender, error) {
+ return appconfig.RenderHeldUnder(ctx, store, priv, dataDir, rt, op, box, work)
+ }
+}
+
+// movesWorkerRecoveryAttempt is the attempt journal's RecoveryAttempt: the
+// armed moves worker's move log hook (recoveryAttemptLogger). The worker is
+// armed after the claim wiring opens this source, so it is looked up when a
+// start is counted; with none armed nothing is logged.
+func movesWorkerRecoveryAttempt(store release.Store) func(ctx context.Context, rec provision.Record, epoch uint64, n int) {
+ return func(ctx context.Context, rec provision.Record, epoch uint64, n int) {
+ if w := armedMovesWorker.Load(); w != nil {
+ w.recoveryAttemptLogger(store)(ctx, rec, epoch, n)
+ }
+ }
+}
+
+// registerProductionMovesWakeSource is the agent's registration, right
+// after the parks (registerProductionMovesParks) and before
+// newClaimWiring: only with both CARLOS_HOST_CLAIM and CARLOS_MOVES_PHASE1
+// on, over the park sources just registered. Off, it registers nothing and
+// the gate stays shut. A registration that succeeds opens the gate.
+func registerProductionMovesWakeSource(getenv func(string) string, parks movesParkSources, dataDir string) error {
+ if getenv == nil || !hostwrite.Enabled(getenv) || !moves.Enabled(getenv) {
+ return nil
+ }
+ if parks.View == nil || parks.Evidence == nil || dataDir == "" {
+ return errors.Join(hostwrite.ErrUnavailable, errors.New("the moves wake source needs the registered park sources and the data directory"))
+ }
+ return registerMovesWakeSource(newProductionMovesWakeSources(getenv, parks, dataDir))
+}
+
+// claimMovesFilesBinder is the moves owner set's C8 binding: the claim's
+// files manifest provider, the one the serving owners use.
+type claimMovesFilesBinder interface {
+ bindClaimFiles(ClaimFilesManifest) error
+}
+
+// bindClaimMovesFiles hands the claim's C8 provider (out.Serving.Files,
+// bound by openClaimFilesManifest from Task 6's registration) to the moves
+// owners, so the recovery owners establish through the same provider. A
+// moves owner set already bound to another provider is a wiring error.
+func bindClaimMovesFiles(out *ClaimOwnerBindings) error {
+ if out == nil || out.Serving.Files == nil {
+ return nil
+ }
+ b, ok := out.Moves.Inputs.(claimMovesFilesBinder)
+ if !ok {
+ return nil
+ }
+ return b.bindClaimFiles(out.Serving.Files)
+}
+
+// bindClaimFiles binds the claim's C8 provider once; the same provider
+// again is a no-op, another one is refused.
+func (m *movesProbeOwners) bindClaimFiles(files ClaimFilesManifest) error {
+ if m == nil || files == nil {
+ return hostwrite.ErrUnavailable
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.Files != nil && m.Files != files {
+ return errors.Join(hostwrite.ErrUnavailable, fmt.Errorf("host claim: two C8 files manifest providers: the moves owners hold %T", m.Files))
+ }
+ m.Files = files
+ return nil
+}
diff --git a/internal/host/moveswakesource_test.go b/internal/host/moveswakesource_test.go
new file mode 100644
index 000000000..55c205688
--- /dev/null
+++ b/internal/host/moveswakesource_test.go
@@ -0,0 +1,367 @@
+package host
+
+import (
+ "context"
+ "encoding/base64"
+ "errors"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/hostops"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/lease/leasetest"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Plan 5 Task 9b (moves-after-Plan-12 Task 8; rulings Q1, Q12, Q15): the
+// moves wake source is registered right after the parks, with both
+// switches on, and its registration is what opens the one gate
+// (movesWakeSourceRegistered), so the parks arm in the same agent start as
+// the wake owners and never before.
+
+// mwSecretsKey is a CARLOS_SECRETS_KEY for the registered source's env
+// report key and held render: a valid P-256 scalar.
+var mwSecretsKey = base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
+
+// mwEnv is an environment with the two switches as given.
+func mwEnv(claim, phase1 bool) func(string) string {
+ vals := map[string]string{"CARLOS_SECRETS_KEY": mwSecretsKey}
+ if claim {
+ vals["CARLOS_HOST_CLAIM"] = "1"
+ }
+ if phase1 {
+ vals[moves.SwitchEnv] = "on"
+ }
+ return func(k string) string { return vals[k] }
+}
+
+// mwReadAccess is the claim's move read access as a fixture: the route and
+// launch it answers, and the held works it selects, counted.
+type mwReadAccess struct {
+ route registry.Route
+ launch ClaimServingLaunch
+ report moves.EnvReport
+ works []string
+}
+
+func (a *mwReadAccess) HeldWork(_ context.Context, _, action string, _ hostguard.Captured) (hostwrite.Work, error) {
+ a.works = append(a.works, action)
+ return plan9D5Work{}, nil
+}
+func (a *mwReadAccess) Route(_ context.Context, host string) (*registry.Route, error) {
+ if host != a.route.Host {
+ return nil, hostbox.ErrChanged
+ }
+ rt := a.route
+ return &rt, nil
+}
+func (a *mwReadAccess) Launch(_ context.Context, host string) (ClaimServingLaunch, moves.EnvReport, error) {
+ if host != a.route.Host {
+ return ClaimServingLaunch{}, moves.EnvReport{}, hostbox.ErrChanged
+ }
+ return a.launch, a.report, nil
+}
+
+// mwArm arms a moveCfg for a box whose placement is placement over the
+// claim's engine in engineRegion, with the fleet's home binding home.
+func mwArm(t *testing.T, view func(context.Context, hostclaim.Observed) (park.View, error), engineRegion, home, placement string) (moveCfg, error) {
+ t.Helper()
+ auth := func(context.Context, hostclaim.Observed, hostclaim.Obligation) error { return nil }
+ providers := ClaimProviders{moveParks: &claimMoveParks{
+ local: claimMoveLocalAuthorizer(func(context.Context) error { return nil }, auth),
+ engine: park.Engine{Bucket: "data", Region: engineRegion, HomeRegion: home, Leases: (&renewTable{}).leases(), View: view, Authorize: auth},
+ leases: hostbox.ReplicaParkLeaseSet, check: func(context.Context) error { return nil }}}
+ c := moveCfg{OwnBucket: "data", Region: placement}
+ err := armMovesParks(t.Context(), &c, providers)
+ return c, err
+}
+
+// TestBothFlagsOnRegistersAndArms (Q1, Q15): with CARLOS_HOST_CLAIM and
+// CARLOS_MOVES_PHASE1 both on, the agent's two registrations, the parks and
+// then the wake source, open the gate: the claim's registered move source
+// is this box's moves source, and the moves arms take the claim's engine. A
+// named box arms over its own engine. A home box arms only when the claim
+// grants it the @home alias (claim Plan 13's signed fleet home
+// designation); without it the home box stays unarmed. The parks alone do
+// not open the gate, and a second registration is refused.
+func TestBothFlagsOnRegistersAndArms(t *testing.T) {
+ withMovesWakeSource(t, false)
+ ctx := t.Context()
+ env := mwEnv(true, true)
+ hib := activator.Config{Bucket: "data", Region: "eu-west-1"}
+ store := release.NewDirStore(t.TempDir())
+ roster := func(context.Context) (string, error) { return "ie", nil }
+ parks, err := registerProductionMovesParks(ctx, env, store, hib, roster, newMovesRefusals("ie-box", nil, t.Logf, time.Now))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if parks.View == nil || parks.Evidence == nil || claimMoveSources.Parks == nil {
+ t.Fatalf("the parks are not registered: %+v", parks)
+ }
+ var dep *ClaimDependencyError
+ if err := movesWakeSourceRegistered(); !errors.As(err, &dep) {
+ t.Fatalf("the parks alone opened the gate: %v", err)
+ }
+ if c, err := mwArm(t, parks.View, "ie", "", "ie"); !errors.As(err, &dep) || parkHooksArmed(c) {
+ t.Fatalf("armed on the parks alone: %v", err)
+ }
+
+ if err := registerProductionMovesWakeSource(env, parks, t.TempDir()); err != nil {
+ t.Fatal(err)
+ }
+ if err := movesWakeSourceRegistered(); err != nil {
+ t.Fatalf("the gate is shut after the wake source registered: %v", err)
+ }
+ reg, ok := claimMoveSources.Source.(*movesWakeSourceRegistration)
+ if !ok {
+ t.Fatalf("the claim's move source is %T, not this box's moves source", claimMoveSources.Source)
+ }
+ if _, ok := reg.ClaimProductionMoveSources.(*movesWakeSources); !ok {
+ t.Fatalf("the registered source is %T", reg.ClaimProductionMoveSources)
+ }
+
+ // A named box arms over its own engine.
+ if c, err := mwArm(t, parks.View, "ie", "", "ie"); err != nil || !parkHooksArmed(c) || c.ParkRunHeld == nil || c.ParkLocalAuthorize == nil {
+ t.Fatalf("a named box did not arm: %v", err)
+ }
+ // A home box with the @home grant arms, and keeps the home binding.
+ c, err := mwArm(t, parks.View, "ie", "ie", "")
+ if err != nil || !parkHooksArmed(c) || c.ParkHomeRegion != "ie" {
+ t.Fatalf("a home box the claim grants @home did not arm: %v (%q)", err, c.ParkHomeRegion)
+ }
+ // Without the grant a home box stays unarmed, over its named engine or
+ // over one that names itself @home.
+ for _, engine := range []string{"ie", park.HomeRegion} {
+ if c, err := mwArm(t, parks.View, engine, "", ""); !errors.Is(err, hostbox.ErrChanged) || parkHooksArmed(c) {
+ t.Fatalf("a home box without the grant (engine %q) armed: %v", engine, err)
+ }
+ }
+
+ // One registration per process: a second is refused and the gate stays.
+ if err := registerProductionMovesWakeSource(env, parks, t.TempDir()); !errors.Is(err, hostwrite.ErrUnavailable) {
+ t.Fatalf("a second registration: %v", err)
+ }
+ if err := movesWakeSourceRegistered(); err != nil {
+ t.Fatalf("a refused second registration shut the gate: %v", err)
+ }
+}
+
+// TestEitherFlagOffRegistersNothing (ruling H1, Q1): with either switch
+// off, or both, neither registration does anything, the gate stays shut,
+// the moves arms stay unarmed, and the claim keeps its named refusals.
+func TestEitherFlagOffRegistersNothing(t *testing.T) {
+ hib := activator.Config{Bucket: "data", Region: "eu-west-1"}
+ roster := func(context.Context) (string, error) { return "ie", nil }
+ f := newVwFixture(t)
+ live := movesParkSources{View: f.view("ie"), Evidence: movesRetirementEvidence{Records: f.records(), Now: time.Now}}
+ for name, env := range map[string]func(string) string{
+ "claim off": mwEnv(false, true),
+ "phase 1 off": mwEnv(true, false),
+ "both off": mwEnv(false, false),
+ } {
+ t.Run(name, func(t *testing.T) {
+ withMovesWakeSource(t, false)
+ store := release.NewDirStore(t.TempDir())
+ parks, err := registerProductionMovesParks(t.Context(), env, store, hib, roster, newMovesRefusals("ie-box", nil, t.Logf, time.Now))
+ if err != nil || parks.View != nil || parks.Evidence != nil {
+ t.Fatalf("the parks: %+v %v", parks, err)
+ }
+ // Off, even a complete park source registers no wake source.
+ for _, p := range []movesParkSources{parks, live} {
+ if err := registerProductionMovesWakeSource(env, p, t.TempDir()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if claimMoveSources.Source != nil || claimMoveSources.Parks != nil {
+ t.Fatalf("registered: source %v parks %v", claimMoveSources.Source, claimMoveSources.Parks)
+ }
+ var dep *ClaimDependencyError
+ if err := movesWakeSourceRegistered(); !errors.As(err, &dep) || !strings.Contains(err.Error(), movesWakeSourceContract) {
+ t.Fatalf("the gate: %v", err)
+ }
+ if c, err := mwArm(t, live.View, "ie", "", "ie"); !errors.As(err, &dep) || parkHooksArmed(c) {
+ t.Fatalf("armed with a switch off: %v", err)
+ }
+ })
+ }
+}
+
+// TestTheClaimsParkViewIsTheMovesView (Q15): the wake source's View and
+// VerifyRetirement are never read by the claim, because the parks 9a
+// registered first stay in place; both delegate to the same park sources,
+// so whichever the claim reads, the answer is the moves view's (and the
+// moves evidence's). The claim's regional view, which every host's park
+// chores and the at-rest sweep read, is that view too.
+func TestTheClaimsParkViewIsTheMovesView(t *testing.T) {
+ withMovesWakeSource(t, false)
+ f := newVwFixture(t)
+ evidence := &countingEvidence{RegionalEvidence: movesRetirementEvidence{Records: f.records(), Now: time.Now}}
+ parks, err := newMovesParkSources(f.records(), f.bucket, "data", fixedRegion("ie"), evidence)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := registerMovesParks(parks); err != nil {
+ t.Fatal(err)
+ }
+ registered := claimMoveSources.Parks
+ if err := registerProductionMovesWakeSource(mwEnv(true, true), parks, t.TempDir()); err != nil {
+ t.Fatal(err)
+ }
+ if claimMoveSources.Parks != registered {
+ t.Fatal("the wake source replaced the parks 9a registered")
+ }
+ f.marker(t, "cur", f.inc)
+ obs := vwObs(&hostclaim.Claim{Holder: f.holder("cur", "cur@ie"), Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"}})
+ direct, err := movesParkView(f.records(), f.bucket, "data", "ie")(t.Context(), obs)
+ if err != nil || direct.AuthoritativeRegion != "ie" || !direct.MarkerKnown || direct.MarkerGeneration != "cur" {
+ t.Fatalf("the moves view: %+v %v", direct, err)
+ }
+ src := claimMoveSources.Source
+ for name, view := range map[string]func(context.Context, hostclaim.Observed) (park.View, error){
+ "the claim's park view": claimMoveSources.Parks.View,
+ "the wake source's View": src.View,
+ "the registered parks": parks.View,
+ "the claim's regional view": productionClaimRegional{Identity: handoverRecoveryIdentity(t, hostbox.Actor{Box: "box-1", Enrollment: "enrollment-1", Revision: 7, Region: "ie"}), Parks: claimMoveSources.Parks}.View,
+ } {
+ got, err := view(t.Context(), obs)
+ if err != nil {
+ t.Fatalf("%s: %v", name, err)
+ }
+ got.GenerationOf, direct.GenerationOf = nil, nil
+ got.ArchivedIncarnations, got.RefusedLocations = nil, nil
+ if !reflect.DeepEqual(got, direct) {
+ t.Fatalf("%s is not the moves view:\n got %+v\nwant %+v", name, got, direct)
+ }
+ }
+ retired := vwObs(&hostclaim.Claim{})
+ if _, _, err := src.VerifyRetirement(t.Context(), retired, nil); evidence.calls != 1 {
+ t.Fatalf("the wake source's VerifyRetirement did not delegate to the moves evidence: %d calls (%v)", evidence.calls, err)
+ }
+ if _, _, err := claimMoveSources.Parks.Evidence.VerifyRetirement(t.Context(), retired, nil); evidence.calls != 2 {
+ t.Fatalf("the claim's evidence is not the moves evidence: %d calls (%v)", evidence.calls, err)
+ }
+}
+
+// countingEvidence counts VerifyRetirement calls.
+type countingEvidence struct {
+ hostbox.RegionalEvidence
+ calls int
+}
+
+func (e *countingEvidence) VerifyRetirement(ctx context.Context, o hostclaim.Observed, v []lease.ObjectVersion) (string, time.Time, error) {
+ e.calls++
+ return e.RegionalEvidence.VerifyRetirement(ctx, o, v)
+}
+
+// TestRegisteredSourceOpensTheMovesOwnersWithTheClaimsFiles (Task 8's
+// controller overrides): the registered source's Open returns the moves
+// owner set the claim binds (the landing, committed and recovery owners,
+// with Operations, LossRecovery and LossSources), over the claim's read
+// access, the actor's bucket and region, and Task 7b's held release adapter;
+// it opens no files provider of its own. The C8 provider the recovery
+// owners establish through is the one Task 6's registration binds for the
+// claim's serving owners: the same value. A second provider is a wiring
+// error.
+func TestRegisteredSourceOpensTheMovesOwnersWithTheClaimsFiles(t *testing.T) {
+ withMovesWakeSource(t, false)
+ ctx := t.Context()
+ f := newVwFixture(t)
+ parks, err := newMovesParkSources(f.records(), f.bucket, "data", fixedRegion("ie"), movesRetirementEvidence{Records: f.records(), Now: time.Now})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := registerMovesParks(parks); err != nil {
+ t.Fatal(err)
+ }
+ env := mwEnv(true, true)
+ dataDir := t.TempDir()
+ if err := registerProductionMovesWakeSource(env, parks, dataDir); err != nil {
+ t.Fatal(err)
+ }
+ src := claimMoveSources.Source.(*movesWakeSourceRegistration).ClaimProductionMoveSources.(*movesWakeSources)
+ src.Getenv = env
+ src.markers = func(context.Context, string, string) (ClaimMarkerStore, error) { return plan9D5Markers{f.bucket}, nil }
+ actor := hostbox.Actor{Box: "ie-box", Region: "ie", Bucket: "data", Enrollment: "enr", Cloud: hostbox.CloudIdentity{Region: "eu-west-1"}}
+ store := release.NewDirStore(t.TempDir())
+ data := leasetest.NewVersionedBucket()
+ access := &mwReadAccess{route: registry.Route{Host: vwHost}}
+ out := ClaimOwnerBindings{Data: data}
+ // OpenClaimOwners hands Open its move read access; here the fixture's.
+ owners, files, err := claimMoveSources.Source.Open(ctx, store, data, actor, access)
+ if err != nil || files != nil {
+ t.Fatalf("open: files %v, %v", files, err)
+ }
+ if ok, err := claimMovesC1(env, owners); !ok || err != nil {
+ t.Fatalf("the owner set is not complete: %v %v", ok, err)
+ }
+ if owners.LossRecovery == nil || owners.LossSources == nil || owners.Operations == nil || owners.ProbeReady != nil {
+ t.Fatalf("owners %+v", owners)
+ }
+ if reflect.TypeOf(owners.LossRecovery) != reflect.TypeOf(hostops.MoveLossRecoveryOwner()) {
+ t.Fatalf("the loss owner is %T", owners.LossRecovery)
+ }
+ m, ok := owners.Inputs.(*movesProbeOwners)
+ if !ok {
+ t.Fatalf("the inputs are %T", owners.Inputs)
+ }
+ if !m.valid() || m.Box != "ie-box" || m.Region != "ie" || m.Bucket != "data" || m.Data != data || m.Claims != store || m.DataDir != movesDataDir(dataDir) || m.Files != nil || m.Release == nil || m.Page == nil {
+ t.Fatalf("the probe owners: %+v", m)
+ }
+ ops, ok := owners.Operations.(*movesProbeAttempts)
+ if !ok || ops.Claims != store || ops.DataDir != m.DataDir || ops.Admitted == nil || ops.RecoveryAttempt == nil {
+ t.Fatalf("the attempts: %#v", owners.Operations)
+ }
+ // The read access is the claim's: route, launch and held works.
+ if rt, err := m.Route(vwHost); err != nil || rt.Host != vwHost {
+ t.Fatalf("route %v %v", rt, err)
+ }
+ if _, err := m.Works(ctx, vwHost, movesActionHeldConfig, hostguard.Captured{}); err != nil || !reflect.DeepEqual(access.works, []string{movesActionHeldConfig}) {
+ t.Fatalf("held work %v %v", access.works, err)
+ }
+
+ // The claim's C8 provider binds the serving owners and the recovery
+ // owners alike: one value.
+ out.Moves = owners
+ provider := &movesFiles{}
+ closeFiles, err := openClaimFilesManifest(ctx, env, &fakeFilesSource{files: provider}, store, data, actor, &out)
+ if err != nil || closeFiles == nil {
+ t.Fatalf("files: %v", err)
+ }
+ if out.Serving.Files != ClaimFilesManifest(provider) || m.Files != ClaimFilesManifest(provider) {
+ t.Fatalf("the recovery owners' files %v, the serving owners' %v", m.Files, out.Serving.Files)
+ }
+ // Another provider for the same owners is refused, never swapped in.
+ again := ClaimOwnerBindings{Moves: owners}
+ if _, err := openClaimFilesManifest(ctx, env, &fakeFilesSource{files: &movesFiles{}}, store, data, actor, &again); !errors.Is(err, hostwrite.ErrUnavailable) || m.Files != ClaimFilesManifest(provider) {
+ t.Fatalf("a second provider: %v, files %v", err, m.Files)
+ }
+ // Without the read access, or the secrets key, Open refuses: the wake
+ // is then refused by name, never half-wired.
+ if _, _, err := src.Open(ctx, store, data, actor, nil); !errors.Is(err, hostwrite.ErrUnavailable) {
+ t.Fatalf("no read access: %v", err)
+ }
+ src.Getenv = mwEnv(true, true)
+ noKey := *src
+ noKey.Getenv = func(k string) string {
+ if k == "CARLOS_SECRETS_KEY" {
+ return ""
+ }
+ return env(k)
+ }
+ if _, _, err := noKey.Open(ctx, store, data, actor, access); err == nil {
+ t.Fatal("opened without the env report key")
+ }
+ if _, _, err := src.Open(ctx, store, data, hostbox.Actor{Box: "ie-box"}, access); !errors.Is(err, hostwrite.ErrUnavailable) {
+ t.Fatalf("a bare actor: %v", err)
+ }
+}