package host import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "os" "sort" "sync" "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/hostwrite" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/registry" ) // ClaimMoveReadAccess is retained by Open and becomes usable after Build. // Selection reads authority; effects still require the returned Work.With. type ClaimMoveReadAccess interface { HeldWork(context.Context, string, string, hostguard.Captured) (hostwrite.Work, error) Route(context.Context, string) (*registry.Route, error) Launch(context.Context, string) (ClaimServingLaunch, moves.EnvReport, error) } type claimMoveReadAccess struct { mu sync.RWMutex bound bool check func(context.Context) error actor hostbox.Actor source hostbox.WorkSource route func(string) (*registry.Route, error) launch func(context.Context, registry.Route) (ClaimServingLaunch, moves.EnvReport, error) } func (a *claimMoveReadAccess) bind(ctx context.Context, source hostbox.WorkSource) error { if a == nil { return hostwrite.ErrUnavailable } a.mu.Lock() defer a.mu.Unlock() if a.bound || a.check == nil || a.route == nil || a.launch == nil || source.Box != a.actor.Box || source.Region != a.actor.Region || source.Guard.Claims == nil { return hostwrite.ErrUnavailable } if err := a.check(ctx); err != nil { return err } // SelectAt calls Prepare. Clearing it prevents participation writes while // retaining the same guard, policy, local checks and continuation table. source.Prepare = nil a.source, a.bound = source, true return nil } func (a *claimMoveReadAccess) selected(ctx context.Context, host string) (hostbox.WorkSource, error) { if a == nil || ctx == nil { return hostbox.WorkSource{}, hostwrite.ErrUnavailable } a.mu.RLock() source, bound := a.source, a.bound a.mu.RUnlock() if !bound { return hostbox.WorkSource{}, hostwrite.ErrUnavailable } if err := a.check(ctx); err != nil { return hostbox.WorkSource{}, err } o, err := source.Guard.Claims.Read(ctx, host) if err != nil { return hostbox.WorkSource{}, err } if o.Absent || o.Host != host || o.Claim == nil || o.Claim.Host != host || o.Claim.Holder == nil || o.Claim.Holder.Incarnation == "" || o.Claim.Op.Exec != a.actor.Box || !claimMovesKind(o.Claim.Op.Kind) { return hostbox.WorkSource{}, hostbox.ErrChanged } for _, lost := range o.Claim.Lost { if lost == a.actor.Box { return hostbox.WorkSource{}, hostbox.ErrChanged } } return source, nil } func (a *claimMoveReadAccess) Route(ctx context.Context, host string) (*registry.Route, error) { if _, err := a.selected(ctx, host); err != nil { return nil, err } rt, err := a.route(host) if err != nil { return nil, err } if rt == nil || rt.Host != host { return nil, hostbox.ErrChanged } // Deep copy future reference fields as well as today's scalar fields. raw, err := json.Marshal(rt) if err != nil { return nil, err } var out registry.Route if err := json.Unmarshal(raw, &out); err != nil { return nil, err } return &out, nil } func (a *claimMoveReadAccess) HeldWork(ctx context.Context, host, action string, captured hostguard.Captured) (hostwrite.Work, error) { switch action { case movesActionHeldConfig, movesActionHeldRelease: default: return nil, hostbox.ErrUnsupported } source, err := a.selected(ctx, host) if err != nil { return nil, err } work, err := source.SelectAt(ctx, host, action, captured) if err != nil { return nil, err } return claimMoveReadWork{access: a, host: host, work: work}, nil } type claimMoveReadWork struct { access *claimMoveReadAccess host string work hostwrite.Work } func (w claimMoveReadWork) With(ctx context.Context, id hostwrite.Identity, fn func() error) error { if _, err := w.access.selected(ctx, w.host); err != nil { return err } return w.work.With(ctx, id, fn) } func (a *claimMoveReadAccess) Launch(ctx context.Context, host string) (ClaimServingLaunch, moves.EnvReport, error) { rt, err := a.Route(ctx, host) if err != nil { return ClaimServingLaunch{}, moves.EnvReport{}, err } launch, report, err := a.launch(ctx, *rt) if err != nil { return ClaimServingLaunch{}, moves.EnvReport{}, err } launch.Args = append([]string(nil), launch.Args...) launch.Env = append([]string(nil), launch.Env...) vars := make(map[string]string, len(report.Vars)) for k, v := range report.Vars { vars[k] = v } report.Vars = vars return launch, report, nil } func claimMoveLaunchSource(backend *activator.ExecBackend) func(context.Context, registry.Route) (ClaimServingLaunch, moves.EnvReport, error) { return func(ctx context.Context, rt registry.Route) (ClaimServingLaunch, moves.EnvReport, error) { // The selection refuses a missing token, and gates a replica. This // path must never mint one. snapshot, err := backend.SelectClaimLaunch(ctx, rt) if err != nil { return ClaimServingLaunch{}, moves.EnvReport{}, err } // A declined box's launch gate, as for a claims-backed wake (Task 9 // fix round 1, ruling 4c): a moves launch execs the snapshot's bytes // too. Nil, and nothing read, on every other box. if err := declinedClaimGate(backend.OptDir, rt, snapshot); err != nil { return ClaimServingLaunch{}, moves.EnvReport{}, err } env, supplied, _, err := backend.LaunchLayers(rt) if err != nil { return ClaimServingLaunch{}, moves.EnvReport{}, err } key, err := moves.ReportKeyFromSecrets(os.Getenv("CARLOS_SECRETS_KEY")) if err != nil { return ClaimServingLaunch{}, moves.EnvReport{}, err } delete(env, "CARLOS_MOVE_PROBE") entries := make([]string, 0, len(env)) for k, v := range env { entries = append(entries, k+"="+v) } sort.Strings(entries) raw, err := json.Marshal(entries) if err != nil { return ClaimServingLaunch{}, moves.EnvReport{}, err } digest := sha256.Sum256(raw) snapshot.Identity.Config = hex.EncodeToString(digest[:]) // The report is made from this same composition, so its pin and the // launch's env can never come from different reads (fix round 4). return ClaimServingLaunch{Identity: snapshot.Identity, Executable: snapshot.Executable, ExecutableSHA256: snapshot.SHA256, Args: snapshot.Args, Env: entries, Version: snapshot.Version, Staged: snapshot.Staged, Limit: snapshot.Limit}, moves.ReportLaunch(key, env, supplied), nil } } // ClaimInputPolicyOwner is implemented by (*movesProbeOwners).InputPolicy. // The moves owner chooses each kind's record pins; serving has no fallback. type ClaimInputPolicyOwner interface { InputPolicy(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error } func (d ClaimOwnerDispatch) InputPolicy(ctx context.Context, c *hostclaim.Claim, s hostbox.Selection, id hostwrite.Identity) error { if c == nil || c.Holder == nil || s.Host != c.Host || s.Kind != c.Op.Kind || s.Captured.Incarnation != c.Holder.Incarnation || s.Captured.Stamp != c.Holder.Stamp { return hostbox.ErrChanged } switch { case c.Op.Kind == hostclaim.KindServing: if d.Serving.InputPolicy == nil { return hostwrite.ErrUnavailable } return d.Serving.InputPolicy(ctx, c, s, id) case claimMovesKind(c.Op.Kind): if d.Moves == nil { return claimOperationRefusal() } owner, ok := d.Moves.Inputs.(ClaimInputPolicyOwner) if !ok { return claimOperationRefusal() } return owner.InputPolicy(ctx, c, s, id) default: return hostbox.ErrUnsupported } } func bindProductionClaimInputPolicy(getenv func(string) string, out *ClaimOwnerBindings, read MovesRecordReader) { if !hostwrite.Enabled(getenv) || !moves.Enabled(getenv) { return } dispatch := ClaimOwnerDispatch{Serving: ClaimKindOwners{InputPolicy: out.InputPolicy}, Moves: &out.Moves.ClaimKindOwners} out.InputPolicy = dispatch.InputPolicy // Policy.Check dispatches move actions to Moves, never Inputs directly. out.MovesPolicy = MovesClaimPolicyWithInputs(read, dispatch.InputPolicy) }