package resolution import ( "fmt" "regexp" "slices" "sort" "strconv" "strings" mcat "git.novox.be/novox/mesh-sdk/go/catalogue" ) func itemKey(it mcat.Item) string { return mcat.ItemKey(it.Collection, it.Name) } // order is the item's place among the items of one placeholder: its `order` attribute, or OrderDefault. func itemOrder(it mcat.Item) int { if n, err := strconv.Atoi(it.Attributes[mcat.OrderAttribute]); err == nil && n >= 0 && n <= 99 { return n } return mcat.OrderDefault } // matches is whether the item's attributes carry every pair of the selector. func itemMatches(it mcat.Item, 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 []mcat.Item) []mcat.Item { out := append([]mcat.Item(nil), items...) sort.SliceStable(out, func(a, b int) bool { if oa, ob := itemOrder(out[a]), itemOrder(out[b]); oa != ob { return oa < ob } return out[a].Name < out[b].Name }) return out } // oldCollectionNamespaces are the namespaces read as collections of their own name. var oldCollectionNamespaces = []string{"shell", "environment", "contribution"} // ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused. var ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`) var attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) var 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 == mcat.CollectedNamespace && strings.HasPrefix(key, mcat.EnvironmentCollection+":"): // The environment is one collection, and the rest of the name picks the rendering (issue 795). return mcat.EnvironmentCollection, true case namespace == mcat.CollectedNamespace: return key, true case namespace == "environment": return mcat.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 != mcat.EnvironmentPOSIX && key != mcat.EnvironmentSystemd { return "", true, fmt.Errorf("%s is no format: the environment is environment:%s or environment:%s", name, mcat.EnvironmentPOSIX, mcat.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 } // 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 mcat.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 mcat.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 mcat.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) } // 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 []mcat.Manifest, holder mcat.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 || !itemMatches(it, 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, itemKey(it), 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 []mcat.Manifest, holder mcat.Manifest, collections []mcat.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 mcat.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(manifestAllContributions(m), func(s mcat.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 } // 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 == mcat.EnvironmentCollection } // heldItem is one item of a module rendered the old way. type heldItem struct { item mcat.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 mcat.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 || !itemMatches(it, 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 mcat.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 }