| 1 | package hostbox |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "testing" |
| 7 | |
| 8 | "github.com/carlosframework/platform/internal/hostclaim" |
| 9 | "github.com/carlosframework/platform/internal/hostwrite" |
| 10 | ) |
| 11 | |
| 12 | func plan11Relocating(phase string) *hostclaim.Claim { |
| 13 | exec := "target" |
| 14 | switch phase { |
| 15 | case "starting", "releasing": |
| 16 | exec = "source" |
| 17 | case "pinning": |
| 18 | exec = "none" |
| 19 | } |
| 20 | return &hostclaim.Claim{Holder: &hostclaim.Holder{Session: 7, WriterBox: "target"}, |
| 21 | Op: hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "r1", Phase: phase, Exec: exec, Participants: []string{"source", "target"}, |
| 22 | Intent: map[string]string{"region": "uk", "source_box": "source", "target_box": "target"}}} |
| 23 | } |
| 24 | |
| 25 | // WakePhase admits a relocation's probe at probing on op.exec only. No |
| 26 | // ordinary relocating wake is admitted at any phase: the source's ordinary |
| 27 | // wake in starting and releasing is not part of the accepted R1. |
| 28 | func TestPlan11WakePhaseAdmitsOnlyTheRelocationProbe(t *testing.T) { |
| 29 | for _, tc := range []struct { |
| 30 | phase, box string |
| 31 | probe, want bool |
| 32 | }{ |
| 33 | {"probing", "target", true, true}, |
| 34 | {"probing", "target", false, false}, |
| 35 | {"probing", "source", true, false}, |
| 36 | {"starting", "source", false, false}, |
| 37 | {"releasing", "source", false, false}, |
| 38 | {"starting", "source", true, false}, |
| 39 | {"installed", "target", true, false}, |
| 40 | {"installing", "target", true, false}, |
| 41 | {"probe-failed", "target", true, false}, |
| 42 | {"serving", "target", false, false}, |
| 43 | {"serving", "target", true, false}, |
| 44 | } { |
| 45 | if got := WakePhase(plan11Relocating(tc.phase), tc.box, tc.probe) == nil; got != tc.want { |
| 46 | t.Errorf("%+v: admitted=%v", tc, got) |
| 47 | } |
| 48 | } |
| 49 | c := plan11Relocating("probing") |
| 50 | c.Lost = []string{"target"} |
| 51 | if WakePhase(c, "target", true) == nil { |
| 52 | t.Fatal("a lost target probes") |
| 53 | } |
| 54 | c = plan11Relocating("probing") |
| 55 | c.Op.Journal = map[string]string{"stuck": "deadline"} |
| 56 | if WakePhase(c, "target", true) == nil { |
| 57 | t.Fatal("a stuck relocation probes") |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | type plan11Owner struct{ calls []string } |
| 62 | |
| 63 | func (o *plan11Owner) check(name string, err error) func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error { |
| 64 | return func(_ context.Context, _ *hostclaim.Claim, s Selection, _ hostwrite.Identity) error { |
| 65 | o.calls = append(o.calls, name+":"+s.Action) |
| 66 | return err |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | func plan11Policy(o *plan11Owner, hold string) Policy { |
| 71 | return Policy{Held: func(context.Context, string) (string, error) { return hold, nil }, |
| 72 | Moves: o.check("moves", nil), Relocation: o.check("relocation", nil)} |
| 73 | } |
| 74 | |
| 75 | // Policy.Check under relocating: the claim's own gate runs first and only |
| 76 | // the "probe" action of op.exec at probing (or its session carried into |
| 77 | // serving) reaches the typed relocation owner. Every other action, a stale |
| 78 | // op, a foreign hold, and a nil owner refuse without consulting it. The |
| 79 | // moves owner never sees a relocating claim. |
| 80 | func TestPlan11PolicyGivesProbeAuthorityOnlyToTheRelocationProbe(t *testing.T) { |
| 81 | ctx := context.Background() |
| 82 | o := &plan11Owner{} |
| 83 | p := plan11Policy(o, "") |
| 84 | probe := Selection{Host: "x.example", Box: "target", Op: "r1", Action: "probe", Phase: "probing", Session: 7} |
| 85 | if err := p.Check(ctx, plan11Relocating("probing"), probe, hostwrite.Identity{}); err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | if len(o.calls) != 1 || o.calls[0] != "relocation:probe" { |
| 89 | t.Fatalf("owner calls %v", o.calls) |
| 90 | } |
| 91 | // The admitted probe session carried into relocating/serving. |
| 92 | o.calls = nil |
| 93 | if err := p.Check(ctx, plan11Relocating("serving"), probe, hostwrite.Identity{}); err != nil { |
| 94 | t.Fatalf("carried session: %v", err) |
| 95 | } |
| 96 | // A probe hold by this op is the op's own; another op's hold refuses. |
| 97 | if err := plan11Policy(o, "r1").Check(ctx, plan11Relocating("probing"), probe, hostwrite.Identity{}); err != nil { |
| 98 | t.Fatalf("own hold: %v", err) |
| 99 | } |
| 100 | o.calls = nil |
| 101 | refused := map[string]struct { |
| 102 | p Policy |
| 103 | c *hostclaim.Claim |
| 104 | s Selection |
| 105 | }{ |
| 106 | "spawn": {plan11Policy(o, ""), plan11Relocating("probing"), func() Selection { s := probe; s.Action = "spawn"; return s }()}, |
| 107 | "seed": {plan11Policy(o, ""), plan11Relocating("probing"), func() Selection { s := probe; s.Action = "seed"; return s }()}, |
| 108 | "held-config": {plan11Policy(o, "r1"), plan11Relocating("probing"), func() Selection { s := probe; s.Action = "held-config"; return s }()}, |
| 109 | "install": {plan11Policy(o, ""), plan11Relocating("installing"), func() Selection { s := probe; s.Action = "install"; return s }()}, |
| 110 | "reconcile": {plan11Policy(o, ""), plan11Relocating("probing"), func() Selection { s := probe; s.Action = "reconcile"; return s }()}, |
| 111 | "probe installed": {plan11Policy(o, ""), plan11Relocating("installed"), probe}, |
| 112 | "probe probe-failed": {plan11Policy(o, ""), plan11Relocating("probe-failed"), probe}, |
| 113 | "source probe": {plan11Policy(o, ""), plan11Relocating("starting"), func() Selection { s := probe; s.Box = "source"; return s }()}, |
| 114 | "source spawn": {plan11Policy(o, ""), plan11Relocating("releasing"), func() Selection { s := probe; s.Box, s.Action = "source", "spawn"; return s }()}, |
| 115 | "stale op": {plan11Policy(o, ""), plan11Relocating("probing"), func() Selection { s := probe; s.Op = "r0"; return s }()}, |
| 116 | "foreign hold": {plan11Policy(o, "m9"), plan11Relocating("probing"), probe}, |
| 117 | "serving, selected at serving": {plan11Policy(o, ""), plan11Relocating("serving"), func() Selection { s := probe; s.Phase = "serving"; return s }()}, |
| 118 | "serving, other writer": {plan11Policy(o, ""), func() *hostclaim.Claim { c := plan11Relocating("serving"); c.Holder.WriterBox = "source"; return c }(), probe}, |
| 119 | "no owner": {Policy{Held: func(context.Context, string) (string, error) { return "", nil }, Moves: o.check("moves", nil)}, plan11Relocating("probing"), probe}, |
| 120 | } |
| 121 | for name, tc := range refused { |
| 122 | if err := tc.p.Check(ctx, tc.c, tc.s, hostwrite.Identity{}); err == nil { |
| 123 | t.Errorf("%s: admitted", name) |
| 124 | } |
| 125 | } |
| 126 | if len(o.calls) != 0 { |
| 127 | t.Fatalf("owners consulted on refused selections: %v", o.calls) |
| 128 | } |
| 129 | // The owner can refuse what the claim's gate allows. |
| 130 | deny := errors.New("region HA refuses") |
| 131 | p.Relocation = o.check("relocation", deny) |
| 132 | if err := p.Check(ctx, plan11Relocating("probing"), probe, hostwrite.Identity{}); !errors.Is(err, deny) { |
| 133 | t.Fatalf("owner refusal: %v", err) |
| 134 | } |
| 135 | // "probe" is never an action of any other kind. |
| 136 | landing := &hostclaim.Claim{Holder: &hostclaim.Holder{}, Op: hostclaim.Op{Kind: hostclaim.KindLanding, ID: "m1", Phase: "probing", Exec: "target"}} |
| 137 | o.calls = nil |
| 138 | if err := plan11Policy(o, "m1").Check(ctx, landing, Selection{Host: "x.example", Box: "target", Op: "m1", Action: "probe"}, hostwrite.Identity{}); err == nil || len(o.calls) != 0 { |
| 139 | t.Fatalf("landing probe action: %v %v", err, o.calls) |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | // R8 as accepted: an append-only policy check. Refusals run only after the |
| 144 | // whole base check passed, so they can add a refusal and never remove one; |
| 145 | // WithRefusals copies, so an earlier Policy value is unchanged. |
| 146 | func TestPlan11PolicyRefusalsAreAppendOnly(t *testing.T) { |
| 147 | ctx := context.Background() |
| 148 | serving := &hostclaim.Claim{Holder: &hostclaim.Holder{}, Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"}} |
| 149 | s := Selection{Host: "x.example", Box: "box", Action: "spawn"} |
| 150 | base := Policy{Held: func(context.Context, string) (string, error) { return "", nil }} |
| 151 | if err := base.Check(ctx, serving, s, hostwrite.Identity{}); err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | var seen []string |
| 155 | deny := errors.New("refused by region HA") |
| 156 | pass := func(name string) PolicyRefusal { |
| 157 | return func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error { |
| 158 | seen = append(seen, name) |
| 159 | return nil |
| 160 | } |
| 161 | } |
| 162 | refuse := func(context.Context, *hostclaim.Claim, Selection, hostwrite.Identity) error { |
| 163 | seen = append(seen, "refuse") |
| 164 | return deny |
| 165 | } |
| 166 | one := base.WithRefusals(pass("a")) |
| 167 | two := one.WithRefusals(refuse) |
| 168 | if err := one.Check(ctx, serving, s, hostwrite.Identity{}); err != nil || len(seen) != 1 { |
| 169 | t.Fatalf("passing refusal: %v %v", err, seen) |
| 170 | } |
| 171 | seen = nil |
| 172 | if err := two.Check(ctx, serving, s, hostwrite.Identity{}); !errors.Is(err, deny) || len(seen) != 2 || seen[0] != "a" { |
| 173 | t.Fatalf("added refusal: %v %v", err, seen) |
| 174 | } |
| 175 | seen = nil |
| 176 | if err := one.Check(ctx, serving, s, hostwrite.Identity{}); err != nil || len(seen) != 1 { |
| 177 | t.Fatalf("WithRefusals changed an earlier policy: %v %v", err, seen) |
| 178 | } |
| 179 | if err := base.Check(ctx, serving, s, hostwrite.Identity{}); err != nil { |
| 180 | t.Fatalf("base changed: %v", err) |
| 181 | } |
| 182 | // A refused base never reaches the refusals. |
| 183 | seen = nil |
| 184 | foreign := s |
| 185 | foreign.Box = "other" |
| 186 | if err := two.Check(ctx, serving, foreign, hostwrite.Identity{}); !errors.Is(err, ErrChanged) || len(seen) != 0 { |
| 187 | t.Fatalf("refusals consulted after a base refusal: %v %v", err, seen) |
| 188 | } |
| 189 | // A nil refusal fails closed. |
| 190 | if err := base.WithRefusals(nil).Check(ctx, serving, s, hostwrite.Identity{}); !errors.Is(err, ErrUnsupported) { |
| 191 | t.Fatalf("nil refusal: %v", err) |
| 192 | } |
| 193 | // Aliasing: appending to two copies of the same parent keeps each its own. |
| 194 | x, y := one.WithRefusals(refuse), one.WithRefusals(pass("y")) |
| 195 | seen = nil |
| 196 | if err := y.Check(ctx, serving, s, hostwrite.Identity{}); err != nil || len(seen) != 2 || seen[1] != "y" { |
| 197 | t.Fatalf("sibling copies share refusals: %v %v", err, seen) |
| 198 | } |
| 199 | if err := x.Check(ctx, serving, s, hostwrite.Identity{}); !errors.Is(err, deny) { |
| 200 | t.Fatalf("sibling refusal lost: %v", err) |
| 201 | } |
| 202 | } |
| 203 | |