| index ac35bd185..941219887 100644 |
| --- a/internal/host/movespolicy_test.go |
| +++ b/internal/host/movespolicy_test.go |
| @@ -29,7 +29,7 @@ const ( |
| // target executing, the holder at stamp 7. |
| func mpClaim(kind, phase string) *hostclaim.Claim { |
| return &hostclaim.Claim{V: 1, Host: mpHost, Gen: 9, |
| - Holder: &hostclaim.Holder{Family: "instance", Incarnation: mpInc, Stamp: 7, Generation: "g-src", Session: 3, Location: "instances/" + mpHost + "/" + mpInc}, |
| + Holder: &hostclaim.Holder{Family: "instance", Account: "bab", App: "titogo", Incarnation: mpInc, Stamp: 7, Generation: "g-src", Session: 3, Location: "instances/" + mpHost + "/" + mpInc}, |
| Op: hostclaim.Op{Kind: kind, ID: mpOp, Phase: phase, Exec: mpTarget, Participants: []string{mpSource, mpTarget}, |
| Intent: map[string]string{"to": "uk", "source": mpSource, "target": mpTarget, "source_enrollment": "enr-ie"}}} |
| } |
| @@ -42,11 +42,47 @@ func mpSelection(c *hostclaim.Claim, action string) hostbox.Selection { |
| Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: c.Holder.Generation, Session: c.Holder.Session} |
| } |
| |
| -// mpIdentity is the identity that fits the action. |
| +// mpPins are the inputs the move pinned at readiness. |
| +func mpPins() *provision.MoveInputs { |
| + return &provision.MoveInputs{BundleETags: map[string]string{"default": "e-default", "environment": "e-env"}, OverlayETag: "e-overlay", Release: "v1.2.3"} |
| +} |
| + |
| +// mpRecord is the landing record the reader answers: this move, this |
| +// incarnation, landing, with the pins. |
| +func mpRecord() *provision.Record { |
| + return &provision.Record{Host: mpHost, Account: "bab", App: "titogo", Region: "uk", |
| + Move: &provision.MoveState{ID: mpOp, Incarnation: mpInc, From: "ie", To: "uk", State: provision.MoveLanding, LandingPhase: provision.LandConfigured, Inputs: mpPins()}} |
| +} |
| + |
| +// mpReader counts its reads and answers rec, or err. |
| +type mpReader struct { |
| + reads int |
| + rec *provision.Record |
| + err error |
| + args []string |
| +} |
| + |
| +func (r *mpReader) read(_ context.Context, acct, app, host string) (*provision.Record, error) { |
| + r.reads++ |
| + r.args = []string{acct, app, host} |
| + if r.err != nil { |
| + return nil, r.err |
| + } |
| + return r.rec, nil |
| +} |
| + |
| +// mpPolicy is the policy over a reader answering the landing record. |
| +func mpPolicy() (func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error, *mpReader) { |
| + r := &mpReader{rec: mpRecord()} |
| + return MovesClaimPolicy(r.read), r |
| +} |
| + |
| +// mpIdentity is the identity that fits the action: the pinned config and |
| +// release for the held writes, nothing for the rest. |
| func mpIdentity(action string) hostwrite.Identity { |
| switch action { |
| case "held-config": |
| - return hostwrite.Identity{Config: "sha256:abc;default=true;environment=false;instance=false"} |
| + return HeldConfigIdentity(mpPins().BundleETags, mpPins().OverlayETag) |
| case "held-release": |
| return hostwrite.Identity{Release: "v1.2.3"} |
| } |
| @@ -66,13 +102,14 @@ func TestMovesClaimPolicyGrantsByPhase(t *testing.T) { |
| provision.LandRestored: {"restore": true, "install": true}, |
| } |
| phases := append(provision.LandingOrder(), "committed") |
| - policy := hostbox.Policy{Moves: MovesClaimPolicy, Held: func(context.Context, string) (string, error) { return mpOp, nil }} |
| + moves, reader := mpPolicy() |
| + policy := hostbox.Policy{Moves: moves, Held: func(context.Context, string) (string, error) { return mpOp, nil }} |
| grants := 0 |
| for _, phase := range phases { |
| for _, action := range mpActions { |
| c := mpClaim(hostclaim.KindLanding, phase) |
| s := mpSelection(c, action) |
| - err := MovesClaimPolicy(t.Context(), c, s, mpIdentity(action)) |
| + err := moves(t.Context(), c, s, mpIdentity(action)) |
| want := granted[phase][action] |
| if (err == nil) != want { |
| t.Errorf("%s/%s: err %v, want granted %v", phase, action, err, want) |
| @@ -95,6 +132,11 @@ func TestMovesClaimPolicyGrantsByPhase(t *testing.T) { |
| if grants != 5 { |
| t.Fatalf("%d cells granted, want 5", grants) |
| } |
| + // The record is read for the held config and release only: twice per |
| + // pass over the table (the direct call and Policy.Check), at configured. |
| + if reader.reads != 4 || !reflect.DeepEqual(reader.args, []string{"bab", "titogo", mpHost}) { |
| + t.Fatalf("record reads: %d %v", reader.reads, reader.args) |
| + } |
| } |
| |
| // The claim's role and the selection's shape: a non-move op, the other move |
| @@ -192,7 +234,8 @@ func TestMovesClaimPolicyRefusesOtherRoles(t *testing.T) { |
| c, s := base() |
| id := hostwrite.Identity{} |
| mutate(c, &s, &id) |
| - err := MovesClaimPolicy(t.Context(), c, s, id) |
| + moves, _ := mpPolicy() |
| + err := moves(t.Context(), c, s, id) |
| if err == nil { |
| t.Fatal("granted") |
| } |
| @@ -203,53 +246,60 @@ func TestMovesClaimPolicyRefusesOtherRoles(t *testing.T) { |
| } |
| t.Run("the base cell is granted", func(t *testing.T) { |
| c, s := base() |
| - if err := MovesClaimPolicy(t.Context(), c, s, hostwrite.Identity{}); err != nil { |
| + moves, _ := mpPolicy() |
| + if err := moves(t.Context(), c, s, hostwrite.Identity{}); err != nil { |
| t.Fatal(err) |
| } |
| }) |
| - t.Run("held-config needs the rendered identity and no release", func(t *testing.T) { |
| + t.Run("held-config needs the pinned config identity and no release", func(t *testing.T) { |
| c := mpClaim(hostclaim.KindLanding, provision.LandConfigured) |
| s := mpSelection(c, "held-config") |
| - for _, id := range []hostwrite.Identity{{}, {Release: "v1"}, {Config: "sha256:abc", Release: "v1"}} { |
| - if err := MovesClaimPolicy(t.Context(), c, s, id); !errors.Is(err, ErrMovesRefused) { |
| + moves, _ := mpPolicy() |
| + pinned := mpIdentity("held-config").Config |
| + for _, id := range []hostwrite.Identity{{}, {Release: "v1.2.3"}, {Config: pinned, Release: "v1.2.3"}} { |
| + if err := moves(t.Context(), c, s, id); !errors.Is(err, ErrMovesRefused) { |
| t.Fatalf("%+v: %v", id, err) |
| } |
| } |
| - if err := MovesClaimPolicy(t.Context(), c, s, hostwrite.Identity{Config: "sha256:abc"}); err != nil { |
| + if err := moves(t.Context(), c, s, hostwrite.Identity{Config: pinned}); err != nil { |
| t.Fatal(err) |
| } |
| }) |
| - t.Run("held-release needs the release and no config", func(t *testing.T) { |
| + t.Run("held-release needs the pinned release and no config", func(t *testing.T) { |
| c := mpClaim(hostclaim.KindLanding, provision.LandConfigured) |
| s := mpSelection(c, "held-release") |
| - for _, id := range []hostwrite.Identity{{}, {Config: "sha256:abc"}, {Config: "sha256:abc", Release: "v1"}} { |
| - if err := MovesClaimPolicy(t.Context(), c, s, id); !errors.Is(err, ErrMovesRefused) { |
| + moves, _ := mpPolicy() |
| + pinned := mpIdentity("held-config").Config |
| + for _, id := range []hostwrite.Identity{{}, {Config: pinned}, {Config: pinned, Release: "v1.2.3"}} { |
| + if err := moves(t.Context(), c, s, id); !errors.Is(err, ErrMovesRefused) { |
| t.Fatalf("%+v: %v", id, err) |
| } |
| } |
| - if err := MovesClaimPolicy(t.Context(), c, s, hostwrite.Identity{Release: "v1"}); err != nil { |
| + if err := moves(t.Context(), c, s, hostwrite.Identity{Release: "v1.2.3"}); err != nil { |
| t.Fatal(err) |
| } |
| }) |
| t.Run("a nil claim or a dead context", func(t *testing.T) { |
| c, s := base() |
| - if err := MovesClaimPolicy(t.Context(), nil, s, hostwrite.Identity{}); !errors.Is(err, ErrMovesRefused) { |
| + moves, _ := mpPolicy() |
| + if err := moves(t.Context(), nil, s, hostwrite.Identity{}); !errors.Is(err, ErrMovesRefused) { |
| t.Fatal(err) |
| } |
| ctx, cancel := context.WithCancel(t.Context()) |
| cancel() |
| - if err := MovesClaimPolicy(ctx, c, s, hostwrite.Identity{}); !errors.Is(err, context.Canceled) { |
| + if err := moves(ctx, c, s, hostwrite.Identity{}); !errors.Is(err, context.Canceled) { |
| t.Fatal(err) |
| } |
| //lint:ignore SA1012 the nil context is the case under test |
| - if err := MovesClaimPolicy(nil, c, s, hostwrite.Identity{}); !errors.Is(err, ErrMovesRefused) { //nolint:staticcheck |
| + if err := moves(nil, c, s, hostwrite.Identity{}); !errors.Is(err, ErrMovesRefused) { //nolint:staticcheck |
| t.Fatal(err) |
| } |
| }) |
| t.Run("the refusal says what and why", func(t *testing.T) { |
| c, s := base() |
| s.Region = "ie" |
| - err := MovesClaimPolicy(t.Context(), c, s, hostwrite.Identity{}) |
| + moves, _ := mpPolicy() |
| + err := moves(t.Context(), c, s, hostwrite.Identity{}) |
| var r *MovesPolicyRefusal |
| if !errors.As(err, &r) || r.Host != mpHost || r.Op != mpOp || r.Action != "held-timers" || r.Box != mpTarget || r.Kind != hostclaim.KindLanding || r.Phase != provision.LandLocal || r.Reason == "" || err.Error() == "" { |
| t.Fatalf("%#v", err) |
| @@ -257,6 +307,178 @@ func TestMovesClaimPolicyRefusesOtherRoles(t *testing.T) { |
| }) |
| } |
| |
| +// The held config and release are the move's pinned inputs (spec §9.1, |
| +// §9.2): at configured the policy reads the record and grants a held-release |
| +// identity equal to Inputs.Release and a held-config identity equal to the |
| +// pinned etags' canonical form, and nothing else. Missing pins, a record |
| +// that is not this landing, a read error or no reader refuse. The record is |
| +// not read for any other action. |
| +func TestMovesClaimPolicyBindsHeldInputsToThePins(t *testing.T) { |
| + at := func(action string) (*hostclaim.Claim, hostbox.Selection) { |
| + c := mpClaim(hostclaim.KindLanding, provision.LandConfigured) |
| + return c, mpSelection(c, action) |
| + } |
| + refused := func(t *testing.T, err error, reason string) { |
| + t.Helper() |
| + var r *MovesPolicyRefusal |
| + if err == nil || !errors.As(err, &r) || !errors.Is(err, ErrMovesRefused) { |
| + t.Fatalf("not the typed refusal: %v", err) |
| + } |
| + if reason != "" && r.Reason != reason { |
| + t.Fatalf("reason %q, want %q", r.Reason, reason) |
| + } |
| + } |
| + t.Run("the pinned identities are granted", func(t *testing.T) { |
| + moves, reader := mpPolicy() |
| + c, s := at("held-release") |
| + if err := moves(t.Context(), c, s, hostwrite.Identity{Release: "v1.2.3"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + c, s = at("held-config") |
| + if err := moves(t.Context(), c, s, HeldConfigIdentity(map[string]string{"default": "e-default", "environment": "e-env"}, "e-overlay")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if reader.reads != 2 { |
| + t.Fatalf("%d reads", reader.reads) |
| + } |
| + }) |
| + t.Run("a mismatched release", func(t *testing.T) { |
| + moves, _ := mpPolicy() |
| + c, s := at("held-release") |
| + for _, v := range []string{"v1.2.4", "v1.2.3 ", "V1.2.3", "sha256:v1.2.3"} { |
| + refused(t, moves(t.Context(), c, s, hostwrite.Identity{Release: v}), "the release is not the pinned one") |
| + } |
| + }) |
| + t.Run("a mismatched config", func(t *testing.T) { |
| + moves, _ := mpPolicy() |
| + c, s := at("held-config") |
| + for _, id := range []hostwrite.Identity{ |
| + HeldConfigIdentity(map[string]string{"default": "e-other", "environment": "e-env"}, "e-overlay"), |
| + HeldConfigIdentity(map[string]string{"default": "e-default"}, "e-overlay"), |
| + HeldConfigIdentity(map[string]string{"default": "e-default", "environment": "e-env", "extra": "e"}, "e-overlay"), |
| + HeldConfigIdentity(map[string]string{"default": "e-default", "environment": "e-env"}, "e-other"), |
| + HeldConfigIdentity(map[string]string{"default": "e-default", "environment": "e-env"}, ""), |
| + {Config: "sha256:abc;default=true;environment=true;instance=true"}, |
| + {Config: "etags:"}, |
| + } { |
| + refused(t, moves(t.Context(), c, s, id), "the config is not the pinned one") |
| + } |
| + }) |
| + t.Run("missing pins", func(t *testing.T) { |
| + for name, mutate := range map[string]func(*provision.Record){ |
| + "no inputs": func(r *provision.Record) { r.Move.Inputs = nil }, |
| + "no release pinned": func(r *provision.Record) { r.Move.Inputs.Release = "" }, |
| + "no config pinned": func(r *provision.Record) { |
| + r.Move.Inputs.BundleETags, r.Move.Inputs.OverlayETag = nil, "" |
| + }, |
| + "empty etags pinned": func(r *provision.Record) { |
| + r.Move.Inputs.BundleETags, r.Move.Inputs.OverlayETag = map[string]string{"default": "", "environment": ""}, "" |
| + }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + rec := mpRecord() |
| + mutate(rec) |
| + moves := MovesClaimPolicy((&mpReader{rec: rec}).read) |
| + c, s := at("held-release") |
| + errRelease := moves(t.Context(), c, s, hostwrite.Identity{Release: "v1.2.3"}) |
| + c, s = at("held-config") |
| + errConfig := moves(t.Context(), c, s, mpIdentity("held-config")) |
| + switch name { |
| + case "no release pinned": |
| + refused(t, errRelease, "no pinned release") |
| + if errConfig != nil { |
| + t.Fatalf("the config pins are intact: %v", errConfig) |
| + } |
| + case "no config pinned", "empty etags pinned": |
| + refused(t, errConfig, "no pinned config") |
| + if errRelease != nil { |
| + t.Fatalf("the release pin is intact: %v", errRelease) |
| + } |
| + default: |
| + refused(t, errRelease, "no pinned inputs") |
| + refused(t, errConfig, "no pinned inputs") |
| + } |
| + }) |
| + } |
| + }) |
| + t.Run("a record read error", func(t *testing.T) { |
| + cause := errors.New("the store is down") |
| + moves := MovesClaimPolicy((&mpReader{err: cause}).read) |
| + for _, action := range []string{"held-release", "held-config"} { |
| + c, s := at(action) |
| + err := moves(t.Context(), c, s, mpIdentity(action)) |
| + refused(t, err, "") |
| + if !errors.Is(err, cause) { |
| + t.Fatalf("the cause is not carried: %v", err) |
| + } |
| + } |
| + }) |
| + t.Run("a record that is not this landing", func(t *testing.T) { |
| + for name, mutate := range map[string]func(*provision.Record) *provision.Record{ |
| + "absent": func(*provision.Record) *provision.Record { return nil }, |
| + "no move": func(r *provision.Record) *provision.Record { r.Move = nil; return r }, |
| + "another move": func(r *provision.Record) *provision.Record { r.Move.ID = "mv_other"; return r }, |
| + "another incarnation": func(r *provision.Record) *provision.Record { r.Move.Incarnation = "0"; return r }, |
| + "not landing": func(r *provision.Record) *provision.Record { r.Move.State = provision.MoveRollingBack; return r }, |
| + "another host": func(r *provision.Record) *provision.Record { r.Host = "other.example"; return r }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + moves := MovesClaimPolicy((&mpReader{rec: mutate(mpRecord())}).read) |
| + for _, action := range []string{"held-release", "held-config"} { |
| + c, s := at(action) |
| + refused(t, moves(t.Context(), c, s, mpIdentity(action)), "the record is not this landing") |
| + } |
| + }) |
| + } |
| + }) |
| + t.Run("a holder without account or app", func(t *testing.T) { |
| + moves, reader := mpPolicy() |
| + for _, action := range []string{"held-release", "held-config"} { |
| + c, s := at(action) |
| + c.Holder.App = "" |
| + refused(t, moves(t.Context(), c, s, mpIdentity(action)), "the holder names no account or app") |
| + c, s = at(action) |
| + c.Holder.Account = "" |
| + refused(t, moves(t.Context(), c, s, mpIdentity(action)), "the holder names no account or app") |
| + } |
| + if reader.reads != 0 { |
| + t.Fatalf("%d reads", reader.reads) |
| + } |
| + }) |
| + t.Run("no reader", func(t *testing.T) { |
| + moves := MovesClaimPolicy(nil) |
| + for _, action := range []string{"held-release", "held-config"} { |
| + c, s := at(action) |
| + refused(t, moves(t.Context(), c, s, mpIdentity(action)), "no record reader") |
| + } |
| + // The other grants need no record. |
| + c := mpClaim(hostclaim.KindLanding, provision.LandLocal) |
| + if err := moves(t.Context(), c, mpSelection(c, "held-timers"), hostwrite.Identity{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + }) |
| + t.Run("the record is read for the held config and release only", func(t *testing.T) { |
| + moves, reader := mpPolicy() |
| + for phase, action := range map[string]string{provision.LandLocal: "held-timers", provision.LandRestored: "restore", provision.LandChecking: "spawn"} { |
| + c := mpClaim(hostclaim.KindLanding, phase) |
| + moves(t.Context(), c, mpSelection(c, action), hostwrite.Identity{}) |
| + } |
| + if reader.reads != 0 { |
| + t.Fatalf("%d reads", reader.reads) |
| + } |
| + }) |
| + t.Run("the canonical config identity", func(t *testing.T) { |
| + a := HeldConfigIdentity(map[string]string{"environment": "e-env", "default": "e-default", "instance": ""}, "e-overlay") |
| + b := HeldConfigIdentity(map[string]string{"default": "e-default", "environment": "e-env"}, "e-overlay") |
| + if a != b || a.Release != "" || a.Config == "" { |
| + t.Fatalf("%+v vs %+v", a, b) |
| + } |
| + if HeldConfigIdentity(nil, "").Config != "" { |
| + t.Fatal("no etags is an identity") |
| + } |
| + }) |
| +} |
| + |
| // The held-timers import with CARLOS_HOST_CLAIM off is the registry's own |
| // ReplaceTimers: the rows land exactly as before, bookkeeping included, |
| // another host's rows untouched. With the flag on, the write runs inside the |