carlos / platform Public

host: the moves policy grants held config and release only for the move's pinned inputs (Task 10b fix round 1)

Policy.Inputs is consulted for serving claims only, so MovesClaimPolicy
supplies the held-input check itself. It is now built over a record
reader (MovesRecordsFrom(store) in production): at configured it reads
the record, requires it to be this landing with Move.Inputs pinned, and
grants a held-release identity equal to Inputs.Release and a held-config
identity equal to HeldConfigIdentity(Inputs.BundleETags,
Inputs.OverlayETag), the canonical form of the pinned etags. No pins, a
record that is not this landing, a holder without account or app, a read
error (carried as the refusal's cause) or no reader refuse. The record is
read for those two actions only.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 0ea46afd7c3d296daee6a25add77612d1ebc40c7 parent 2aa69a4
2 files changed, +422 −99
  • internal/host/movespolicy.go +181 −80
  • internal/host/movespolicy_test.go +241 −19
diff --git a/internal/host/movespolicy.go b/internal/host/movespolicy.go
index a83754cb3..0e3146d77 100644
--- a/internal/host/movespolicy.go
+++ b/internal/host/movespolicy.go
@@ -4,12 +4,15 @@ import (
"context"
"errors"
"fmt"
+ "sort"
+ "strings"
"github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/schedule"
)
@@ -17,9 +20,9 @@ import (
//
// hostbox.Policy.Check separates ordinary host writes from the moves-owned
// ones: under a move kind it checks the registry hold and the selection's
-// op and box, and then asks Policy.Moves. MovesClaimPolicy is that answer,
-// the value for ClaimOwnerBindings.MovesPolicy. It grants a write only when
-// every one of these holds, and refuses everything else with a
+// op and box, and then asks Policy.Moves. MovesClaimPolicy builds that
+// answer, the value for ClaimOwnerBindings.MovesPolicy. It grants a write
+// only when every one of these holds, and refuses everything else with a
// *MovesPolicyRefusal (fail closed):
//
// - the claim's op is the landing of a move (hostclaim.KindLanding). The
@@ -37,35 +40,91 @@ import (
// restored). The probe's rights at probing (spawn, seed, writer, and the
// held render before every probe, §7.7) and the ordinary wake rights at
// committed (§19.1) are Plan 3's rows, added when the probe is built;
-// - the identity fits the action: a held-config write names the rendered
-// config and nothing else, a held-release write names the release and
-// nothing else, and every other action carries no identity.
-//
-// Nothing here reads the record: the landing pins the record's inputs
-// itself at configured (the etag comparison against Move.Inputs), and the
-// claim says everything the policy needs about who may write what.
+// - the identity fits the action: a held-config write names the config
+// and nothing else, a held-release write names the release and nothing
+// else, and every other action carries no identity;
+// - a held write names the move's pinned inputs (§9.1, §9.2). Policy.Inputs
+// is consulted for serving claims only (wakepolicy.go), so this policy
+// reads the record itself, through the reader it is built with: the
+// record must be this landing (the op, the holder's incarnation, state
+// landing), Move.Inputs must be pinned, a held-release identity must
+// equal Inputs.Release, and a held-config identity must equal
+// HeldConfigIdentity(Inputs.BundleETags, Inputs.OverlayETag), the
+// canonical form of the pinned etags. No pins, a record that is not this
+// landing, a read error or no reader refuse. The record is read for
+// these two actions only.
//
// Production registration. RegisterProductionClaimOwners requires every
// owner, and the Inputs, PublicationEvidence and AdmissionEvidence owners
// (Plan 3) and View, Authorize, Evidence and LeaseSet (moves Plan 5) do not
// exist yet, so nothing registers this in production: it is tested and
-// ready to plug in. Until that registration, the landing keeps refusing at
-// checking while CARLOS_HOST_CLAIM is on (ruling G16, movesland.go).
+// ready to plug in as MovesClaimPolicy(MovesRecordsFrom(store)). Until that
+// registration, the landing keeps refusing at checking while
+// CARLOS_HOST_CLAIM is on (ruling G16, movesland.go).
// ErrMovesRefused is what every MovesPolicyRefusal unwraps to.
var ErrMovesRefused = errors.New("host: the moves policy refuses the write")
// MovesPolicyRefusal is MovesClaimPolicy's typed refusal: which action was
-// asked under which op, kind and phase, by which box, and why.
+// asked under which op, kind and phase, by which box, and why. Cause is
+// the record read's error when that is the reason.
type MovesPolicyRefusal struct {
Host, Op, Action, Kind, Phase, Box, Reason string
+ Cause error
}
func (e *MovesPolicyRefusal) Error() string {
- return fmt.Sprintf("host: the moves policy refuses %s for %s under %s/%q on %s: %s", e.Action, e.Host, e.Kind, e.Phase, e.Box, e.Reason)
+ msg := fmt.Sprintf("host: the moves policy refuses %s for %s under %s/%q on %s: %s", e.Action, e.Host, e.Kind, e.Phase, e.Box, e.Reason)
+ if e.Cause != nil {
+ msg += ": " + e.Cause.Error()
+ }
+ return msg
+}
+
+func (e *MovesPolicyRefusal) Unwrap() []error {
+ if e.Cause != nil {
+ return []error{ErrMovesRefused, e.Cause}
+ }
+ return []error{ErrMovesRefused}
+}
+
+// MovesRecordReader reads the host's provisioning record: the move's
+// pinned inputs live there. Production is MovesRecordsFrom(store).
+type MovesRecordReader func(ctx context.Context, acct, app, host string) (*provision.Record, error)
+
+// MovesRecordsFrom is provision.GetRecord over the deployment store.
+func MovesRecordsFrom(s release.Store) MovesRecordReader {
+ return func(ctx context.Context, acct, app, host string) (*provision.Record, error) {
+ if s == nil {
+ return nil, hostwrite.ErrUnavailable
+ }
+ return provision.GetRecord(ctx, s, acct, app, host)
+ }
}
-func (e *MovesPolicyRefusal) Unwrap() error { return ErrMovesRefused }
+// HeldConfigIdentity is the identity a held-config write carries (§9.1):
+// the bundle etags and the overlay etag it rendered from, in one canonical
+// string, so the policy can compare them with the move's pins exactly.
+// Keys are sorted; an empty etag is the same as an absent one. No etags at
+// all is the zero identity, which the policy never grants.
+func HeldConfigIdentity(bundleETags map[string]string, overlayETag string) hostwrite.Identity {
+ var keys []string
+ for k, v := range bundleETags {
+ if k != "" && v != "" {
+ keys = append(keys, k)
+ }
+ }
+ if len(keys) == 0 && overlayETag == "" {
+ return hostwrite.Identity{}
+ }
+ sort.Strings(keys)
+ parts := make([]string, 0, len(keys)+1)
+ for _, k := range keys {
+ parts = append(parts, k+"="+bundleETags[k])
+ }
+ parts = append(parts, "overlay="+overlayETag)
+ return hostwrite.Identity{Config: "etags:" + strings.Join(parts, ";")}
+}
// The held actions and the landing's rights hostbox.Policy.Check asks
// Policy.Moves about (wakepolicy.go).
@@ -95,76 +154,118 @@ var movesPolicyActions = map[string]bool{
movesActionRestore: true, movesActionSpawn: true, movesActionSeed: true, movesActionWriter: true, movesActionInstall: true,
}
-// MovesClaimPolicy is hostbox.Policy.Moves for the moves plans: see the
-// file comment for what it grants.
-func MovesClaimPolicy(ctx context.Context, c *hostclaim.Claim, s hostbox.Selection, id hostwrite.Identity) error {
- refuse := func(reason string) error {
- r := &MovesPolicyRefusal{Host: s.Host, Op: s.Op, Action: s.Action, Box: s.Box, Reason: reason}
- if c != nil {
- r.Kind, r.Phase = c.Op.Kind, c.Op.Phase
+// MovesClaimPolicy builds hostbox.Policy.Moves for the moves plans over a
+// record reader: see the file comment for what it grants.
+func MovesClaimPolicy(read MovesRecordReader) func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error {
+ return func(ctx context.Context, c *hostclaim.Claim, s hostbox.Selection, id hostwrite.Identity) error {
+ refuse := func(reason string, cause error) error {
+ r := &MovesPolicyRefusal{Host: s.Host, Op: s.Op, Action: s.Action, Box: s.Box, Reason: reason, Cause: cause}
+ if c != nil {
+ r.Kind, r.Phase = c.Op.Kind, c.Op.Phase
+ }
+ return r
}
- return r
- }
- if ctx == nil {
- return refuse("no context")
- }
- if err := ctx.Err(); err != nil {
- return err
- }
- if c == nil || c.Holder == nil {
- return refuse("the claim has no holder")
- }
- if !movesPolicyActions[s.Action] {
- return refuse("not a moves action")
- }
- if s.Host == "" || s.Box == "" || s.Region == "" || s.Op == "" {
- return refuse("the selection names no host, box, region or op")
- }
- if c.Host != s.Host {
- return refuse("the claim is another host's")
- }
- if c.Op.ID != s.Op {
- return refuse("the claim is under another op")
- }
- if c.Op.Kind != s.Kind || c.Op.Phase != s.Phase {
- return refuse("the claim's kind or phase is not the selection's")
- }
- if s.Captured.Stamp == 0 || c.Holder.Stamp != s.Captured.Stamp || c.Holder.Incarnation != s.Captured.Incarnation {
- return refuse("the capture is not the holder's")
- }
- switch c.Op.Kind {
- case hostclaim.KindLanding:
- case hostclaim.KindMoving, hostclaim.KindReverting, hostclaim.KindRestoring:
- return refuse("only the landing writes held state")
- default:
- return refuse("not a move")
- }
- if c.Op.Exec != s.Box {
- return refuse("this box is not the executor")
- }
- if len(c.Op.Participants) != 2 || c.Op.Participants[1] != s.Box || c.Op.Intent["target"] != s.Box || c.Op.Intent["source"] == s.Box {
- return refuse("this box is not the move's target")
- }
- if c.Op.Intent["to"] != s.Region {
- return refuse("this region is not the move's target region")
- }
- for _, lost := range c.Lost {
- if lost == s.Box {
- return refuse("this box is declared lost")
+ if ctx == nil {
+ return refuse("no context", nil)
}
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if c == nil || c.Holder == nil {
+ return refuse("the claim has no holder", nil)
+ }
+ if !movesPolicyActions[s.Action] {
+ return refuse("not a moves action", nil)
+ }
+ if s.Host == "" || s.Box == "" || s.Region == "" || s.Op == "" {
+ return refuse("the selection names no host, box, region or op", nil)
+ }
+ if c.Host != s.Host {
+ return refuse("the claim is another host's", nil)
+ }
+ if c.Op.ID != s.Op {
+ return refuse("the claim is under another op", nil)
+ }
+ if c.Op.Kind != s.Kind || c.Op.Phase != s.Phase {
+ return refuse("the claim's kind or phase is not the selection's", nil)
+ }
+ if s.Captured.Stamp == 0 || c.Holder.Stamp != s.Captured.Stamp || c.Holder.Incarnation != s.Captured.Incarnation {
+ return refuse("the capture is not the holder's", nil)
+ }
+ switch c.Op.Kind {
+ case hostclaim.KindLanding:
+ case hostclaim.KindMoving, hostclaim.KindReverting, hostclaim.KindRestoring:
+ return refuse("only the landing writes held state", nil)
+ default:
+ return refuse("not a move", nil)
+ }
+ if c.Op.Exec != s.Box {
+ return refuse("this box is not the executor", nil)
+ }
+ if len(c.Op.Participants) != 2 || c.Op.Participants[1] != s.Box || c.Op.Intent["target"] != s.Box || c.Op.Intent["source"] == s.Box {
+ return refuse("this box is not the move's target", nil)
+ }
+ if c.Op.Intent["to"] != s.Region {
+ return refuse("this region is not the move's target region", nil)
+ }
+ for _, lost := range c.Lost {
+ if lost == s.Box {
+ return refuse("this box is declared lost", nil)
+ }
+ }
+ if !movesPhaseRights[c.Op.Phase][s.Action] {
+ return refuse("the phase does not permit the action", nil)
+ }
+ if !movesIdentityFits(s.Action, id) {
+ return refuse("the identity does not fit the action", nil)
+ }
+ if s.Action != movesActionHeldConfig && s.Action != movesActionHeldRelease {
+ return nil
+ }
+ // The held config and release are the move's pinned inputs (§9.1,
+ // §9.2), read from the record this claim's holder names.
+ if read == nil {
+ return refuse("no record reader", nil)
+ }
+ if c.Holder.Account == "" || c.Holder.App == "" {
+ return refuse("the holder names no account or app", nil)
+ }
+ rec, err := read(ctx, c.Holder.Account, c.Holder.App, c.Host)
+ if err != nil {
+ return refuse("the record cannot be read", err)
+ }
+ if rec == nil || rec.Host != c.Host || rec.Move == nil || rec.Move.ID != s.Op || rec.Move.Incarnation != c.Holder.Incarnation || rec.Move.State != provision.MoveLanding {
+ return refuse("the record is not this landing", nil)
+ }
+ in := rec.Move.Inputs
+ if in == nil {
+ return refuse("no pinned inputs", nil)
+ }
+ switch s.Action {
+ case movesActionHeldRelease:
+ if in.Release == "" {
+ return refuse("no pinned release", nil)
+ }
+ if id.Release != in.Release {
+ return refuse("the release is not the pinned one", nil)
+ }
+ case movesActionHeldConfig:
+ pinned := HeldConfigIdentity(in.BundleETags, in.OverlayETag)
+ if pinned.Config == "" {
+ return refuse("no pinned config", nil)
+ }
+ if id.Config != pinned.Config {
+ return refuse("the config is not the pinned one", nil)
+ }
+ }
+ return nil
}
- if !movesPhaseRights[c.Op.Phase][s.Action] {
- return refuse("the phase does not permit the action")
- }
- if !movesIdentityFits(s.Action, id) {
- return refuse("the identity does not fit the action")
- }
- return nil
}
-// movesIdentityFits: a held-config write names the rendered config and no
-// release, a held-release write the release and no config, and every other
-// action carries no identity at all.
+// movesIdentityFits: a held-config write names the config and no release,
+// a held-release write the release and no config, and every other action
+// carries no identity at all. Which config and release is the record's
+// business, checked after this.
func movesIdentityFits(action string, id hostwrite.Identity) bool {
switch action {
case movesActionHeldConfig:
diff --git a/internal/host/movespolicy_test.go b/internal/host/movespolicy_test.go
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