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, `.`, 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:}`, 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////; 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::=,…}`) 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 : 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 `.`. 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 `.`: 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 `.`; 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 `.`: 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_]*$`)