Files
mesh-controller/internal/inventory/provisioning.go
T
mesh-tools 97a2d841c7 Land feature 57's controller work on its release branch
The controller plans config, templates and moved providers' provisions,
hands bus accounts to the vault and judges the new manifest words, on the
feature's SDK and node-engine validator (feature 57, config and provisioning).
2026-10-15 21:27:33 +00:00

518 lines
22 KiB
Go

package inventory
import (
"context"
"encoding/json"
"fmt"
"path"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue"
)
// The provisioner's plan of a module on a node (novox/hq ADR 0349 clause 4, design 57 P5, issue 671): what a node's
// provisioner client needs to fill the module's files there, carried in the assignment's value of the assignments
// mirror, `<node>.<module>`, which the client already reads for its own machine alone (broker.ProvisionerReads).
//
// **Placement is the controller's, and on the bus** (ADR 0349 clauses 1 and 6): the client watches each owner's
// keys for its node, and this is the placement owner's key. So the client never reads the node's declaration,
// which the node-engine keeps for root alone. The plan names the templates archive the module's build packed (by
// its reference and digest), each file the provisioner fills (its id, path, template and who owns it at runtime),
// the value of every placement placeholder its templates name, and the name of each of the module's own secrets the
// declaration names there (design 57 V4): the client asks the vault for those, and the vault hands one only when
// the plan names it. No secret's value: placement is not one, and a secret is never in a bucket (ADR 0201).
//
// Composed only for a module handed to the provisioner (design 57 P6); every other assignment's value carries no
// plan and is the bytes it was. A module handed there that nothing assigned, only pulled in by another, has a value
// of its own under the same key, naming what requires it (issue 765).
// Provisioning is one module's plan on one node.
type Provisioning struct {
// Templates is the archive the module's build packed its templates directory into, fetched by digest.
Templates TemplatesArchive `json:"templates"`
// Files are the files the provisioner fills for the module on this node, in the manifest's order.
Files []ProvisionedFile `json:"files"`
// Placement is the value of each placement placeholder the templates name, keyed as the placeholder names
// it without its braces: `dir:state`, `machine:account`, `port:8080`, `seat:mesh-broker:4222`, `access:tls`.
Placement map[string]string `json:"placement,omitempty"`
// Secrets are the module's own secrets its declaration names on this node, by name (design 57 V4, issue 647):
// each its templates name as `${secret:<name>}`, and each it keeps in a file of its own, with that file's path.
// The client asks the vault for each; the vault hands it one only when its plan names it.
Secrets []ProvisionedSecret `json:"secrets,omitempty"`
// Items are the module's items of collections (design 57 L1, issue 684): each its collection and its template
// inside the archive, filled by the client and recorded in mesh-provisioner_collect for the node.
Items []ProvisionedItem `json:"items,omitempty"`
// Collections are the collections the module's files name, as their collector, each with the comment that
// introduces each contributor's item.
Collections []ProvisionedCollection `json:"collections,omitempty"`
// Provisions are what the module requires on this node (issue 760), each under its local name with the
// provider resolution chose to serve it: the providing machine and module. The vault reads them to see a
// consumer bind to a provider and unbind from it (novox/hq ADR 0349 clause 8); of a mesh-scoped provision with
// several providers, it is the one serving this consumer. An entry with a name is a credential the vault keeps
// for this holder alone (issue 757): the value the provider's software issued for it, by the name the module's
// files call it and the file holding it. The client asks the vault for
// provision/<provider>/<provision>/<node>/<module>; the vault hands it only when the plan names it so.
Provisions []ProvisionedCredential `json:"provisions,omitempty"`
// RequiredBy is, for a module only pulled in by another on this node (issue 765), the module that requires it
// there: the plan is then mirrored in a value of its own, which says so (AssignmentMirrored.RequiredBy). Kept
// with the send; never part of the plan the client reads.
RequiredBy string `json:"-"`
}
// ProvisionedItem is one item the module contributes.
type ProvisionedItem struct {
Collection string `json:"collection"`
// Name tells an item from the module's others of one collection, and Attributes are what a placeholder selecting
// by them (`${collected:<collection>:<k>=<v>,…}`) is matched against (design 57 L3, issue 777).
Name string `json:"name,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
Template string `json:"template"`
}
// ProvisionedCollection is one collection the module collects.
type ProvisionedCollection struct {
Name string `json:"name"`
Comment string `json:"comment,omitempty"`
}
// ProvisionedCredential is one provision the module requires, under one local name, and its provider (issue 760);
// with a name, a credential issued for this holder alone (issue 757).
type ProvisionedCredential struct {
Name string `json:"name,omitempty"`
Provision string `json:"provision"`
Local string `json:"local,omitempty"`
Provider string `json:"provider"`
ProviderModule string `json:"provider-module"`
// CredentialForm is the form the provider's offer declares for its credential (issue 768): shared, pair,
// issued or given; empty when the provider's manifest was not in hand.
CredentialForm string `json:"credential-form,omitempty"`
Path string `json:"path,omitempty"`
}
// ProvisionedSecret is one of the module's own secrets, by its name in the manifest. Path, when set, is the file
// holding the secret alone (the manifest's `own-secrets`), which the client fills with its value and nothing else.
type ProvisionedSecret struct {
Name string `json:"name"`
Path string `json:"path,omitempty"`
}
// TemplatesArchive is where the templates archive is fetched from, and what it hashes to.
type TemplatesArchive struct {
Source string `json:"source"`
Digest string `json:"digest"`
}
// ProvisionedFile is one file the provisioner fills.
type ProvisionedFile struct {
// ID is the file resource's id in the module's manifest.
ID string `json:"id"`
// Path is where it is placed: one of the node's declaration's filled files, which the node-engine writes
// only for the client (ADR 0349 clause 4).
Path string `json:"path"`
// Template is the file's template by its path inside the templates archive: `dunstrc` for a manifest's
// `files/dunstrc`, the module's templates directory (files/, or the one it names, issue 731) being the
// archive's root.
Template string `json:"template"`
// OwnerAtRuntime is `owned`, `seed` or `managed` (ADR 0349 clause 5).
OwnerAtRuntime string `json:"owner-at-runtime"`
// Takes is how its program takes a change (ADR 0349 clause 2): `restart-on`, `reload-on` or `read-on-use`.
// Said for the client's report; the node-engine restarts or reloads on the write itself.
Takes string `json:"takes,omitempty"`
}
// The words a plan's file says who owns it at runtime with, and how its program takes a change.
var (
ownersAtRuntime = []string{"owned", "seed", "managed"}
takings = []string{"restart-on", "reload-on", "read-on-use"}
)
// PlacementKinds are the placeholder kinds placement answers (ADR 0349 clause 2), as a plan's Placement keys
// begin.
var PlacementKinds = []string{"dir", "access", "port", "seat", "machine", "backup"}
// credentialForms are the forms a provider's offer may declare for its credential (issue 768).
var credentialForms = []string{catalogue.CredentialFormShared, catalogue.CredentialFormPair,
catalogue.CredentialFormIssued, catalogue.CredentialFormGiven}
var (
digestShape = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
fileIDShape = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// secretShape is a secret's name as `${secret:…}` names it (catalogue secrets_into_files.go).
secretShape = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// collectionShape is a collection's name (catalogue collections.go).
collectionShape = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(:[a-z0-9][a-z0-9-]*)*$`)
// attributeKeyShape and attributeValueShape are an item's attribute as the catalogue writes it (collections.go).
attributeKeyShape = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
attributeValueShape = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
// machineShape is a machine's name as the vault writes it in a secret's name (mesh-vault intake.go namePart).
machineShape = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
)
// Problems is what is wrong with a plan: each a sentence naming the field. A plan with any is never mirrored, so a
// client is never handed one it would have to guess at.
func (p Provisioning) Problems() []string {
var problems []string
if strings.TrimSpace(p.Templates.Source) == "" {
problems = append(problems, "templates.source is empty: the client has nowhere to fetch the templates from")
}
if !digestShape.MatchString(p.Templates.Digest) {
problems = append(problems, fmt.Sprintf("templates.digest %q is not sha256:<64 hex characters>",
p.Templates.Digest))
}
if len(p.Files) == 0 && len(p.Items) == 0 {
problems = append(problems, "files and items are empty: a module handed to the provisioner has a file it "+
"fills or an item it contributes")
}
contributed := map[string]bool{}
for i, it := range p.Items {
key := it.Collection
if it.Name != "" {
key += "/" + it.Name
}
where := fmt.Sprintf("items[%d]", i)
switch {
case !collectionShape.MatchString(it.Collection):
problems = append(problems, fmt.Sprintf("%s: collection %q is not a collection's name", where,
it.Collection))
case it.Name != "" && !fileIDShape.MatchString(it.Name):
problems = append(problems, fmt.Sprintf("%s: name %q is not a name", where, it.Name))
case contributed[key]:
problems = append(problems, fmt.Sprintf("%s: %s %s is contributed to twice", where,
map[bool]string{true: "item", false: "collection"}[it.Name != ""], key))
}
contributed[key] = true
for _, k := range sortedAttributeKeys(it.Attributes) {
if !attributeFits(it.Collection, k, it.Attributes[k]) {
problems = append(problems, fmt.Sprintf("%s: attribute %s=%q is not a name and a value", where, k,
it.Attributes[k]))
}
}
if it.Template == "" || strings.HasPrefix(it.Template, "/") || path.Clean(it.Template) != it.Template ||
it.Template == ".." || strings.HasPrefix(it.Template, "../") {
problems = append(problems, fmt.Sprintf("%s: template %q is not a plain path inside the templates "+
"archive", where, it.Template))
}
}
collected := map[string]bool{}
for i, c := range p.Collections {
where := fmt.Sprintf("collections[%d]", i)
switch {
case !collectionShape.MatchString(c.Name):
problems = append(problems, fmt.Sprintf("%s: %q is not a collection's name", where, c.Name))
case collected[c.Name]:
problems = append(problems, fmt.Sprintf("%s: %s is named twice", where, c.Name))
}
collected[c.Name] = true
if strings.ContainsAny(c.Comment, "\n\r") {
problems = append(problems, fmt.Sprintf("%s: the comment of %s holds a line break", where, c.Name))
}
}
ids, paths := map[string]bool{}, map[string]bool{}
for i, f := range p.Files {
where := fmt.Sprintf("files[%d]", i)
switch {
case !fileIDShape.MatchString(f.ID):
problems = append(problems, fmt.Sprintf("%s: id %q is not a resource id", where, f.ID))
case ids[f.ID]:
problems = append(problems, fmt.Sprintf("%s: id %q is named twice", where, f.ID))
}
ids[f.ID] = true
switch {
case !filepath.IsAbs(f.Path) || filepath.Clean(f.Path) != f.Path:
problems = append(problems, fmt.Sprintf("%s: path %q is not a clean absolute path", where, f.Path))
case paths[f.Path]:
problems = append(problems, fmt.Sprintf("%s: path %q is named twice", where, f.Path))
}
paths[f.Path] = true
if f.Template == "" || strings.HasPrefix(f.Template, "/") || path.Clean(f.Template) != f.Template ||
f.Template == ".." || strings.HasPrefix(f.Template, "../") {
problems = append(problems, fmt.Sprintf("%s: template %q is not a plain path inside the templates "+
"archive", where, f.Template))
}
if !slices.Contains(ownersAtRuntime, f.OwnerAtRuntime) {
problems = append(problems, fmt.Sprintf("%s: owner-at-runtime %q is not %s", where, f.OwnerAtRuntime,
strings.Join(ownersAtRuntime, ", ")))
}
if f.Takes != "" && !slices.Contains(takings, f.Takes) {
problems = append(problems, fmt.Sprintf("%s: takes %q is not %s", where, f.Takes,
strings.Join(takings, ", ")))
}
}
named := map[string]bool{}
for _, sec := range p.Secrets {
if !secretShape.MatchString(sec.Name) {
problems = append(problems, fmt.Sprintf("secret %q is not a secret's name", sec.Name))
}
if named[sec.Name] {
problems = append(problems, fmt.Sprintf("secret %s is named twice", sec.Name))
}
named[sec.Name] = true
switch {
case sec.Path == "":
case !filepath.IsAbs(sec.Path) || filepath.Clean(sec.Path) != sec.Path:
problems = append(problems, fmt.Sprintf("secret %s: path %q is not a clean absolute path", sec.Name,
sec.Path))
case paths[sec.Path]:
problems = append(problems, fmt.Sprintf("secret %s: path %s is named twice", sec.Name, sec.Path))
default:
paths[sec.Path] = true
}
}
bound := map[[2]string]bool{}
for _, pr := range p.Provisions {
which := pr.Name
if which == "" {
which = pr.Provision
if pr.Local != "" {
which += " (" + pr.Local + ")"
}
}
switch {
case pr.Name == "":
case !secretShape.MatchString(pr.Name):
problems = append(problems, fmt.Sprintf("provision credential %q is not a secret's name", pr.Name))
case named[pr.Name]:
problems = append(problems, fmt.Sprintf("secret %s is named twice", pr.Name))
}
if pr.Name != "" {
named[pr.Name] = true
}
if !secretShape.MatchString(pr.Provision) {
problems = append(problems, fmt.Sprintf("%s: provision %q is not a provision's name", which,
pr.Provision))
}
if !machineShape.MatchString(pr.Provider) {
problems = append(problems, fmt.Sprintf("%s: provider %q is not a machine's name", which, pr.Provider))
}
if !secretShape.MatchString(pr.ProviderModule) {
problems = append(problems, fmt.Sprintf("%s: provider-module %q is not a module's name", which,
pr.ProviderModule))
}
if pr.CredentialForm != "" && !slices.Contains(credentialForms, pr.CredentialForm) {
problems = append(problems, fmt.Sprintf("%s: credential-form %q is not %s", which, pr.CredentialForm,
strings.Join(credentialForms, ", ")))
}
if key := [2]string{pr.Provision, pr.Local}; bound[key] {
problems = append(problems, fmt.Sprintf("%s: its provider is named twice; a consumer has one provider "+
"for a provision and local name", which))
} else {
bound[key] = true
}
switch {
case pr.Path == "":
case pr.Name == "":
problems = append(problems, fmt.Sprintf("%s: path %q names a file and no credential to fill it with",
which, pr.Path))
case !filepath.IsAbs(pr.Path) || filepath.Clean(pr.Path) != pr.Path:
problems = append(problems, fmt.Sprintf("secret %s: path %q is not a clean absolute path", pr.Name,
pr.Path))
case paths[pr.Path]:
problems = append(problems, fmt.Sprintf("secret %s: path %s is named twice", pr.Name, pr.Path))
default:
paths[pr.Path] = true
}
}
keys := make([]string, 0, len(p.Placement))
for k := range p.Placement {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
kind, name, ok := strings.Cut(k, ":")
if !ok || name == "" || !slices.Contains(PlacementKinds, kind) {
problems = append(problems, fmt.Sprintf("placement %q is not <kind>:<name> of a placement kind (%s)", k,
strings.Join(PlacementKinds, ", ")))
}
}
return problems
}
// SetProvisioningPlans gives the assignments mirror the provisioner's plans: plans composes each assignment's of a
// module handed to the provisioner, keyed `<node>.<module>`. Set once, before the mirror is first written.
func (i *Inventory) SetProvisioningPlans(plans func(ctx context.Context) (map[string]Provisioning, error)) {
i.provisioningPlans = plans
}
// AssignmentValues is the assignments mirror's values, keyed `<node>.<module>`: each assignment, with its plan
// when plans has one for its key, and each plan of a module only pulled in by another (RequiredBy) in a value of its
// own, which names what requires it and no time assigned (issue 765). A plan for a module neither assigned nor
// required there, or one with problems, is refused by name, never written: a client is never told to fill a module
// that is not on its node, or handed a plan it must guess at.
func AssignmentValues(assignments []AssignmentMirrored, plans map[string]Provisioning) (map[string][]byte, error) {
out := map[string][]byte{}
used := map[string]bool{}
var problems []string
for _, a := range assignments {
key := a.Node + "." + a.Module
if p, ok := plans[key]; ok {
used[key] = true
if wrong := p.Problems(); len(wrong) > 0 {
problems = append(problems, fmt.Sprintf("%s: %s", key, strings.Join(wrong, "; ")))
continue
}
p := p
a.Provisioning = &p
}
value, err := json.Marshal(a)
if err != nil {
return nil, err
}
if len(value) > broker.MirrorValueBytes {
problems = append(problems, fmt.Sprintf("%s: its value is %d bytes, over the mirror's %d", key,
len(value), broker.MirrorValueBytes))
continue
}
out[key] = value
}
for _, key := range sortedPlanKeys(plans) {
if used[key] {
continue
}
p := plans[key]
// A key is `<node>.<module>`; a module's name holds no dot, a machine's may.
cut := strings.LastIndex(key, ".")
if p.RequiredBy == "" || cut <= 0 {
problems = append(problems, fmt.Sprintf("%s: a plan for a module not assigned there", key))
continue
}
if wrong := p.Problems(); len(wrong) > 0 {
problems = append(problems, fmt.Sprintf("%s: %s", key, strings.Join(wrong, "; ")))
continue
}
value, err := json.Marshal(AssignmentMirrored{Node: key[:cut], Module: key[cut+1:], RequiredBy: p.RequiredBy,
Provisioning: &p})
if err != nil {
return nil, err
}
if len(value) > broker.MirrorValueBytes {
problems = append(problems, fmt.Sprintf("%s: its value is %d bytes, over the mirror's %d", key,
len(value), broker.MirrorValueBytes))
continue
}
out[key] = value
}
if len(problems) > 0 {
return nil, fmt.Errorf("the provisioner's plans are refused, and the assignments mirror is not written: %s",
strings.Join(problems, "; "))
}
return out, nil
}
func sortedPlanKeys(plans map[string]Provisioning) []string {
keys := make([]string, 0, len(plans))
for k := range plans {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// The plans a machine was last sent (design 57 P6, issue 672): composed with its declaration, kept with the send
// (migration 0100) by module, and carried by the assignments mirror in each handed module's assignment. So the
// client is handed the plan of the build its machine was sent, and a controller started afresh mirrors the same.
// SentProvisioningPlans is every plan the machines were last sent, keyed `<node>.<module>`: what
// SetProvisioningPlans is given. A plan of a module only pulled in by another is kept with what required it, and is
// mirrored in a value of its own until a send drops it (issue 765). A plan of a module assigned when it was sent and
// no longer assigned there is left out: the machine's next send drops it.
func (i *Inventory) SentProvisioningPlans(ctx context.Context) (map[string]Provisioning, error) {
rows, err := i.store.Pool().Query(ctx,
`select n.name, n.sent_provisioning from node n where n.sent_provisioning is not null`)
if err != nil {
return nil, err
}
defer rows.Close()
sent := map[string]map[string]Provisioning{}
for rows.Next() {
var node string
var raw []byte
if err := rows.Scan(&node, &raw); err != nil {
return nil, err
}
var kept map[string]keptPlan
if err := json.Unmarshal(raw, &kept); err != nil {
return nil, fmt.Errorf("the plans %s was sent cannot be read: %w", node, err)
}
plans := map[string]Provisioning{}
for module, k := range kept {
p := k.Provisioning
p.RequiredBy = k.RequiredBy
plans[module] = p
}
sent[node] = plans
}
if err := rows.Err(); err != nil {
return nil, err
}
assignments, err := i.assignmentsMirrored(ctx)
if err != nil {
return nil, err
}
out := map[string]Provisioning{}
for _, a := range assignments {
if p, ok := sent[a.Node][a.Module]; ok {
p.RequiredBy = ""
out[a.Node+"."+a.Module] = p
}
}
for node, plans := range sent {
for module, p := range plans {
if _, assigned := out[node+"."+module]; !assigned && p.RequiredBy != "" {
out[node+"."+module] = p
}
}
}
return out, nil
}
// keptPlan is one module's plan as a send keeps it: the plan, and for a module only pulled in by another, the module
// that requires it there (issue 765).
type keptPlan struct {
Provisioning
RequiredBy string `json:"required-by,omitempty"`
}
// provisionedBy is, from a send's plans as kept, each handed module's templates digest; nil for none.
func provisionedBy(raw []byte) (map[string]string, error) {
if len(raw) == 0 {
return nil, nil
}
var plans map[string]Provisioning
if err := json.Unmarshal(raw, &plans); err != nil {
return nil, err
}
if len(plans) == 0 {
return nil, nil
}
out := map[string]string{}
for module, p := range plans {
out[module] = p.Templates.Digest
}
return out, nil
}
func sortedAttributeKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// attributeFits is whether an item's attribute is of the shape its collection allows: words of lower case, except the
// environment's `name`, which is a variable's name (catalogue collections.go, design 57 L3, issue 795).
func attributeFits(collection, k, v string) bool {
if collection == "environment" && k == "name" {
return environmentVariableName.MatchString(v)
}
return attributeKeyShape.MatchString(k) && attributeValueShape.MatchString(v)
}
var environmentVariableName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)