package catalogue import ( "fmt" "regexp" "sort" "strconv" "strings" ) // An operator's value, where a definition needs one (novox/hq ADR 0112, ADR 0155, design 27). // // A mail server's domain, a site's name, the address a proxy forwards from: values that are true of // one installation and of no other, and that a module's software must be told. They had nowhere to // live but the definition, which is how a catalogue meant for any mesh came to name this one // (novox/hq issues 122, 134). ADR 0112 names the operator as one of the four providers; this is the // operator answering. // // `${setting:}` in a file's content, in a service's unit name and in a directory's owner (a uid or // gid only, issue 279) is filled from the module's settings layers — the mesh's, // then this node's — the same layers a mergeable JSON file and a contribution already take, so // `settings set ` is the one place a person's values go. **Refused when no layer sets it**, // naming the key and the remedy: a definition that carried a default for a mail domain would be // carrying the very literal this removes, and a blank written silently would be a service that // comes up wrong somewhere that names neither the module nor the key. // 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 } func (e *UnsetSettingError) Error() string { return e.said } // 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 []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"]) != "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, 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}$`) // unitInstance is the one place a setting may stand in a unit's name: the whole instance of a template, // after its `@` and before its suffix — `zfs-scrub-weekly@${setting:scrub-pool}.timer` — so a value can // make the unit another instance of the same template and nothing else (third review of mesh-catalog #147). var unitInstance = regexp.MustCompile(`^[A-Za-z0-9:_.-]+@\$\{setting:[a-z0-9][a-z0-9_.-]*\}\.[a-z]+$`) // UnitSettingProblems refuses, where a manifest is read, a service whose unit name carries a setting // anywhere but as a template's whole instance. func UnitSettingProblems(m Manifest) []string { var problems []string for _, r := range m.Resources { if fmt.Sprint(r["type"]) != "service" { continue } unit, _ := r["unit"].(string) if len(settingsUsed(unit)) == 0 && !strings.Contains(unit, "${setting:") { continue } if !unitInstance.MatchString(unit) { problems = append(problems, fmt.Sprintf("%s: the service %v's unit %q carries a setting outside a template's "+ "instance; a setting may stand only as the whole instance, after the @ and before the suffix "+ "(name@${setting:key}.timer)", m.Module, r["id"], unit)) } } return problems } // 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 []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, 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 } // A declared directory may be owned by the account its module runs as (novox/hq issue 279). // // A containerised module runs as the identity its assignment sets per node — its puid and pgid // settings, written into the environment file its image reads (ADR 0112, issue 153) — and a directory // its app must write, beyond the one its image re-owns at start, was owned by whatever the manifest // spelt: right on a machine whose identity matched the image's default, refused by the app on one whose // did not. So a directory's `owner` may name the settings that identity is: `${setting:}` for the // user alone, `${setting:}:${setting:}` for both, filled from the same layers at // composition. // // **Whole numbers only.** An owner a setting fills is a uid or a gid — never an account's name, never // text — so no value can put anything into the ownership the node-engine applies but a number; one that // is not is refused at composition, naming the key, and the directory is left as it was. The shape is // judged where the manifest is read (OwnerSettingProblems): each side of the colon a whole placeholder // or a whole number, and every key one the module itself asks for elsewhere — in a file, a unit, a // contribution or a served fact — or declares a default for, so the owner follows the identity its // process is told it runs as, never a key that reaches nothing else. // ownerSide is one side of an owner a setting may fill: a whole placeholder or a whole number. var ownerSide = `(?:\$\{setting:[a-z0-9][a-z0-9_.-]*\}|[0-9]+)` // ownerWithSetting is the only shape an owner holding a setting may have: user, or user:group. var ownerWithSetting = regexp.MustCompile(`^` + ownerSide + `(?::` + ownerSide + `)?$`) // 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 } // OwnerSettingProblems refuses, where a manifest is read, a directory whose owner carries a setting // in any shape but a whole placeholder or number on each side of the colon, or names a key the module // asks for nowhere else and declares no default for. func OwnerSettingProblems(m Manifest) []string { var problems []string asked := settingKeysAskedOutsideOwners(m) for k := range m.Settings { asked[k] = true } for _, r := range m.Resources { owner, _ := r["owner"].(string) if !strings.Contains(owner, "${setting:") { continue } if fmt.Sprint(r["type"]) != "directory" { // Said by the placeholder sweep: only a directory's owner takes a setting. continue } if !ownerWithSetting.MatchString(owner) { problems = append(problems, fmt.Sprintf("%s: the directory %v's owner %q carries a setting in a shape "+ "the mesh does not fill; an owner names the account by number only, each side of the colon a whole "+ "${setting:key} or a whole number (${setting:puid}:${setting:pgid}) (novox/hq issue 279)", m.Module, r["id"], owner)) continue } for _, key := range settingsUsed(owner) { if !asked[key] { problems = append(problems, fmt.Sprintf("%s: the directory %v's owner says ${setting:%s}, and the "+ "module asks for %q nowhere else and declares no default for it: an owner follows the identity "+ "the module's process is told it runs as (its identity file's ${setting:%s}), never a key of its "+ "own (novox/hq issue 279)", m.Module, r["id"], key, key, key)) } } } return problems } // 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 []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, 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 []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 []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, ", ") } // settingKeysUsedBy is every key a module's files, units, directory owners, contributions and served // facts ask for, so a setting that lands in one is not called stray. func settingKeysUsedBy(m Manifest) map[string]bool { used := settingKeysAskedOutsideOwners(m) for _, r := range m.Resources { if fmt.Sprint(r["type"]) == "directory" { if owner, ok := r["owner"].(string); ok { for _, k := range settingsUsed(owner) { used[k] = true } } } } return used } // settingKeysAskedOutsideOwners is every key a module's files, units, contributions and served facts // ask for: what a directory's owner may follow (OwnerSettingProblems). func settingKeysAskedOutsideOwners(m Manifest) map[string]bool { used := map[string]bool{} note := func(s string) { for _, k := range settingsUsed(s) { used[k] = true } } for _, r := range m.Resources { switch fmt.Sprint(r["type"]) { case "file": if content, ok := r["content"].(string); ok { note(content) } case "service": if unit, ok := r["unit"].(string); ok { note(unit) } } } inValues := func(values map[string]any) { for _, v := range values { if s, ok := v.(string); ok { note(s) } } } for _, values := range m.Contributes { inValues(values) } for _, locals := range m.ContributesMany { for _, values := range locals { inValues(values) } } for _, values := range m.Serves { inValues(values) } return used }