package catalogue import ( "fmt" "path" "regexp" "slices" "sort" "strconv" "strings" ) // Collections (novox/hq ADR 0349 clause 13, design 57 L1, issue 684): a provision with many providers. // // **A collector requires a collection; a contributor provides an item from a template.** A module taking additions // from others names a collection in a file of its own, `${collected:}`, and declares it under // `collections`, with the comment its tool writes a line in. Each contributor ships its item as a template of its // own repository and declares it under `items`: `{"collection": …, "template": "files/"}`. Its build packs the // template with the module's others, and the mesh holds its text as it holds a file's (template.go). // // **The node's provisioner client fills both.** An item is filled on the node by the client of the node its // contributor is placed on, from the same owners as a file, and recorded in mesh-provisioner_collect; a collector's // file is filled by the same client from every item of its collection recorded for that node, so an item goes when // its contributor leaves. So a module contributing an item is a moved module (handed.go), and the controller renders // no item: the plan of a contributor names its items and their templates, and the plan of a collector names each // collection it reads and its comment. Neither carries a contributor's text. // // **The three old namespaces read as collections.** `${shell::}`, `${environment:}` and // `${contribution::}` name, in a handed collector's file, the collection of the same name — // `shell:zsh:normal` — with the comment the controller wrote before each module's piece. An item is contributed to // one by that name, by a module the old form would have let contribute it. Each file is filled one way, never // both (ADR 0349 clause 17): a handed collector is filled from items alone and an unhanded one from the old forms // alone, and a piece of the other form on the same machine is said as not placed, never dropped quietly. // Item is one item a module contributes to a collection: its template in the module's repository, or, in the // manifest the mesh holds, the template's text, read back from the build's templates archive like a file's. // // **Attributes, a capability and a name** (design 57 L3, issue 777): an item may carry `attributes` (string keys and // values; `order`, a whole number 0–99 defaulting to 50, orders the items of one placeholder) and `if-capability`, // the capability a machine must report for the item to be placed on it. A module may give several items to one // collection, each then with a `name` unique within the module and the collection. A collector takes only the items // whose attributes match, `${collected::=,…}`, and all of them without the selector. type Item struct { Collection string `json:"collection"` Name string `json:"name,omitempty"` Template string `json:"template,omitempty"` Content string `json:"content,omitempty"` Attributes map[string]string `json:"attributes,omitempty"` IfCapability string `json:"if-capability,omitempty"` } // ItemKey is an item's key among its module's: the collection, and `/` for a named item. func ItemKey(collection, name string) string { if name == "" { return collection } return collection + "/" + name } func (it Item) key() string { return ItemKey(it.Collection, it.Name) } // order is the item's place among the items of one placeholder: its `order` attribute, or OrderDefault. func (it Item) order() int { if n, err := strconv.Atoi(it.Attributes[OrderAttribute]); err == nil && n >= 0 && n <= 99 { return n } return OrderDefault } // OrderAttribute is the attribute that orders items. const OrderAttribute = "order" // matches is whether the item's attributes carry every pair of the selector. func (it Item) matches(where map[string]string) bool { for k, v := range where { if got, has := it.Attributes[k]; !has || got != v { return false } } return true } // itemsInOrder is the module's items by order, then name; the order the old form placed a module's pieces in. func itemsInOrder(items []Item) []Item { out := append([]Item(nil), items...) sort.SliceStable(out, func(a, b int) bool { if oa, ob := out[a].order(), out[b].order(); oa != ob { return oa < ob } return out[a].Name < out[b].Name }) return out } // Collection is one collection a module collects, and the comment its tool writes a line in: each contributor's item // is introduced by ` `, and by nothing without one. type Collection struct { Name string `json:"name"` Comment string `json:"comment,omitempty"` } // CollectedNamespace is a collection's placeholder namespace, `${collected:}`. const CollectedNamespace = "collected" // EnvironmentCollection is the one collection of the session environment (design 57 L3, issue 795): its items are // typed by attributes, a variable (`kind=variable`, `name`) or a PATH entry (`kind=path`, `at=start|end`), and the // template's text is the value or the entry. A collector names the rendering, `${collected:environment:posix}` or // `${collected:environment:systemd}`, which is not a selector. const EnvironmentCollection = "environment" // oldCollectionNamespaces are the namespaces read as collections of their own name. var oldCollectionNamespaces = []string{"shell", "environment", "contribution"} var ( // ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused. ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`) attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`) ) // splitSelector is a placeholder's name without its selector, and the selector: `widgets:bar=top,place=left` is // `widgets` and `bar=top,place=left`. A collection's name holds no `=`, so the selector begins at the last colon // before the first one. func splitSelector(name string) (collection, selector string) { eq := strings.Index(name, "=") if eq < 0 { return name, "" } colon := strings.LastIndex(name[:eq], ":") if colon < 0 { return name, "" } return name[:colon], name[colon+1:] } // selectorOf is a selector's pairs, or why it is none. func selectorOf(selector string) (map[string]string, error) { out := map[string]string{} if selector == "" { return out, nil } for _, pair := range strings.Split(selector, ",") { k, v, ok := strings.Cut(pair, "=") switch { case !ok || !attributeKey.MatchString(k) || !attributeValue.MatchString(v): return nil, fmt.Errorf("%q in %q is not =", pair, selector) case out[k] != "": return nil, fmt.Errorf("%q names %s twice", selector, k) } out[k] = v } return out, nil } // collectionOfPlaceholder is the collection a placeholder's namespace and key name, and whether they name one. func collectionOfPlaceholder(namespace, key string) (string, bool) { key, _ = splitSelector(key) switch { case namespace == CollectedNamespace && strings.HasPrefix(key, EnvironmentCollection+":"): // The environment is one collection, and the rest of the name picks the rendering (issue 795). return EnvironmentCollection, true case namespace == CollectedNamespace: return key, true case namespace == "environment": return EnvironmentCollection, true case slices.Contains(oldCollectionNamespaces, namespace): return namespace + ":" + key, true } return "", false } // oldCollection is an old namespace's collection by its name: its comment, or why no item may be contributed to it. // ok is false for a name that is not an old namespace's. // // forItem is whether an item is to be contributed to it: an item may be contributed to a kind received as data, // which a collector may place as a collection only where its shape says its pieces are text (design 57 L3). func oldCollection(name string, forItem bool) (comment string, old bool, wrong error) { namespace, key, _ := strings.Cut(name, ":") if !slices.Contains(oldCollectionNamespaces, namespace) { return "", false, nil } switch namespace { case "shell": target, slot, two := strings.Cut(key, ":") if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) { return "", true, fmt.Errorf("%s is no slot: shell code is shell::, the shell one of %s, "+ "the session's file one of %s or the power moment one of %s, and the slot one of %s", name, strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", "), strings.Join(knownSlots, ", ")) } // shellCode's line before each module's code. return "#", true, nil case "environment": if key == "" { // The collection itself, which an item is contributed to; a placeholder names a rendering. return "#", true, nil } if key != EnvironmentPOSIX && key != EnvironmentSystemd { return "", true, fmt.Errorf("%s is no format: the environment is environment:%s or environment:%s", name, EnvironmentPOSIX, EnvironmentSystemd) } return "#", true, nil } seat, kind, _ := strings.Cut(key, ":") s, r, ok := receivable(seat, kind) switch { case !ok && s.Name == "": return "", true, fmt.Errorf("%s names the seat %q, which the mesh does not define", name, seat) case !ok: return "", true, fmt.Errorf("%s: %s; %s", name, s.Name, kindsOf(s)) case r.Shape != nil && (forItem || collectableAsText(r)): return r.Comment, true, nil case r.Shape != nil: return "", true, fmt.Errorf("%s is received by %s as data in its own shape, which no item of text is "+ "(novox/hq ADR 0255)", name, s.Name) case r.Dirs: return "", true, fmt.Errorf("%s names its contributors' directories, which the holder reads as paths "+ "the controller fills; it is not yet a collection (design 57 L1)", name) case s.Name != canonicalSeat(seat): return "", true, fmt.Errorf("%s names the seat by another name; a collection names it %s", name, s.Name) } return r.Comment, true, nil } // placerOfOld is the seat whose holder alone may collect an old namespace's collection (ADR 0203, 0204, 0208, // 0211, 0212): the same seat that places it the old way. func placerOfOld(name string) string { namespace, key, _ := strings.Cut(name, ":") switch namespace { case "shell": target, _, _ := strings.Cut(key, ":") return placerOf(target) case "environment": return EnvironmentSeat } seat, _, _ := strings.Cut(key, ":") return canonicalSeat(seat) } // itemTemplateProblem is what is wrong with an item's template path, or "". func itemTemplateProblem(m Manifest, name string) string { dir := m.TemplatesDir() if strings.HasPrefix(name, "/") || path.Clean(name) != name || !strings.HasPrefix(name, dir+"/") || strings.Contains(name, "\\") { return fmt.Sprintf("the template %q is not a file under %s/ of its own repository: a template is %q, a "+ "plain relative path, and the build packs only %s/", name, dir, dir+"/", dir) } return "" } // isOld is whether a collection's name is an old namespace's. func isOld(name string) bool { _, old, _ := oldCollection(name, true) return old } // collectedPlaceholderProblems is what is wrong with one resource's ${collected:…}: placed only in a file's content, // and naming a collection the module declares it collects. func collectedPlaceholderProblems(m Manifest, r map[string]any) []string { var problems []string for _, field := range sortedKeys(r) { v, ok := r[field].(string) if !ok { continue } for _, f := range ofCollected.FindAllStringSubmatch(v, -1) { base, selector := splitSelector(f[1]) if _, err := selectorOf(selector); err != nil { problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err)) continue } switch { case field != "content": problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a collection is placed "+ "only in a file's content", m.Module, r["id"], f[0], field)) case isOld(base): if _, _, wrong := oldCollection(base, false); wrong != nil { problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], wrong)) } case !slices.ContainsFunc(m.Collections, func(c Collection) bool { return c.Name == base }): problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not collect %q: "+ "declare it under collections (novox/hq ADR 0349 §13)", m.Module, r["id"], f[0], m.Module, base)) } } } return problems } // collectionComment is the comment of a collection a module's file names: declared, or the old namespace's. func collectionComment(m Manifest, name string) (string, error) { if comment, old, wrong := oldCollection(name, false); old { return comment, wrong } for _, c := range m.Collections { if c.Name == name { return c.Comment, nil } } return "", fmt.Errorf("%s names ${collected:%s} and does not collect it", m.Module, name) } // itemsLeftOut says each item of a collection a file is filled with the old way that the controller could not render // there (heldItemText): the collector is not handed to the provisioner on this machine (ADR 0349 clause 17). Said, // never dropped quietly. func itemsLeftOut(modules []Manifest, holder Manifest, collection string, facts map[string]string) []string { return itemsLeftOutFor(modules, holder, collection, nil, facts, nil) } // itemsLeftOutFor is itemsLeftOut for a placeholder that selects by attributes (where), on a machine with these // capabilities: an item the selector does not take, or whose capability the machine lacks, is not left out but not // wanted here. func itemsLeftOutFor(modules []Manifest, holder Manifest, collection string, where map[string]string, facts map[string]string, caps map[string]bool) []string { var out []string for _, m := range inModuleOrder(modules) { for _, it := range itemsInOrder(m.Items) { if it.Collection != collection || !it.matches(where) || (it.IfCapability != "" && !caps[it.IfCapability]) { continue } if _, rendered := heldText(it, facts); rendered && renderedOldWay(collection) { continue } out = append(out, fmt.Sprintf("%s's item of %s is not placed: %s is filled the old way on this "+ "machine, from contributions, and an item only by the provisioner's client (design 57 L1)", m.Module, it.key(), holder.Module)) } } return out } // oldFormsLeftOut says each piece of an old form a handed collector's collections would have held: the collector is // filled from items alone, and the piece's module contributes none (ADR 0349 clause 17). func oldFormsLeftOut(modules []Manifest, holder Manifest, collections []PlanCollection) []string { var out []string for _, c := range collections { namespace, key, _ := strings.Cut(c.Name, ":") for _, m := range inModuleOrder(modules) { old := false switch namespace { case "shell": target, slot, _ := strings.Cut(key, ":") old = slices.ContainsFunc(m.Shell, func(s ShellCode) bool { return s.For == target && s.Slot == slot }) case "environment": old = m.Environment != nil && (len(m.Environment.Variables) > 0 || len(m.Environment.Path) > 0) case "contribution": seat, kind, _ := strings.Cut(key, ":") old = slices.ContainsFunc(m.allContributions(), func(s SeatContribution) bool { return canonicalSeat(s.Seat) == canonicalSeat(seat) && s.Kind == kind }) } if old { out = append(out, fmt.Sprintf("%s's %s is not placed: %s is filled by the provisioner's client on "+ "this machine, from items, and %s contributes it the old way (design 57 L1)", m.Module, c.Name, holder.Module, m.Module)) } } } return out } // heldItemText is a moved contributor's items of an old namespace's collection as the controller renders them into a // collector not handed on this machine (design 57 L2, issue 681): the text the mesh holds, read back from the // contributor's templates archive, with the machine's facts filled, each ending its line. As a moved module's file // keeps its text for a machine that cannot take it handed (handed.go), an item keeps its place in a collector filled // the old way, so the file stays what its old form made it. Only `shell:` and `contribution:` collections, which the // controller renders as text; false for a module with no such item, or an item whose text the mesh does not hold or // which names anything but a machine fact this machine has, which is then said as not placed. func heldItemText(m Manifest, collection string, facts map[string]string) (string, bool) { items := heldItems(m, collection, nil, facts, nil) var b strings.Builder for _, h := range items { b.WriteString(h.text) } return b.String(), len(items) > 0 } // renderedOldWay is whether the controller renders a collection's items into a collector not handed. func renderedOldWay(collection string) bool { namespace, _, _ := strings.Cut(collection, ":") return namespace == "shell" || namespace == "contribution" || namespace == EnvironmentCollection } // heldItem is one item of a module rendered the old way. type heldItem struct { item Item text string } // heldItems is a module's items of the collection that the selector takes and the machine's capabilities allow, by // order then name, each rendered as heldText does; the ones it cannot render are left out (itemsLeftOutFor says so). func heldItems(m Manifest, collection string, where map[string]string, facts map[string]string, caps map[string]bool) []heldItem { if !renderedOldWay(collection) { return nil } var out []heldItem for _, it := range itemsInOrder(m.Items) { if it.Collection != collection || !it.matches(where) || (it.IfCapability != "" && !caps[it.IfCapability]) { continue } if text, ok := heldText(it, facts); ok { out = append(out, heldItem{item: it, text: text}) } } return out } // heldText is an item's text with the machine's facts filled, ending its line; false for an item whose text the mesh // does not hold or which names anything but a machine fact this machine has. func heldText(it Item, facts map[string]string) (string, bool) { if it.Content == "" { return "", false } text := it.Content for _, f := range templatePlaceholder.FindAllStringSubmatch(text, -1) { value, has := facts[f[2]] if f[1] != "machine" || !has { return "", false } text = strings.ReplaceAll(text, f[0], value) } if !strings.HasSuffix(text, "\n") { text += "\n" } return text, true }