| 1 | package host |
| 2 | |
| 3 | // The moves wake source (moves Plan 5 Task 9b; the rulings after claim Plan |
| 4 | // 12: Q1, Q12, Q15, and the controller's Task 8 overrides). It is this box's |
| 5 | // ClaimProductionMoveSources: registered with the claim at agent start, |
| 6 | // right after the park providers (registerProductionMovesParks) and before |
| 7 | // newClaimWiring, and only with both CARLOS_HOST_CLAIM and |
| 8 | // CARLOS_MOVES_PHASE1 on. Both default to off, so nothing here turns |
| 9 | // anything on. |
| 10 | // |
| 11 | // Registering it (registerMovesWakeSource) is what opens the one gate |
| 12 | // between the moves parks and production (movesWakeSourceRegistered), so |
| 13 | // the parks arm in the same agent start as the wake owners, never before. |
| 14 | // With the gate open, productionClaimRegional.View, the claim's park view |
| 15 | // for every host on the box, reads through the moves view: the claim's |
| 16 | // at-rest sweep and every regional park chore run through it for every |
| 17 | // host, not only moved ones (Q1's widened sweep; the rehearsal's evidence |
| 18 | // list names it). |
| 19 | // |
| 20 | // - Open builds the moves owner set the claim binds under moves C1 |
| 21 | // (claimMovesC1): one movesProbeOwners, which serves the landing probe, |
| 22 | // the committed landing wake and, dispatched by kind, the recovery |
| 23 | // owners (restoring and reverting), with the moves attempt journal as |
| 24 | // Operations, the moves loss owner as LossRecovery and the loss |
| 25 | // baseline validator as LossSources. Its route, launch and held works |
| 26 | // are the claim's move read access; its held release is Task 7b's root |
| 27 | // adapter (heldReleaseProbeRelease). Open binds no files manifest |
| 28 | // provider: the recovery owners take the claim's C8 provider, the one |
| 29 | // Task 6's RegisterProductionClaimFiles binds for the serving owners |
| 30 | // (openClaimFilesManifest, bindClaimMovesFiles). Two providers are a |
| 31 | // wiring error. |
| 32 | // - View and VerifyRetirement are never read by the claim (Q15): the park |
| 33 | // sources registered first stay, so both delegate to those same values. |
| 34 | // |
| 35 | // Production arming still needs, beyond this registration: |
| 36 | // |
| 37 | // - both switches on (CARLOS_HOST_CLAIM, CARLOS_MOVES_PHASE1); |
| 38 | // - the claim floor (hostfloor: the guard-ready running floor); |
| 39 | // - the switch default: moves.DefaultEnabled stays false while copy rows |
| 40 | // are pending (TestPendingCopyKeepsTheSwitchOff); |
| 41 | // - on a home box, claim Plan 13's signed fleet home designation, so the |
| 42 | // claim grants the @home alias (armMovesParks refuses otherwise); |
| 43 | // - claim activation's #250 checklist: the files manifest backfill for |
| 44 | // hosts that predate the files publisher, CARLOS_RELEASE_PUBKEY for |
| 45 | // instance-control's held release route, claim Plan 14's root |
| 46 | // authority, and claim Plan 15's provenance record for hosts past the |
| 47 | // admission pruning window. |
| 48 | |
| 49 | import ( |
| 50 | "context" |
| 51 | "crypto/ecdh" |
| 52 | "crypto/rand" |
| 53 | "encoding/hex" |
| 54 | "errors" |
| 55 | "fmt" |
| 56 | "strings" |
| 57 | "time" |
| 58 | |
| 59 | "github.com/carlosframework/platform/internal/appconfig" |
| 60 | "github.com/carlosframework/platform/internal/controlsock" |
| 61 | "github.com/carlosframework/platform/internal/hostbox" |
| 62 | "github.com/carlosframework/platform/internal/hostclaim" |
| 63 | "github.com/carlosframework/platform/internal/hostwrite" |
| 64 | "github.com/carlosframework/platform/internal/lease" |
| 65 | "github.com/carlosframework/platform/internal/moves" |
| 66 | "github.com/carlosframework/platform/internal/park" |
| 67 | "github.com/carlosframework/platform/internal/provision" |
| 68 | "github.com/carlosframework/platform/internal/registry" |
| 69 | "github.com/carlosframework/platform/internal/release" |
| 70 | ) |
| 71 | |
| 72 | // movesWakeSources is this box's moves wake source (see the file comment). |
| 73 | type movesWakeSources struct { |
| 74 | // Parks are the park sources registered before it: View and |
| 75 | // VerifyRetirement delegate to them (Q15). |
| 76 | Parks movesParkSources |
| 77 | // DataDir is the moves data directory: the op journals' root and the |
| 78 | // hosts' files directories, the moves worker's (movesDataDir). |
| 79 | DataDir string |
| 80 | // OptDir is /opt/carlos, where root publishes the held release. |
| 81 | OptDir string |
| 82 | // Region is this box's CARLOS_REGION, the held release request's region. |
| 83 | Region string |
| 84 | // Getenv reads CARLOS_SECRETS_KEY when the source opens. |
| 85 | Getenv func(string) string |
| 86 | |
| 87 | // Seams, set by newProductionMovesWakeSources. |
| 88 | records func(release.Store) MovesRecordReader |
| 89 | markers func(ctx context.Context, bucket, region string) (ClaimMarkerStore, error) |
| 90 | relay heldReleaseRelay |
| 91 | now func() time.Time |
| 92 | newID func() string |
| 93 | } |
| 94 | |
| 95 | var _ ClaimProductionMoveSources = (*movesWakeSources)(nil) |
| 96 | |
| 97 | // newProductionMovesWakeSources is the production source over parks and |
| 98 | // the box's data directory. |
| 99 | func newProductionMovesWakeSources(getenv func(string) string, parks movesParkSources, dataDir string) *movesWakeSources { |
| 100 | return &movesWakeSources{Parks: parks, DataDir: movesDataDir(dataDir), OptDir: release.OptDir(), Region: strings.TrimSpace(getenv("CARLOS_REGION")), Getenv: getenv, |
| 101 | records: MovesRecordsFrom, markers: newProductionClaimMarkers, relay: newHeldReleaseRootClient(controlsock.Path), now: time.Now, newID: movesWakeID} |
| 102 | } |
| 103 | |
| 104 | // movesWakeID is a fresh generation name, as the claim's own. |
| 105 | func movesWakeID() string { |
| 106 | var b [16]byte |
| 107 | if _, err := rand.Read(b[:]); err != nil { |
| 108 | return "" |
| 109 | } |
| 110 | return hex.EncodeToString(b[:]) |
| 111 | } |
| 112 | |
| 113 | // Open builds the moves owner set for the verified actor, over the claim's |
| 114 | // store, its versioned data bucket and its move read access (see the file |
| 115 | // comment). Anything missing refuses: the claim then refuses every wake by |
| 116 | // name rather than half-wiring moves. |
| 117 | func (s *movesWakeSources) Open(ctx context.Context, store release.Store, data lease.VersionedStore, actor hostbox.Actor, read ClaimMoveReadAccess) (ClaimMovesOwners, ClaimFilesManifest, error) { |
| 118 | region := park.PlacementRegion(actor.Region) |
| 119 | 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 || |
| 120 | s.DataDir == "" || s.OptDir == "" || actor.Box == "" || actor.Bucket == "" || region == "" || actor.Cloud.Region == "" { |
| 121 | return ClaimMovesOwners{}, nil, hostwrite.ErrUnavailable |
| 122 | } |
| 123 | if a, ok := read.(*claimMoveReadAccess); ok && a == nil { |
| 124 | return ClaimMovesOwners{}, nil, hostwrite.ErrUnavailable |
| 125 | } |
| 126 | secrets := s.Getenv("CARLOS_SECRETS_KEY") |
| 127 | envKey, err := moves.ReportKeyFromSecrets(secrets) |
| 128 | if err != nil { |
| 129 | return ClaimMovesOwners{}, nil, err |
| 130 | } |
| 131 | priv, err := appconfig.ParsePrivateKey(secrets, "CARLOS_SECRETS_KEY") |
| 132 | if err != nil { |
| 133 | return ClaimMovesOwners{}, nil, err |
| 134 | } |
| 135 | markers, err := s.markers(ctx, actor.Bucket, actor.Cloud.Region) |
| 136 | if err != nil { |
| 137 | return ClaimMovesOwners{}, nil, err |
| 138 | } |
| 139 | records := s.records(store) |
| 140 | via := heldReleaseVia{Relay: s.relay, Region: s.Region} |
| 141 | owners := &movesProbeOwners{HomeRegion: actor.HomeRegion, FleetHomeRegion: actor.FleetHomeRegion, Claims: store, Records: records, |
| 142 | ReadyPath: movesReadyPath(store), Data: data, Bucket: actor.Bucket, Region: region, Box: actor.Box, |
| 143 | DataDir: s.DataDir, FilesDir: movesFilesDirFor(s.DataDir), Markers: markers, |
| 144 | Route: movesReadRoute(read), |
| 145 | Launch: movesReadLaunch(read), |
| 146 | EnvKey: envKey, Admitted: MovesRecordAdmittedAttempts, |
| 147 | Works: read.HeldWork, |
| 148 | Render: movesProbeRender(store, priv, s.DataDir, actor.Box), |
| 149 | Release: heldReleaseProbeRelease(via, store, actor.Box, s.OptDir, s.DataDir, release.NewRootProofGate(release.ProofPolicyPath, s.DataDir)), |
| 150 | NewID: s.newID, Now: s.now, Page: PageOperator} |
| 151 | if !owners.valid() { |
| 152 | return ClaimMovesOwners{}, nil, hostwrite.ErrUnavailable |
| 153 | } |
| 154 | attempts := &movesProbeAttempts{Claims: store, Records: records, DataDir: s.DataDir, Admitted: MovesRecordAdmittedAttempts, NewID: s.newID, Now: s.now, |
| 155 | RecoveryAttempt: movesWorkerRecoveryAttempt(store)} |
| 156 | return owners.MovesOwners(attempts), nil, nil |
| 157 | } |
| 158 | |
| 159 | // View is the park sources' view (Q15): the claim reads the parks |
| 160 | // registered first, so this is the same view. |
| 161 | func (s *movesWakeSources) View(ctx context.Context, o hostclaim.Observed) (park.View, error) { |
| 162 | if s == nil || s.Parks.View == nil { |
| 163 | return park.View{}, hostwrite.ErrUnavailable |
| 164 | } |
| 165 | return s.Parks.View(ctx, o) |
| 166 | } |
| 167 | |
| 168 | // VerifyRetirement is the park sources' evidence (Q15). |
| 169 | func (s *movesWakeSources) VerifyRetirement(ctx context.Context, o hostclaim.Observed, v []lease.ObjectVersion) (string, time.Time, error) { |
| 170 | if s == nil || s.Parks.Evidence == nil { |
| 171 | return "", time.Time{}, hostwrite.ErrUnavailable |
| 172 | } |
| 173 | return s.Parks.Evidence.VerifyRetirement(ctx, o, v) |
| 174 | } |
| 175 | |
| 176 | // Close closes nothing: Open holds no resource of its own. |
| 177 | func (s *movesWakeSources) Close(context.Context) error { return nil } |
| 178 | |
| 179 | // movesReadyPath is the app manifest's moves.ready_path, "/" when the |
| 180 | // manifest declares none; a manifest that cannot be read refuses (S1's rule, |
| 181 | // probeSource). |
| 182 | func movesReadyPath(store release.Store) func(ctx context.Context, account, app string) (string, error) { |
| 183 | return func(ctx context.Context, account, app string) (string, error) { |
| 184 | m, err := provision.GetManifest(ctx, store, account, app) |
| 185 | if err != nil { |
| 186 | return "", err |
| 187 | } |
| 188 | return m.ReadyPathOrDefault(), nil |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | // movesReadRoute is the claim's move read access as the owners' route: |
| 193 | // each read is bounded by the claim's request limit. |
| 194 | func movesReadRoute(read ClaimMoveReadAccess) func(host string) (*registry.Route, error) { |
| 195 | return func(host string) (*registry.Route, error) { |
| 196 | ctx, cancel := context.WithTimeout(context.Background(), hostbox.RequestLimit) |
| 197 | defer cancel() |
| 198 | return read.Route(ctx, host) |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | // movesReadLaunch is the claim's move read access as the owners' launch, |
| 203 | // with the access's env report: made from the same composition as the |
| 204 | // launch's env (claimMoveLaunchSource), its digest is the launch's |
| 205 | // move-portable pin, which the owners check (fix round 4). |
| 206 | func movesReadLaunch(read ClaimMoveReadAccess) func(context.Context, registry.Route) (ClaimServingLaunch, moves.EnvReport, error) { |
| 207 | return func(ctx context.Context, rt registry.Route) (ClaimServingLaunch, moves.EnvReport, error) { |
| 208 | return read.Launch(ctx, rt.Host) |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | // movesProbeRender is the held render under the claim for the probe and |
| 213 | // recovery owners (appconfig.RenderHeldUnder, the held render authority). |
| 214 | 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) { |
| 215 | return func(ctx context.Context, rt registry.Route, op string, work func(appconfig.HeldRender) (hostwrite.Work, error)) (appconfig.HeldRender, error) { |
| 216 | return appconfig.RenderHeldUnder(ctx, store, priv, dataDir, rt, op, box, work) |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | // movesWorkerRecoveryAttempt is the attempt journal's RecoveryAttempt: the |
| 221 | // armed moves worker's move log hook (recoveryAttemptLogger). The worker is |
| 222 | // armed after the claim wiring opens this source, so it is looked up when a |
| 223 | // start is counted; with none armed nothing is logged. |
| 224 | func movesWorkerRecoveryAttempt(store release.Store) func(ctx context.Context, rec provision.Record, epoch uint64, n int) { |
| 225 | return func(ctx context.Context, rec provision.Record, epoch uint64, n int) { |
| 226 | if w := armedMovesWorker.Load(); w != nil { |
| 227 | w.recoveryAttemptLogger(store)(ctx, rec, epoch, n) |
| 228 | } |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | // registerProductionMovesWakeSource is the agent's registration, right |
| 233 | // after the parks (registerProductionMovesParks) and before |
| 234 | // newClaimWiring: only with both CARLOS_HOST_CLAIM and CARLOS_MOVES_PHASE1 |
| 235 | // on, over the park sources just registered. Off, it registers nothing and |
| 236 | // the gate stays shut. A registration that succeeds opens the gate. |
| 237 | func registerProductionMovesWakeSource(getenv func(string) string, parks movesParkSources, dataDir string) error { |
| 238 | if getenv == nil || !hostwrite.Enabled(getenv) || !moves.Enabled(getenv) { |
| 239 | return nil |
| 240 | } |
| 241 | if parks.View == nil || parks.Evidence == nil || dataDir == "" { |
| 242 | return errors.Join(hostwrite.ErrUnavailable, errors.New("the moves wake source needs the registered park sources and the data directory")) |
| 243 | } |
| 244 | return registerMovesWakeSource(newProductionMovesWakeSources(getenv, parks, dataDir)) |
| 245 | } |
| 246 | |
| 247 | // claimMovesFilesBinder is the moves owner set's C8 binding: the claim's |
| 248 | // files manifest provider, the one the serving owners use. |
| 249 | type claimMovesFilesBinder interface { |
| 250 | bindClaimFiles(ClaimFilesManifest) error |
| 251 | } |
| 252 | |
| 253 | // bindClaimMovesFiles hands the claim's C8 provider (out.Serving.Files, |
| 254 | // bound by openClaimFilesManifest from Task 6's registration) to the moves |
| 255 | // owners, so the recovery owners establish through the same provider. A |
| 256 | // moves owner set already bound to another provider is a wiring error. |
| 257 | func bindClaimMovesFiles(out *ClaimOwnerBindings) error { |
| 258 | if out == nil || out.Serving.Files == nil { |
| 259 | return nil |
| 260 | } |
| 261 | b, ok := out.Moves.Inputs.(claimMovesFilesBinder) |
| 262 | if !ok { |
| 263 | return nil |
| 264 | } |
| 265 | return b.bindClaimFiles(out.Serving.Files) |
| 266 | } |
| 267 | |
| 268 | // bindClaimFiles binds the claim's C8 provider once; the same provider |
| 269 | // again is a no-op, another one is refused. |
| 270 | func (m *movesProbeOwners) bindClaimFiles(files ClaimFilesManifest) error { |
| 271 | if m == nil || files == nil { |
| 272 | return hostwrite.ErrUnavailable |
| 273 | } |
| 274 | m.mu.Lock() |
| 275 | defer m.mu.Unlock() |
| 276 | if m.Files != nil && m.Files != files { |
| 277 | return errors.Join(hostwrite.ErrUnavailable, fmt.Errorf("host claim: two C8 files manifest providers: the moves owners hold %T", m.Files)) |
| 278 | } |
| 279 | m.Files = files |
| 280 | return nil |
| 281 | } |
| 282 | |