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.NewRootProofGate(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, // with the access's env report: made from the same composition as the // launch's env (claimMoveLaunchSource), its digest is the launch's // move-portable pin, which the owners check (fix round 4). func movesReadLaunch(read ClaimMoveReadAccess) func(context.Context, registry.Route) (ClaimServingLaunch, moves.EnvReport, error) { return func(ctx context.Context, rt registry.Route) (ClaimServingLaunch, moves.EnvReport, error) { return read.Launch(ctx, rt.Host) } } // 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 }