A media manager runs as the puid/pgid its assignment sets per node, but a
declared directory's owner was literal, so a folder the app must write was
refused on a machine whose identity was not the image's default. A
directory's owner now takes ${setting:<key>} (user, or user:group), filled
at composition and refused unless the value is a whole number, and the
module check refuses any other shape or a key the module asks for nowhere
else.
334 lines
13 KiB
Go
334 lines
13 KiB
Go
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:<key>}` 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 <module>` 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:<key>}.
|
|
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:<key>} 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 <file>` 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 <file>` 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:<key>}` for the
|
|
// user alone, `${setting:<user>}:${setting:<group>}` 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 whether a value is a uid or a gid: a whole number below the kernel's "no id" (2^32-1).
|
|
func wholeID(v string) bool {
|
|
if v == "" || strings.TrimLeft(v, "0123456789") != "" {
|
|
return false
|
|
}
|
|
n, err := strconv.ParseUint(v, 10, 32)
|
|
return 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 <file>` with {%q: …}%s",
|
|
module, resource["id"], key, key, module, key, orNoSettings(layers))}
|
|
}
|
|
v := plainly(value)
|
|
if !wholeID(v) {
|
|
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"])
|
|
}
|
|
owner = strings.ReplaceAll(owner, "${setting:"+key+"}", v)
|
|
}
|
|
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
|
|
}
|