package resolution import ( "errors" "fmt" "regexp" "sort" "strconv" "strings" mcat "git.novox.be/novox/mesh-sdk/go/catalogue" ) // settingRef is how a definition asks for an operator's value: ${setting:}. var settingRef = regexp.MustCompile(`\$\{setting:([a-z0-9][a-z0-9_.-]*)\}`) // settingsUsed is every key a file's content asks for, once each, in order of first use. func settingsUsed(content string) []string { var keys []string seen := map[string]bool{} for _, m := range settingRef.FindAllStringSubmatch(content, -1) { if !seen[m[1]] { seen[m[1]] = true keys = append(keys, m[1]) } } return keys } // UnsetSettingError is a module whose definition says ${setting:} where nothing sets that key: typed, so // that whoever reads why a module was left out of a machine can tell a setting nobody gave — the operator's // to give, named with the command that gives it — from any other reason (novox/hq issue 380). Its words are // the refusal's, unchanged. type UnsetSettingError struct { Module, Setting string said string // where says what asks for it — "a file", "a service whose unit", "a directory whose owner" — and // setToday which of the module's keys a layer sets, for saying it again in the config form's words. where, setToday string // field is the key of a contribution whose value asks for it, where the contribution's words are made by its caller. field string } func (e *UnsetSettingError) Error() string { return e.said } // unsetInConfigWords is the refusal of a required config value nothing sets, in the words of a module written in the // config form (novox/hq ADR 0348 §1, §4): `${config:}` and the config verb, never `settings set`. Any other // error, and any error of a module in the settings form, is returned as it was. func unsetInConfigWords(err error, m mcat.Manifest) error { var unset *UnsetSettingError if err == nil || !m.DeclaresConfig() || !errors.As(err, &unset) { return err } return &UnsetSettingError{Module: unset.Module, Setting: unset.Setting, where: unset.where, setToday: unset.setToday, said: fmt.Sprintf( "%s has %s that says ${config:%s}, and nothing sets the required config value %q for it — a "+ "required value is the operator's own and has no default (novox/hq ADR 0348 §4): set it with "+ "the config verb, for %s on this node, key %q%s", unset.Module, unset.where, unset.Setting, unset.Setting, unset.Module, unset.Setting, strings.ReplaceAll(unset.setToday, "no setting is set", "no config value is set"))} } // settingInto fills a file's ${setting:…} placeholders from the layers over a module. // // The last layer setting a key wins, which is the node's over the mesh's over the module's own // default (novox/hq ADR 0262) — the same order settle applies to a mergeable file. The caller lays // the defaults under the layers with WithDefaults. A value that is not a string is written the way a program would read // it (a number without a trailing .000000, a boolean as true/false). func settingInto(resource map[string]any, layers []mcat.Layer, module string) error { if fmt.Sprint(resource["type"]) == "service" { return settingIntoUnit(resource, layers, module) } if fmt.Sprint(resource["type"]) == "directory" { return settingIntoOwner(resource, layers, module) } if fmt.Sprint(resource["type"]) == "container" { return settingIntoGroups(resource, layers, module) } if fmt.Sprint(resource["type"]) != "file" { return nil } content, ok := resource["content"].(string) if !ok { return nil } for _, key := range settingsUsed(content) { value, set := settingValue(layers, key) if !set { return &UnsetSettingError{Module: module, Setting: key, where: "a file", setToday: orNoSettings(layers), said: fmt.Sprintf( "%s has a file that says ${setting:%s}, and nothing sets %q for it — an operator's "+ "value is the assignment's, never the definition's (novox/hq ADR 0112), and only a "+ "preference has a default in the definition (ADR 0262): "+ "`settings set %s ` with {%q: …}%s", module, key, key, module, key, orNoSettings(layers))} } content = strings.ReplaceAll(content, "${setting:"+key+"}", plainly(value)) } resource["content"] = content return nil } // unitPart is what a setting may put into a unit's name: the characters systemd allows in a unit name, // less the instance's `@` and the escape's `\`, and at least one of them. Anything else — a space, a slash, // a newline that would begin a directive in the unit file the name ends up in — is refused, never written. var unitPart = regexp.MustCompile(`^[A-Za-z0-9:_.][A-Za-z0-9:_.-]{0,63}$`) // settingIntoUnit fills ${setting:…} in a service resource's unit name: a module that holds the // distribution's timer for the pool the operator names cannot write the pool into its definition // (novox/hq ADR 0112). Refused, with the key and why, when nothing sets it or the value would not make a // unit's name; the resource is left as it was. func settingIntoUnit(resource map[string]any, layers []mcat.Layer, module string) error { unit, ok := resource["unit"].(string) if !ok { return nil } for _, key := range settingsUsed(unit) { value, set := settingValue(layers, key) if !set { return &UnsetSettingError{Module: module, Setting: key, where: "a service whose unit", setToday: orNoSettings(layers), said: fmt.Sprintf( "%s has a service whose unit says ${setting:%s}, and nothing sets %q for it — an operator's "+ "value is the assignment's, never the definition's (novox/hq ADR 0112): "+ "`settings set %s ` with {%q: …}%s", module, key, key, module, key, orNoSettings(layers))} } v := plainly(value) if !unitPart.MatchString(v) { return fmt.Errorf("%s: the setting %q is %q, which cannot be the instance of the unit %s: an instance "+ "takes letters, digits, ':', '_', '.' and '-', does not begin with '-' (a systemctl option), and is "+ "at most 64 characters", module, key, v, unit) } unit = strings.ReplaceAll(unit, "${setting:"+key+"}", v) } resource["unit"] = unit return nil } // wholeID is a value as a uid or a gid, and whether it is one: a whole number below the kernel's "no id" // (2^32-1). Uid 0 is one: a key an owner asks for is the controller's terminal's alone (TerminalKeys). func wholeID(v string) (uint64, bool) { if v == "" || strings.TrimLeft(v, "0123456789") != "" { return 0, false } n, err := strconv.ParseUint(v, 10, 32) return n, err == nil && n < 1<<32-1 } // settingIntoOwner fills ${setting:…} in a directory's owner, refusing — with the key and why, the // resource left as it was — a key nothing sets and a value that is not a whole number. func settingIntoOwner(resource map[string]any, layers []mcat.Layer, module string) error { owner, ok := resource["owner"].(string) if !ok { return nil } for _, key := range settingsUsed(owner) { value, set := settingValue(layers, key) if !set { return &UnsetSettingError{Module: module, Setting: key, where: fmt.Sprintf("a directory %v whose owner", resource["id"]), setToday: orNoSettings(layers), said: fmt.Sprintf( "%s has a directory %v whose owner says ${setting:%s}, and nothing sets %q for it — an operator's "+ "value is the assignment's, never the definition's (novox/hq ADR 0112): "+ "`settings set %s ` with {%q: …}%s", module, resource["id"], key, key, module, key, orNoSettings(layers))} } v := plainly(value) id, whole := wholeID(v) if !whole { return fmt.Errorf("%s: the setting %q is %q, which cannot own the directory %v: an owner a setting "+ "names is a uid or a gid, a whole number, never an account's name or other text (novox/hq issue 279)", module, key, v, resource["id"]) } // Written as the number parsed, so "0001" reaches the node-engine as 1, never as text. owner = strings.ReplaceAll(owner, "${setting:"+key+"}", strconv.FormatUint(id, 10)) } resource["owner"] = owner return nil } func settingValue(layers []mcat.Layer, key string) (any, bool) { var value any set := false for _, layer := range layers { if v, has := layer.Values[key]; has { value, set = v, true } } return value, set } func orNoSettings(layers []mcat.Layer) string { var keys []string for _, l := range layers { if l.Default { continue } for k := range l.Values { keys = append(keys, k) } } if len(keys) == 0 { return "; no setting is set for this module" } sort.Strings(keys) return "; set today: " + strings.Join(keys, ", ") } // groupsOf is a container resource's groups as the manifest wrote them (text or numbers), none when it names none. func groupsOf(r map[string]any) []any { groups, _ := r["groups"].([]any) return groups } // settingIntoGroups fills ${setting:…} in a container's groups, refusing — with the key and why, the resource left as // it was — a key nothing sets and a value that is not a whole number above zero. A number is kept; the list that // results is numbers alone. func settingIntoGroups(resource map[string]any, layers []mcat.Layer, module string) error { groups := groupsOf(resource) if len(groups) == 0 { return nil } filled := make([]any, 0, len(groups)) for _, g := range groups { s, isText := g.(string) if !isText { if f, isNumber := g.(float64); isNumber && f == float64(int(f)) { g = int(f) // a number the manifest wrote, as the numbers a placeholder gives } filled = append(filled, g) continue } keys := settingsUsed(s) if len(keys) != 1 || settingRef.ReplaceAllString(s, "") != "" { filled = append(filled, g) // not a whole placeholder: the manifest check refuses it continue } key := keys[0] value, set := settingValue(layers, key) if !set { return &UnsetSettingError{Module: module, Setting: key, where: fmt.Sprintf("a container %v whose groups", resource["id"]), setToday: orNoSettings(layers), said: fmt.Sprintf( "%s has a container %v whose groups say ${setting:%s}, and nothing sets %q for it — an operator's "+ "value is the assignment's, never the definition's (novox/hq ADR 0112): "+ "`settings set %s ` with {%q: …}%s", module, resource["id"], key, key, module, key, orNoSettings(layers))} } v := plainly(value) id, whole := wholeID(v) if !whole || id == 0 { return fmt.Errorf("%s: the setting %q is %q, which cannot be a group of the container %v: a supplementary "+ "group is a whole number above zero, never a name or other text (novox/hq ADR 0480 clause 7, ADR 0328 §15)", module, key, v, resource["id"]) } filled = append(filled, int(id)) } resource["groups"] = filled return nil }