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).
518 lines
22 KiB
Go
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_]*$`)
|