| 1 | package hostbox |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "time" |
| 7 | |
| 8 | "github.com/carlosframework/platform/internal/hostclaim" |
| 9 | "github.com/carlosframework/platform/internal/hostwrite" |
| 10 | "github.com/carlosframework/platform/internal/provision" |
| 11 | ) |
| 12 | |
| 13 | // WakePhase is the K6 execution table. Participant membership alone never starts code. |
| 14 | func WakePhase(c *hostclaim.Claim, box string, probe bool) error { |
| 15 | if c == nil || c.Holder == nil || box == "" || c.Op.Exec != box { |
| 16 | return ErrChanged |
| 17 | } |
| 18 | for _, lost := range c.Lost { |
| 19 | if lost == box { |
| 20 | return ErrChanged |
| 21 | } |
| 22 | } |
| 23 | switch c.Op.Kind { |
| 24 | case hostclaim.KindServing: |
| 25 | if !probe && c.Op.ID == "" && c.Op.Phase == "" { |
| 26 | return nil |
| 27 | } |
| 28 | case hostclaim.KindLanding: |
| 29 | if c.Op.ID != "" && ((!probe && c.Op.Phase == "committed") || (probe && c.Op.Phase == "probing")) { |
| 30 | return nil |
| 31 | } |
| 32 | case hostclaim.KindReverting, hostclaim.KindRestoring: |
| 33 | if c.Op.ID != "" && ((!probe && (c.Op.Phase == "probed" || c.Op.Phase == "opened")) || (probe && c.Op.Phase == "probing")) { |
| 34 | return nil |
| 35 | } |
| 36 | case hostclaim.KindRelocating: |
| 37 | // Claim Plan 11 R1 as accepted: op.exec's probe in probing, never |
| 38 | // while stuck. No ordinary relocating wake is admitted, the |
| 39 | // source's in starting and releasing included. |
| 40 | if probe && c.Op.ID != "" && c.Op.Phase == hostclaim.PhaseRelocatingProbing && c.Op.Journal["stuck"] == "" { |
| 41 | return nil |
| 42 | } |
| 43 | } |
| 44 | return ErrChanged |
| 45 | } |
| 46 | |
| 47 | // Policy separates ordinary writes from the moves-owned held actions. |
| 48 | // Moves validates roles, held inputs and phase-specific rights with the same capture. |
| 49 | // Relocation is region HA's typed owner for a relocating claim (claim Plan |
| 50 | // 11 R1, R8). It is consulted only after the claim's own relocating gate has |
| 51 | // passed, so it can refuse and never grant; nil refuses every relocating |
| 52 | // action. refusals are the append-only checks WithRefusals adds. |
| 53 | type Policy struct { |
| 54 | Inputs func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error |
| 55 | Moves func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error |
| 56 | Relocation func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error |
| 57 | Held func(context.Context, string) (string, error) |
| 58 | refusals []PolicyRefusal |
| 59 | } |
| 60 | |
| 61 | // PolicyRefusal is one append-only policy check (claim Plan 11, R8 as |
| 62 | // accepted). It runs only after every base check of Policy.Check passed, so |
| 63 | // returning nil changes nothing and an error refuses the selection. |
| 64 | type PolicyRefusal func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error |
| 65 | |
| 66 | // WithRefusals returns a copy of p that also runs refusals, after p's own |
| 67 | // checks and its earlier refusals. p is unchanged, and no refusal can be |
| 68 | // removed or replaced: the list is unexported and only ever copied. |
| 69 | func (p Policy) WithRefusals(refusals ...PolicyRefusal) Policy { |
| 70 | p.refusals = append(append([]PolicyRefusal(nil), p.refusals...), refusals...) |
| 71 | return p |
| 72 | } |
| 73 | |
| 74 | func (p Policy) Check(ctx context.Context, c *hostclaim.Claim, s Selection, id hostwrite.Identity) error { |
| 75 | if err := p.check(ctx, c, s, id); err != nil { |
| 76 | return err |
| 77 | } |
| 78 | for _, refuse := range p.refusals { |
| 79 | if refuse == nil { |
| 80 | return ErrUnsupported |
| 81 | } |
| 82 | copy, err := detachedPolicyClaim(c) |
| 83 | if err != nil { |
| 84 | return err |
| 85 | } |
| 86 | if err := refuse(ctx, copy, s, id); err != nil { |
| 87 | return err |
| 88 | } |
| 89 | } |
| 90 | return nil |
| 91 | } |
| 92 | |
| 93 | func (p Policy) check(ctx context.Context, c *hostclaim.Claim, s Selection, id hostwrite.Identity) error { |
| 94 | if c == nil || p.Held == nil { |
| 95 | return ErrUnsupported |
| 96 | } |
| 97 | hold, err := p.Held(ctx, s.Host) |
| 98 | if err != nil { |
| 99 | return err |
| 100 | } |
| 101 | if c.Op.Kind == hostclaim.KindServing { |
| 102 | if id.Config != "" || id.Release != "" { |
| 103 | if p.Inputs == nil { |
| 104 | return ErrUnsupported |
| 105 | } |
| 106 | if err := p.Inputs(ctx, c, s, id); err != nil { |
| 107 | return err |
| 108 | } |
| 109 | } |
| 110 | if hold != "" { |
| 111 | return ErrChanged |
| 112 | } |
| 113 | return Serving(c, s) |
| 114 | } |
| 115 | if c.Op.Kind == hostclaim.KindRelocating { |
| 116 | if err := relocationProbe(c, s, hold); err != nil { |
| 117 | return err |
| 118 | } |
| 119 | if p.Relocation == nil { |
| 120 | return ErrChanged |
| 121 | } |
| 122 | copy, err := detachedPolicyClaim(c) |
| 123 | if err != nil { |
| 124 | return err |
| 125 | } |
| 126 | return p.Relocation(ctx, copy, s, id) |
| 127 | } |
| 128 | if p.Moves == nil || s.Op == "" || c.Op.ID != s.Op || c.Op.Exec != s.Box { |
| 129 | return ErrChanged |
| 130 | } |
| 131 | owner, owned := movesHoldOwner(c, s) |
| 132 | switch s.Action { |
| 133 | case "held-config", "held-release", "held-timers": |
| 134 | if !owned || hold != owner { |
| 135 | return ErrChanged |
| 136 | } |
| 137 | case "restore", "spawn", "seed", "writer", "install": |
| 138 | if hold != "" { |
| 139 | if !owned || hold != owner { |
| 140 | return ErrChanged |
| 141 | } |
| 142 | } else if err := WakePhase(c, s.Box, false); err != nil { |
| 143 | return err |
| 144 | } |
| 145 | default: |
| 146 | return ErrChanged |
| 147 | } |
| 148 | return p.Moves(ctx, c, s, id) |
| 149 | } |
| 150 | |
| 151 | // movesHoldOwner is the op a moves selection's registry hold must name, and |
| 152 | // false when no hold can be the selection's. A landing, a moving op and a |
| 153 | // revert hold the row by their op id, the selection's. A rollback keeps the |
| 154 | // move id as the claim's op id (hostclaim.RollBack) and hands the row to the |
| 155 | // rollback id (ruling J3), so a restoring op in the source's recovery phases |
| 156 | // expects the rollback id. That id comes only from the claim's |
| 157 | // intent.rollback, as RollBack recorded it, never from the selection, the |
| 158 | // request or a registry field, and it must be a valid rollback id. A |
| 159 | // restoring op without one, or outside those phases, matches no hold and |
| 160 | // never falls back to the selection's op (Plan 4 Task 3b fix round 1). |
| 161 | func movesHoldOwner(c *hostclaim.Claim, s Selection) (string, bool) { |
| 162 | if c.Op.Kind != hostclaim.KindRestoring { |
| 163 | return s.Op, true |
| 164 | } |
| 165 | rb := c.Op.Intent["rollback"] |
| 166 | if !restoringHoldPhases[c.Op.Phase] || !provision.ValidRollbackID(rb) { |
| 167 | return "", false |
| 168 | } |
| 169 | return rb, true |
| 170 | } |
| 171 | |
| 172 | // restoringHoldPhases are the source's recovery phases of a rollback |
| 173 | // (hostclaim.RecoveryPhaseOrder(KindRestoring) from claimed on): the source |
| 174 | // executes, and its registry row is held by the rollback id. |
| 175 | var restoringHoldPhases = map[string]bool{ |
| 176 | hostclaim.PhaseClaimed: true, hostclaim.PhaseProbing: true, hostclaim.PhaseProbed: true, hostclaim.PhaseOpened: true, |
| 177 | } |
| 178 | |
| 179 | // relocationProbe is the claim's own gate for a relocating claim (claim Plan |
| 180 | // 11 R1). The only relocating action is "probe", op.exec's probe spawn, |
| 181 | // selected as its own action so an ordinary spawn never gets probe |
| 182 | // authority. It is admitted in probing (WakePhase with probe set), and the |
| 183 | // same selection keeps working once the passed probe's session serves in the |
| 184 | // op's serving phase: selected at probing, the holder's writer box is this |
| 185 | // box, the session is the selected one. A registry hold must be absent or |
| 186 | // this op's own. |
| 187 | func relocationProbe(c *hostclaim.Claim, s Selection, hold string) error { |
| 188 | if c.Holder == nil || s.Action != "probe" || s.Op == "" || c.Op.ID != s.Op || c.Op.Exec != s.Box || (hold != "" && hold != s.Op) { |
| 189 | return ErrChanged |
| 190 | } |
| 191 | if c.Op.Phase == hostclaim.PhaseRelocatingServing { |
| 192 | if s.Phase != hostclaim.PhaseRelocatingProbing || c.Holder.WriterBox != s.Box || c.Holder.Session != s.Session || c.Op.Journal["stuck"] != "" { |
| 193 | return ErrChanged |
| 194 | } |
| 195 | for _, lost := range c.Lost { |
| 196 | if lost == s.Box { |
| 197 | return ErrChanged |
| 198 | } |
| 199 | } |
| 200 | return nil |
| 201 | } |
| 202 | return WakePhase(c, s.Box, true) |
| 203 | } |
| 204 | |
| 205 | const RequestLimit = 120 * time.Second |
| 206 | |
| 207 | // Each optional check receives its own observation. A callback cannot mutate |
| 208 | // authority already checked by the base or change a later refusal's inputs. |
| 209 | func detachedPolicyClaim(c *hostclaim.Claim) (*hostclaim.Claim, error) { |
| 210 | raw, err := json.Marshal(c) |
| 211 | if err != nil { |
| 212 | return nil, ErrChanged |
| 213 | } |
| 214 | var copy hostclaim.Claim |
| 215 | if err = json.Unmarshal(raw, ©); err != nil { |
| 216 | return nil, ErrChanged |
| 217 | } |
| 218 | return ©, nil |
| 219 | } |
| 220 | |