From 4a967933b3686f264a998ec6b792e430a5ceda5c Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 29 Oct 2026 22:32:15 +0100 Subject: [PATCH] Lay out a provision as a key of mesh-provisioner holding a reference and never a value, and a plan as a key of its own, so the holder, the vault, the clients and the providers read one record (issue 1954 L6-P2, ADR 0570 clauses 1, 2) --- go/catalogue/upgrade_test.go | 5 +- go/provisions/provisions.go | 220 +++++++++++++++++++++++++++++++ go/provisions/provisions_test.go | 78 +++++++++++ 3 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 go/provisions/provisions.go create mode 100644 go/provisions/provisions_test.go diff --git a/go/catalogue/upgrade_test.go b/go/catalogue/upgrade_test.go index 79cfbff..e007897 100644 --- a/go/catalogue/upgrade_test.go +++ b/go/catalogue/upgrade_test.go @@ -53,10 +53,7 @@ func TestWhereAModulesUpgradePolicyComesFrom(t *testing.T) { t.Errorf("%+v was accepted", p) } } -} - -// A policy other than roll says why, and an unknown one is refused. -func TestAnUpgradePolicySaysWhy(t *testing.T) { + // A policy other than roll says why, and an unknown one is refused. for _, u := range []UpgradePolicy{{Policy: PolicyRecord}, {Policy: PolicyTogether}, {Policy: "sometimes", Why: "x"}} { if problems := (Manifest{Module: "m", Upgrade: &u}).upgradeProblems(); len(problems) == 0 { t.Errorf("%+v was accepted", u) diff --git a/go/provisions/provisions.go b/go/provisions/provisions.go new file mode 100644 index 0000000..4981dd4 --- /dev/null +++ b/go/provisions/provisions.go @@ -0,0 +1,220 @@ +// Package provisions is the contract of the seat mesh-provisioner's records (novox/hq ADR 0570 clauses 1, 2 and 5; issue +// 1954): every provision one key of the state `provisions`, holding a reference and never a value, and every handed +// module's provisioning plan one key of the state `plans`. The module mesh-provisioner alone writes both; the vault, +// each node's provisioner client and every provider read them through `reads`. +package provisions + +import ( + "bytes" + "encoding/json" + "fmt" + "regexp" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The seat, its holder and its two states, and the names a reader declares in its `reads`. +const ( + Seat = "mesh-provisioner" + Module = "mesh-provisioner" + ProvisionsState = "provisions" + PlansState = "plans" + ProvisionsRead = Module + "." + ProvisionsState + PlansRead = Module + "." + PlansState + // ProvisionsBucket and PlansBucket are the states' buckets. + ProvisionsBucket = Module + "_" + ProvisionsState + PlansBucket = Module + "_" + PlansState +) + +// The states of a provision (clause 1). +const ( + // Asked is a provision its consumer needs that its provider has not answered yet, or whose dependencies are not + // all provided (clause 3). + Asked = "asked" + // Provided is a provision the provider said it made: set only from the provider's outcome (clause 3). + Provided = "provided" + // Waits is a provision only a person can give, naming what is awaited (clause 7). + Waits = "waits" + // Failed is a provision the provider could not make, with why. + Failed = "failed" + // Retiring is a provision no want needs, asked of the operator before its credential goes (clause 5). + Retiring = "retiring" +) + +// States is every state a provision may be in. +var States = []string{Asked, Provided, Waits, Failed, Retiring} + +// Version is the version of the records' layout. +const Version = 1 + +// token is one part of a key: what a bucket's key takes, no dot. +var token = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +// Key is a provision's key: `..`, and `.` where the consumer keeps several values of +// one provision (ADR 0094). The same key the provider's own entry for that consumer has (provider.Entry.Key). +func Key(node, consumer, provision, local string) (string, error) { + parts := []string{node, consumer, provision} + if local != "" { + parts = append(parts, local) + } + for _, p := range parts { + if !token.MatchString(p) { + return "", fmt.Errorf("provisions: %q cannot be part of a provision's key", p) + } + } + return strings.Join(parts, "."), nil +} + +// ParseKey is the inverse of Key. +func ParseKey(key string) (node, consumer, provision, local string, err error) { + parts := strings.Split(key, ".") + if len(parts) < 3 || len(parts) > 4 { + return "", "", "", "", fmt.Errorf("provisions: %q is not ..[.]", key) + } + for _, p := range parts { + if !token.MatchString(p) { + return "", "", "", "", fmt.Errorf("provisions: %q is not ..[.]", key) + } + } + if len(parts) == 4 { + local = parts[3] + } + return parts[0], parts[1], parts[2], local, nil +} + +// PlanKey is a plan's key in `plans`: `.` (clause 2). +func PlanKey(node, module string) (string, error) { + if !token.MatchString(node) || !token.MatchString(module) { + return "", fmt.Errorf("provisions: %s.%s is not a plan's key, .", node, module) + } + return node + "." + module, nil +} + +// End is one end of a provision: a module on a node. +type End struct { + Module string `json:"module"` + Node string `json:"node"` +} + +// Reference is a value the vault keeps, by its id and the generation of the value: never the value, sealed or not +// (ADR 0349 §7). The holder writes the generation the vault says in its own entry (clause 6). +type Reference struct { + ID string `json:"id"` + Generation int `json:"generation"` +} + +// Warrant is the operator's answer to a retiring provision's ask, as the holder writes it into the key (clause 5). +type Warrant struct { + Ask string `json:"ask"` + Option string `json:"option"` + By string `json:"by"` + At time.Time `json:"at"` +} + +// Provision is one key of `provisions`. +type Provision struct { + V int `json:"v"` + Provision string `json:"provision"` + Local string `json:"local,omitempty"` + Consumer End `json:"consumer"` + Provider End `json:"provider"` + // DependsOn are the keys of the provisions this one waits for (clause 3): a database user whose password is the + // vault's. + DependsOn []string `json:"depends-on,omitempty"` + State string `json:"state"` + // Why is the reason for waits, failed and retiring, and for asked when a dependency holds it. + Why string `json:"why,omitempty"` + Reference *Reference `json:"reference,omitempty"` + // Ask is a retiring provision's ask of the operator; Warrant the answer, once given. + Ask string `json:"ask,omitempty"` + Warrant *Warrant `json:"warrant,omitempty"` + At time.Time `json:"at"` +} + +// Key is the provision's key. +func (p Provision) Key() (string, error) { + return Key(p.Consumer.Node, p.Consumer.Module, p.Provision, p.Local) +} + +func (p Provision) check() error { + if _, err := p.Key(); err != nil { + return err + } + if p.Provider.Module == "" || p.Provider.Node == "" { + return fmt.Errorf("provisions: %s names no provider", p.Provision) + } + known := false + for _, s := range States { + known = known || s == p.State + } + switch { + case !known: + return fmt.Errorf("provisions: a provision's state is one of %s, not %q", strings.Join(States, ", "), p.State) + case (p.State == Waits || p.State == Failed || p.State == Retiring) && strings.TrimSpace(p.Why) == "": + return fmt.Errorf("provisions: a %s provision says why", p.State) + case p.Warrant != nil && p.State != Retiring: + return fmt.Errorf("provisions: only a retiring provision carries the operator's warrant") + } + for _, d := range p.DependsOn { + if _, _, _, _, err := ParseKey(d); err != nil { + return err + } + } + return nil +} + +// Encode is p as JSON at Version; it refuses what a holder may not write. +func Encode(p Provision) ([]byte, error) { + p.V = Version + if err := p.check(); err != nil { + return nil, err + } + return json.Marshal(p) +} + +// Decode reads a key's value strictly: a field the layout does not name is refused, so no value, sealed or not, rides in +// a provision (clause 1, ADR 0201 §13). +func Decode(data []byte) (Provision, error) { + var p Provision + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&p); err != nil { + return Provision{}, fmt.Errorf("provisions: a provision is not readable: %w", err) + } + if p.V != Version { + return Provision{}, fmt.Errorf("provisions: a provision of version %d, and this reads %d", p.V, Version) + } + return p, p.check() +} + +// Plan is one key of `plans`: a module's provisioning plan on a node, never a value (clause 2). +type Plan struct { + V int `json:"v"` + Node string `json:"node"` + Module string `json:"module"` + Plan catalogue.Plan `json:"plan"` +} + +// EncodePlan is p as JSON at Version. +func EncodePlan(p Plan) ([]byte, error) { + p.V = Version + if _, err := PlanKey(p.Node, p.Module); err != nil { + return nil, err + } + return json.Marshal(p) +} + +// DecodePlan reads a plan's key. +func DecodePlan(data []byte) (Plan, error) { + var p Plan + if err := json.Unmarshal(data, &p); err != nil { + return Plan{}, fmt.Errorf("provisions: a plan is not readable: %w", err) + } + if p.V != Version { + return Plan{}, fmt.Errorf("provisions: a plan of version %d, and this reads %d", p.V, Version) + } + _, err := PlanKey(p.Node, p.Module) + return p, err +} diff --git a/go/provisions/provisions_test.go b/go/provisions/provisions_test.go new file mode 100644 index 0000000..294ef2f --- /dev/null +++ b/go/provisions/provisions_test.go @@ -0,0 +1,78 @@ +package provisions + +import ( + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// ADR 0570 clauses 1, 2 and 5 (issue 1954 L6-P2): a provision's key is its consumer's node and module, the provision and +// its local name, the provider's entry's key; its value round-trips, names its state and why, and refuses any field the +// layout does not name, so no value rides in it; a plan's key is its node and module. +func TestAProvisionIsAReferenceUnderItsConsumersKey(t *testing.T) { + at := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC) + db := Provision{Provision: "postgres-database", Consumer: End{Module: "keycloak", Node: "novox"}, + Provider: End{Module: "postgres", Node: "novox"}, DependsOn: []string{"novox.keycloak.secret.db"}, State: Asked, + Reference: &Reference{ID: "provision/postgres/postgres-database", Generation: 2}, At: at} + for _, c := range []struct { + name string + edit func(*Provision) + key string + refused string + }{ + {name: "asked, depending on the vault's", key: "novox.keycloak.postgres-database"}, + {name: "under a local name", edit: func(p *Provision) { p.Local = "replica" }, key: "novox.keycloak.postgres-database.replica"}, + {name: "retiring with the operator's warrant", edit: func(p *Provision) { + p.State, p.Why, p.Ask = Retiring, "keycloak left novox", "a-1" + p.Warrant = &Warrant{Ask: "a-1", Option: "yes", By: "the operator", At: at} + }, key: "novox.keycloak.postgres-database"}, + {name: "an unknown state", edit: func(p *Provision) { p.State = "done" }, refused: "one of"}, + {name: "failed without why", edit: func(p *Provision) { p.State = Failed }, refused: "says why"}, + {name: "a warrant on a provided one", edit: func(p *Provision) { p.State, p.Warrant = Provided, &Warrant{Ask: "a"} }, refused: "only a retiring"}, + {name: "no provider", edit: func(p *Provision) { p.Provider = End{} }, refused: "no provider"}, + {name: "a dotted consumer", edit: func(p *Provision) { p.Consumer.Module = "a.b" }, refused: "cannot be part"}, + {name: "a dependency that is no key", edit: func(p *Provision) { p.DependsOn = []string{"x"} }, refused: "is not"}, + } { + p := db + if c.edit != nil { + c.edit(&p) + } + raw, err := Encode(p) + if c.refused != "" { + if err == nil || !strings.Contains(err.Error(), c.refused) { + t.Errorf("%s: %v, want refused saying %q", c.name, err, c.refused) + } + continue + } + back, err := Decode(raw) + key, kerr := back.Key() + if err != nil || kerr != nil || key != c.key || back.V != Version || back.Reference.Generation != 2 { + t.Errorf("%s: read back as %+v under %q, %v %v", c.name, back, key, err, kerr) + } + if node, consumer, provision, local, err := ParseKey(key); err != nil || node != "novox" || consumer != "keycloak" || + provision != "postgres-database" || local != p.Local { + t.Errorf("%s: %s parses as %s %s %s %s, %v", c.name, key, node, consumer, provision, local, err) + } + } + // A value never rides in a provision: a field the layout does not name is refused. + if _, err := Decode([]byte(`{"v":1,"provision":"p","consumer":{"module":"c","node":"n"},"provider":{"module":"p","node":"n"},` + + `"state":"provided","at":"2026-10-29T22:00:00Z","password":"hunter2"}`)); err == nil { + t.Error("a provision carrying a password was read") + } + plan := Plan{Node: "novox", Module: "restic", Plan: catalogue.Plan{}} + raw, err := EncodePlan(plan) + if err != nil { + t.Fatal(err) + } + if back, err := DecodePlan(raw); err != nil || back.Node != "novox" || back.Module != "restic" { + t.Errorf("a plan read back as %+v, %v", back, err) + } + if k, err := PlanKey("novox", "restic"); k != "novox.restic" || err != nil { + t.Errorf("a plan's key is %q, %v", k, err) + } + if ProvisionsBucket != "mesh-provisioner_provisions" || PlansRead != "mesh-provisioner.plans" { + t.Errorf("the states are %s and %s", ProvisionsBucket, PlansRead) + } +}