Files
mesh-catalog/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/plan.go
T
jschoubben 68e3326d4c
mesh/repo-check head 68e3326d4cbc merged into trunk 44e7002d3c84 beside mesh-controller#378@b56c1d00 — pass: its merge-check.sh passed, each part in its to…
mesh/delivery-group group feat/chain-on-the-bus ready: every member ready, and composed together they pass
mesh/merge-gate head 68e3326d4cbc merged into trunk 44e7002d3c84 — pass: group feat/chain-on-the-bus composed: every machine composes with the change as it…
mesh/delivery superseded: a newer head of the same pull request
Providers and the provisioner client take the SDK that reads an ended assignment as a removed key (issue 1098)
2026-10-18 02:40:18 +02:00

346 lines
16 KiB
Go

package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"path"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
)
// The provisioner's plan of a module on this node (novox/hq ADR 0349 clause 4, design 57 P5): placement's owner,
// the controller, carries it in the module's assignment, `<node>.<module>` of mesh-controller_assignments
// (mesh-controller internal/inventory Provisioning), which this client reads for its own machine alone. An
// assignment with no plan is a module the controller has not handed to the provisioner: its files are the
// node-engine's, from the declaration, and this client leaves them alone (ADR 0349 clause 17). A module only pulled
// in by another on this node, and handed, has a value of its own there, which names what requires it and no time it
// was assigned (issue 765): read and filled the same way.
// Assignment is one value of the assignments mirror. **Lenient at the top**, so a field the controller adds to an
// assignment (design 54 §1) does not stop the client; **strict in the plan**, which is the client's to act on.
type Assignment struct {
Node string `json:"node"`
Module string `json:"module"`
Provisioning json.RawMessage `json:"provisioning,omitempty"`
}
// Plan is one module's plan on this node.
type Plan struct {
Templates Templates `json:"templates"`
Files []PlannedFile `json:"files"`
Placement map[string]string `json:"placement,omitempty"`
// Secrets are the module's own secrets its declaration on this node names (design 57 V4): each one its
// templates name as `${secret:<name>}`, and each it keeps in a file of its own at Path. The vault hands this
// client a secret only when the plan names it (mesh-vault askers.go).
Secrets []PlannedSecret `json:"secrets,omitempty"`
// Items are the module's items of collections (ADR 0349 clause 13, design 57 L1): each filled from its template
// like a file, and recorded in mesh-provisioner_collect for this node instead of written to a file. The collector
// of each, on this node, is filled from every contributor's item there.
Items []PlannedItem `json:"items,omitempty"`
// Collections are the collections the module's templates name, as their collector: `${collected:<name>}`, or
// one of the three old namespaces read as a collection of the same name (`${shell:zsh:normal}` is the collection
// `shell:zsh:normal`). Each with the comment its tool writes a line in, which introduces each contributor's
// item with the contributor's name; none for a tool without one.
Collections []PlannedCollection `json:"collections,omitempty"`
// Provisions are what the module requires on this node, each with the provider serving it (issue 760), which
// the vault acts on and this client only reads. An entry with a name is a credential this client fills from the
// vault (issue 757): the value a provider's software issued for this module on this machine. Its templates name
// one as `${secret:<name>}`, and Path, when set, is a file holding it alone. The vault hands one only when the
// plan names it (mesh-vault askers.go).
Provisions []PlannedProvision `json:"provisions,omitempty"`
}
// PlannedItem is one item the module contributes: to which collection, and the template it is filled from.
//
// A module may give several items to one collection, each with a Name of its own, and any with Attributes a
// placeholder selecting by them is matched against (design 57 L3, issue 777). An item whose capability the machine
// lacks is not in the plan: the controller judged that.
type PlannedItem struct {
Collection string `json:"collection"`
Name string `json:"name,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
Template string `json:"template"`
}
// key is the item among its module's: the collection, and `/<name>` for a named item.
func (it PlannedItem) key() string {
if it.Name == "" {
return it.Collection
}
return it.Collection + "/" + it.Name
}
// PlannedCollection is one collection the module collects.
type PlannedCollection struct {
Name string `json:"name"`
Comment string `json:"comment,omitempty"`
}
// PlannedProvision is one provision the module requires, under its local name (empty for the ordinary one), and
// its provider: the machine and the module serving it. With a name, a credential of it this client fills: Name as
// the module's files name it (`${secret:<name>}`), and the file holding it alone, when there is one.
type PlannedProvision struct {
Name string `json:"name,omitempty"`
Provision string `json:"provision"`
Local string `json:"local,omitempty"`
Provider string `json:"provider"`
ProviderModule string `json:"provider-module,omitempty"`
// CredentialForm is the form the provider's offer declares for its credential (issue 768), which the vault acts
// on and this client only reads: one of credentialForms, or none from a controller before it.
CredentialForm string `json:"credential-form,omitempty"`
Path string `json:"path,omitempty"`
// Contributes is what this module tells the provider about itself for this provision (a database's name, its
// extensions): the consumer manifest's `contributes`, which the provider's helper reads and the client keeps.
Contributes map[string]any `json:"contributes,omitempty"`
// At, From and As are where the provider is on the private network, its machine, and who the consumer is at
// its end (issue 788); Names are what the mesh calls the consumer through this provision (issue 792). The
// provider's helper reads them from the plan; the client keeps them, so the plan reads whole (issue 954).
At string `json:"at,omitempty"`
From string `json:"from,omitempty"`
As string `json:"as,omitempty"`
Names map[string]string `json:"names,omitempty"`
// CredentialTool is, for a software-issued offer, the provider's tool the vault asks for the value (issue 928);
// the vault acts on it and this client only reads it.
CredentialTool string `json:"credential-tool,omitempty"`
}
// issuedForHolder is whether the entry names a value its provider's software issued for this holder alone (issue
// 757). An entry of the login-per-consumer form that names a file (issue 779) is still the bind it was: its provider
// lists this consumer and its bound values are read from it.
func (p PlannedProvision) issuedForHolder() bool { return p.Name != "" && p.CredentialForm != "pair" }
// credentialForms are the forms a provider's offer may declare for its credential (mesh-vault pair.go): one value
// every holder shares, a login per consumer, a value its software issues, or one a person gives.
var credentialForms = []string{"shared", "pair", "issued", "given"}
// PlannedSecret is one secret of the module's own, by its name in the module's manifest; Path, when set, is the
// file holding it alone (the manifest's `own-secrets`), which the client fills with its value and nothing else.
type PlannedSecret struct {
Name string `json:"name"`
Path string `json:"path,omitempty"`
}
// Templates is the archive the module's build packed its templates directory into: fetched from Source, and used only when it
// hashes to Digest.
type Templates struct {
Source string `json:"source"`
Digest string `json:"digest"`
}
// PlannedFile is one file the provisioner fills.
type PlannedFile struct {
ID string `json:"id"`
Path string `json:"path"`
Template string `json:"template"`
OwnerAtRuntime string `json:"owner-at-runtime"`
Takes string `json:"takes,omitempty"`
}
// The placeholder kinds placement answers, as the plan's Placement keys begin (ADR 0349 clause 2).
var placementKinds = []string{"dir", "access", "port", "seat", "machine", "backup"}
var (
digestShape = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
// secretShape is a secret's name as a manifest writes it and `${secret:…}` names it (mesh-controller
// internal/catalogue secrets_into_files.go).
secretShape = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// collectionShape is a collection's name (mesh-controller internal/catalogue collections.go): words joined by
// colons, so an old namespace's placeholder names one as it reads, `shell:zsh:normal`.
collectionShape = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(:[a-z0-9][a-z0-9-]*)*$`)
// itemNameShape, attributeKeyShape and attributeValueShape are an item's name and attributes (mesh-controller
// internal/catalogue collections.go); none holds a colon, a comma or an equals sign.
itemNameShape = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
attributeKeyShape = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
attributeValueShape = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
// nodeShape is a machine's name as the vault writes it in a secret's name (mesh-vault intake.go namePart).
nodeShape = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
ownerWords = []string{"owned", "seed", "managed"}
takeWords = []string{"restart-on", "reload-on", "read-on-use"}
)
// errNoPlan is an assignment the controller has not handed to the provisioner.
var errNoPlan = errors.New("no plan")
// planOf reads an assignment's value: its module and its plan, errNoPlan when it has none, or why the plan cannot
// be acted on.
func planOf(node, key string, value []byte) (string, Plan, error) {
var a Assignment
if err := json.Unmarshal(value, &a); err != nil {
return "", Plan{}, fmt.Errorf("the assignment %s is not the controller's JSON: %w", key, err)
}
if a.Node != node || a.Node+"."+a.Module != key {
return "", Plan{}, fmt.Errorf("the assignment %s names node %q and module %q, not its key's", key, a.Node,
a.Module)
}
if len(a.Provisioning) == 0 || string(a.Provisioning) == "null" {
return a.Module, Plan{}, errNoPlan
}
var p Plan
dec := json.NewDecoder(bytes.NewReader(a.Provisioning))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return a.Module, Plan{}, fmt.Errorf("the plan of %s cannot be read: %w", a.Module, err)
}
if wrong := p.problems(); len(wrong) > 0 {
return a.Module, Plan{}, fmt.Errorf("the plan of %s cannot be acted on: %s", a.Module,
strings.Join(wrong, "; "))
}
return a.Module, p, nil
}
func (p Plan) problems() []string {
var out []string
if p.Templates.Source == "" {
out = append(out, "it names no source for its templates")
}
if !digestShape.MatchString(p.Templates.Digest) {
out = append(out, fmt.Sprintf("its templates' digest %q is not sha256:<64 hex>", p.Templates.Digest))
}
if len(p.Files) == 0 && len(p.Items) == 0 {
out = append(out, "it fills no file and contributes no item")
}
seen := map[string]bool{}
for _, f := range p.Files {
switch {
case !filepath.IsAbs(f.Path) || filepath.Clean(f.Path) != f.Path:
out = append(out, fmt.Sprintf("%s: %q is not a clean absolute path", f.ID, f.Path))
case seen[f.Path]:
out = append(out, fmt.Sprintf("%s: %s is named twice", f.ID, f.Path))
}
seen[f.Path] = true
if !plainTemplate(f.Template) {
out = append(out, fmt.Sprintf("%s: template %q is not a plain path inside the templates archive", f.ID,
f.Template))
}
if !slices.Contains(ownerWords, f.OwnerAtRuntime) {
out = append(out, fmt.Sprintf("%s: owner-at-runtime %q is not owned, seed or managed", f.ID,
f.OwnerAtRuntime))
}
if f.Takes != "" && !slices.Contains(takeWords, f.Takes) {
out = append(out, fmt.Sprintf("%s: takes %q is not restart-on, reload-on or read-on-use", f.ID, f.Takes))
}
}
named := map[string]bool{}
for _, sec := range p.Secrets {
if !secretShape.MatchString(sec.Name) {
out = append(out, fmt.Sprintf("secret %q is not a secret's name", sec.Name))
}
if named[sec.Name] {
out = append(out, fmt.Sprintf("secret %s is named twice", sec.Name))
}
named[sec.Name] = true
if sec.Path == "" {
continue
}
switch {
case !filepath.IsAbs(sec.Path) || filepath.Clean(sec.Path) != sec.Path:
out = append(out, fmt.Sprintf("secret %s: %q is not a clean absolute path", sec.Name, sec.Path))
case seen[sec.Path]:
out = append(out, fmt.Sprintf("secret %s: %s is named twice", sec.Name, sec.Path))
}
seen[sec.Path] = true
}
bound := map[[2]string]bool{}
for _, pr := range p.Provisions {
which := pr.Name
if which == "" {
which = pr.Provision + " " + pr.Local
}
switch {
case pr.Name == "":
case !secretShape.MatchString(pr.Name):
out = append(out, fmt.Sprintf("provision credential %q is not a secret's name", pr.Name))
case named[pr.Name]:
out = append(out, fmt.Sprintf("secret %s is named twice", pr.Name))
}
if pr.Name != "" {
named[pr.Name] = true
}
if !secretShape.MatchString(pr.Provision) {
out = append(out, fmt.Sprintf("%s: provision %q is not a provision's name", which, pr.Provision))
}
if !nodeShape.MatchString(pr.Provider) {
out = append(out, fmt.Sprintf("%s: provider %q is not a machine's name", which, pr.Provider))
}
if pr.CredentialForm != "" && !slices.Contains(credentialForms, pr.CredentialForm) {
out = append(out, 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] {
out = append(out, fmt.Sprintf("%s: its provider is named twice", which))
} else {
bound[key] = true
}
if pr.Path == "" {
continue
}
if pr.Name == "" {
out = append(out, fmt.Sprintf("%s: %s is a file with no credential to fill it with", which, pr.Path))
continue
}
switch {
case !filepath.IsAbs(pr.Path) || filepath.Clean(pr.Path) != pr.Path:
out = append(out, fmt.Sprintf("secret %s: %q is not a clean absolute path", pr.Name, pr.Path))
case seen[pr.Path]:
out = append(out, fmt.Sprintf("secret %s: %s is named twice", pr.Name, pr.Path))
}
seen[pr.Path] = true
}
contributed := map[string]bool{}
for _, it := range p.Items {
if !collectionShape.MatchString(it.Collection) {
out = append(out, fmt.Sprintf("item %q is not a collection's name", it.Collection))
}
if it.Name != "" && !itemNameShape.MatchString(it.Name) {
out = append(out, fmt.Sprintf("item %s: %q is not a name", it.Collection, it.Name))
}
if contributed[it.key()] {
out = append(out, fmt.Sprintf("item %s is named twice: a module contributes one item to a collection, "+
"or several each with a name of its own", it.key()))
}
contributed[it.key()] = true
for _, k := range sortedKeys(it.Attributes) {
if v := it.Attributes[k]; !attributeFits(it.Collection, k, v) {
out = append(out, fmt.Sprintf("item %s: attribute %s=%q is not a name and a value", it.key(), k, v))
} else if n, err := strconv.Atoi(v); k == OrderAttribute && (err != nil || n < 0 || n > 99) {
out = append(out, fmt.Sprintf("item %s: order=%q is not a whole number from 0 to 99", it.key(), v))
}
}
if !plainTemplate(it.Template) {
out = append(out, fmt.Sprintf("item %s: template %q is not a plain path inside the templates archive",
it.Collection, it.Template))
}
}
collected := map[string]bool{}
for _, c := range p.Collections {
if !collectionShape.MatchString(c.Name) {
out = append(out, fmt.Sprintf("collection %q is not a collection's name", c.Name))
}
if collected[c.Name] {
out = append(out, fmt.Sprintf("collection %s is named twice", c.Name))
}
collected[c.Name] = true
if strings.ContainsAny(c.Comment, "\n\r") {
out = append(out, fmt.Sprintf("collection %s: its comment holds a line break", c.Name))
}
}
for k := range p.Placement {
kind, name, ok := strings.Cut(k, ":")
if !ok || name == "" || !slices.Contains(placementKinds, kind) {
out = append(out, fmt.Sprintf("placement %q is not <kind>:<name> of a placement kind", k))
}
}
slices.Sort(out)
return out
}
// plainTemplate is whether a template's path is a plain path inside the templates archive.
func plainTemplate(t string) bool {
return t != "" && !strings.HasPrefix(t, "/") && path.Clean(t) == t && t != ".." && !strings.HasPrefix(t, "../")
}