package hostbox import ( "context" "encoding/json" "time" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostwrite" "github.com/carlosframework/platform/internal/provision" ) // WakePhase is the K6 execution table. Participant membership alone never starts code. func WakePhase(c *hostclaim.Claim, box string, probe bool) error { if c == nil || c.Holder == nil || box == "" || c.Op.Exec != box { return ErrChanged } for _, lost := range c.Lost { if lost == box { return ErrChanged } } switch c.Op.Kind { case hostclaim.KindServing: if !probe && c.Op.ID == "" && c.Op.Phase == "" { return nil } case hostclaim.KindLanding: if c.Op.ID != "" && ((!probe && c.Op.Phase == "committed") || (probe && c.Op.Phase == "probing")) { return nil } case hostclaim.KindReverting, hostclaim.KindRestoring: if c.Op.ID != "" && ((!probe && (c.Op.Phase == "probed" || c.Op.Phase == "opened")) || (probe && c.Op.Phase == "probing")) { return nil } case hostclaim.KindRelocating: // Claim Plan 11 R1 as accepted: op.exec's probe in probing, never // while stuck. No ordinary relocating wake is admitted, the // source's in starting and releasing included. if probe && c.Op.ID != "" && c.Op.Phase == hostclaim.PhaseRelocatingProbing && c.Op.Journal["stuck"] == "" { return nil } } return ErrChanged } // Policy separates ordinary writes from the moves-owned held actions. // Moves validates roles, held inputs and phase-specific rights with the same capture. // Relocation is region HA's typed owner for a relocating claim (claim Plan // 11 R1, R8). It is consulted only after the claim's own relocating gate has // passed, so it can refuse and never grant; nil refuses every relocating // action. refusals are the append-only checks WithRefusals adds. type Policy struct { Inputs func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error Moves func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error Relocation func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error Held func(context.Context, string) (string, error) refusals []PolicyRefusal } // PolicyRefusal is one append-only policy check (claim Plan 11, R8 as // accepted). It runs only after every base check of Policy.Check passed, so // returning nil changes nothing and an error refuses the selection. type PolicyRefusal func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error // WithRefusals returns a copy of p that also runs refusals, after p's own // checks and its earlier refusals. p is unchanged, and no refusal can be // removed or replaced: the list is unexported and only ever copied. func (p Policy) WithRefusals(refusals ...PolicyRefusal) Policy { p.refusals = append(append([]PolicyRefusal(nil), p.refusals...), refusals...) return p } func (p Policy) Check(ctx context.Context, c *hostclaim.Claim, s Selection, id hostwrite.Identity) error { if err := p.check(ctx, c, s, id); err != nil { return err } for _, refuse := range p.refusals { if refuse == nil { return ErrUnsupported } copy, err := detachedPolicyClaim(c) if err != nil { return err } if err := refuse(ctx, copy, s, id); err != nil { return err } } return nil } func (p Policy) check(ctx context.Context, c *hostclaim.Claim, s Selection, id hostwrite.Identity) error { if c == nil || p.Held == nil { return ErrUnsupported } hold, err := p.Held(ctx, s.Host) if err != nil { return err } if c.Op.Kind == hostclaim.KindServing { if id.Config != "" || id.Release != "" { if p.Inputs == nil { return ErrUnsupported } if err := p.Inputs(ctx, c, s, id); err != nil { return err } } if hold != "" { return ErrChanged } return Serving(c, s) } if c.Op.Kind == hostclaim.KindRelocating { if err := relocationProbe(c, s, hold); err != nil { return err } if p.Relocation == nil { return ErrChanged } copy, err := detachedPolicyClaim(c) if err != nil { return err } return p.Relocation(ctx, copy, s, id) } if p.Moves == nil || s.Op == "" || c.Op.ID != s.Op || c.Op.Exec != s.Box { return ErrChanged } owner, owned := movesHoldOwner(c, s) switch s.Action { case "held-config", "held-release", "held-timers": if !owned || hold != owner { return ErrChanged } case "restore", "spawn", "seed", "writer", "install": if hold != "" { if !owned || hold != owner { return ErrChanged } } else if err := WakePhase(c, s.Box, false); err != nil { return err } default: return ErrChanged } return p.Moves(ctx, c, s, id) } // movesHoldOwner is the op a moves selection's registry hold must name, and // false when no hold can be the selection's. A landing, a moving op and a // revert hold the row by their op id, the selection's. A rollback keeps the // move id as the claim's op id (hostclaim.RollBack) and hands the row to the // rollback id (ruling J3), so a restoring op in the source's recovery phases // expects the rollback id. That id comes only from the claim's // intent.rollback, as RollBack recorded it, never from the selection, the // request or a registry field, and it must be a valid rollback id. A // restoring op without one, or outside those phases, matches no hold and // never falls back to the selection's op (Plan 4 Task 3b fix round 1). func movesHoldOwner(c *hostclaim.Claim, s Selection) (string, bool) { if c.Op.Kind != hostclaim.KindRestoring { return s.Op, true } rb := c.Op.Intent["rollback"] if !restoringHoldPhases[c.Op.Phase] || !provision.ValidRollbackID(rb) { return "", false } return rb, true } // restoringHoldPhases are the source's recovery phases of a rollback // (hostclaim.RecoveryPhaseOrder(KindRestoring) from claimed on): the source // executes, and its registry row is held by the rollback id. var restoringHoldPhases = map[string]bool{ hostclaim.PhaseClaimed: true, hostclaim.PhaseProbing: true, hostclaim.PhaseProbed: true, hostclaim.PhaseOpened: true, } // relocationProbe is the claim's own gate for a relocating claim (claim Plan // 11 R1). The only relocating action is "probe", op.exec's probe spawn, // selected as its own action so an ordinary spawn never gets probe // authority. It is admitted in probing (WakePhase with probe set), and the // same selection keeps working once the passed probe's session serves in the // op's serving phase: selected at probing, the holder's writer box is this // box, the session is the selected one. A registry hold must be absent or // this op's own. func relocationProbe(c *hostclaim.Claim, s Selection, hold string) error { if c.Holder == nil || s.Action != "probe" || s.Op == "" || c.Op.ID != s.Op || c.Op.Exec != s.Box || (hold != "" && hold != s.Op) { return ErrChanged } if c.Op.Phase == hostclaim.PhaseRelocatingServing { if s.Phase != hostclaim.PhaseRelocatingProbing || c.Holder.WriterBox != s.Box || c.Holder.Session != s.Session || c.Op.Journal["stuck"] != "" { return ErrChanged } for _, lost := range c.Lost { if lost == s.Box { return ErrChanged } } return nil } return WakePhase(c, s.Box, true) } const RequestLimit = 120 * time.Second // Each optional check receives its own observation. A callback cannot mutate // authority already checked by the base or change a later refusal's inputs. func detachedPolicyClaim(c *hostclaim.Claim) (*hostclaim.Claim, error) { raw, err := json.Marshal(c) if err != nil { return nil, ErrChanged } var copy hostclaim.Claim if err = json.Unmarshal(raw, ©); err != nil { return nil, ErrChanged } return ©, nil }