Through the settings verb a caller could place a module's directory at /etc with an owner of its own and have root hand it over at the next send, or mount any of the machine's paths into a container (hq ADR 0266). Both keys are now the terminal's and never at the machine's own trees; a plans line that acts is refused wherever its subcommand stands; and a line break in a setting, which a file it is written into reads as a directive, is refused where it is kept and where it is composed.
420 lines
14 KiB
Go
420 lines
14 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Where a module's data is on THIS machine is the assignment's (novox/hq ADR 0112, issue 153).
|
|
//
|
|
// A definition names no host path. It declares the directories it owns by id, and the operator's
|
|
// shared data it needs by id too (an `access`, ADR 0051). A node has a default layout for the
|
|
// former — <root>/<module>/<id> — and nothing at all for the latter, because shared data is
|
|
// wherever the operator keeps it. An adopted machine keeps its data where the predecessor put it:
|
|
// a 40 TB library on its own pool, a configuration on a second disk. Both halves are said on the
|
|
// assignment, validated the way `endpoints` is — an id the module does not declare is refused,
|
|
// because a setting that reaches nothing is a mistake — and resolved here, so the host receives
|
|
// concrete paths exactly as it always has and learns no field.
|
|
//
|
|
// {"places": {"config": "/services/sonarr/config",
|
|
// "data": {"path": "/mnt/plex/data", "owner": "1000:1000"}},
|
|
// "accesses": {"series": "/storage/media/series",
|
|
// "downloads": "/storage/downloads"}}
|
|
//
|
|
// A placed directory is still the mesh's: created, chowned to the owner the assignment says (or
|
|
// the manifest's), removed when empty and no longer declared. A placed access is still the
|
|
// operator's: mounted, never created, chowned or removed.
|
|
|
|
// PlacesSetting is the settings key that places a module's declared directories, by id.
|
|
const PlacesSetting = "places"
|
|
|
|
// AccessesSetting is the settings key that says where a module's accesses are on this node, by id.
|
|
const AccessesSetting = "accesses"
|
|
|
|
// Placement is what an assignment says about one of a module's directories.
|
|
type Placement struct {
|
|
// Path is where the directory is on this machine. Absolute.
|
|
Path string
|
|
// Owner is "uid:gid" when the assignment overrides the manifest's — the predecessor's data is
|
|
// owned by whoever it ran as, and that is one machine's fact.
|
|
Owner string
|
|
}
|
|
|
|
var ownerShape = regexp.MustCompile(`^[0-9]+:[0-9]+$`)
|
|
|
|
// systemTrees are where no placement and no access may be: the machine's own system, and the node-engine's
|
|
// and the tool runner's state (novox/hq ADR 0266). A placed directory is created and chowned by the
|
|
// node-engine as root, and an access is mounted into a container: a place at /etc, owned by an account a
|
|
// caller names, hands that account the machine. Refused at or below each of these.
|
|
var systemTrees = []string{"/etc", "/usr", "/boot", "/root", "/proc", "/sys", "/dev", "/run", "/bin", "/sbin",
|
|
"/lib", "/lib64", "/var/lib/mesh", "/var/lib/mesh-host", "/var/lib/mesh-bus-conf"}
|
|
|
|
// systemRoots are directories a placement may be below but never be: each holds the whole machine's, or
|
|
// every module's or every person's, directories.
|
|
var systemRoots = []string{"/", "/var", "/var/lib", "/home", "/mnt", "/srv", "/opt", "/storage", "/data", "/tmp", "/var/tmp"}
|
|
|
|
// systemPath says why a path is the machine's own and no placement's, or "".
|
|
func systemPath(path string) string {
|
|
clean := filepath.Clean(path)
|
|
for _, root := range systemRoots {
|
|
if clean == root {
|
|
return clean + " is a whole tree of the machine's"
|
|
}
|
|
}
|
|
for _, tree := range systemTrees {
|
|
if clean == tree || strings.HasPrefix(clean, tree+"/") {
|
|
return clean + " is in " + tree + ", the machine's own or the mesh's state"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// accessRef is how a module names one of its accesses: ${access:<id>}.
|
|
var accessRef = regexp.MustCompile(`\$\{access:([a-z0-9][a-z0-9-]*)\}`)
|
|
|
|
// Places reads where this node places the module's directories, by directory id.
|
|
//
|
|
// It refuses an id the module declares no directory for, a path that is not absolute, and an
|
|
// owner that is not uid:gid. A directory the assignment does not mention keeps the manifest's
|
|
// stated path or the node's default layout.
|
|
func Places(m Manifest, layers []Layer) (map[string]Placement, error) {
|
|
declared := map[string]bool{}
|
|
for _, r := range m.Resources {
|
|
if fmt.Sprint(r["type"]) == "directory" {
|
|
declared[fmt.Sprint(r["id"])] = true
|
|
}
|
|
}
|
|
out := map[string]Placement{}
|
|
for _, layer := range layers {
|
|
raw, ok := layer.Values[PlacesSetting]
|
|
if !ok {
|
|
continue
|
|
}
|
|
blocks, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s: %s is a { directory: path | { path, owner } } map, and %q set it to something else",
|
|
m.Module, PlacesSetting, layer.From)
|
|
}
|
|
for id, body := range blocks {
|
|
if !declared[id] {
|
|
return nil, fmt.Errorf(
|
|
"%s places the directory %q, which it does not declare — the setting reaches "+
|
|
"nothing. It declares %s", m.Module, id, orNothing(namesOfDirs(directoriesOf(m))))
|
|
}
|
|
p := out[id]
|
|
switch v := body.(type) {
|
|
case string:
|
|
p.Path = strings.TrimSpace(v)
|
|
case map[string]any:
|
|
if path, said := v["path"]; said {
|
|
text, _ := path.(string)
|
|
p.Path = strings.TrimSpace(text)
|
|
}
|
|
if owner, said := v["owner"]; said {
|
|
text, _ := owner.(string)
|
|
if !ownerShape.MatchString(strings.TrimSpace(text)) {
|
|
return nil, fmt.Errorf("%s places %q with owner %v; an owner is uid:gid, numeric",
|
|
m.Module, id, owner)
|
|
}
|
|
p.Owner = strings.TrimSpace(text)
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("%s places %q with %v; a placement is a path, or { path, owner }",
|
|
m.Module, id, body)
|
|
}
|
|
if p.Path == "" {
|
|
return nil, fmt.Errorf("%s places %q without a path", m.Module, id)
|
|
}
|
|
if !strings.HasPrefix(p.Path, "/") {
|
|
return nil, fmt.Errorf("%s places %q at %q, which is not an absolute path", m.Module, id, p.Path)
|
|
}
|
|
p.Path = filepath.Clean(p.Path)
|
|
if why := systemPath(p.Path); why != "" {
|
|
return nil, fmt.Errorf("%s places %q at %s: %s, and the node-engine would create and own it as "+
|
|
"root (novox/hq ADR 0266)", m.Module, id, p.Path, why)
|
|
}
|
|
out[id] = p
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, nil
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// AccessPlaces reads where this node keeps the operator's data the module accesses, by access id.
|
|
//
|
|
// It refuses an id the module declares no access under, and a path that is not absolute. An
|
|
// access declared by path alone cannot be placed — it has no name to place it by.
|
|
func AccessPlaces(m Manifest, layers []Layer) (map[string]string, error) {
|
|
declared := map[string]bool{}
|
|
for _, a := range m.Accesses {
|
|
if a.ID != "" {
|
|
declared[a.ID] = true
|
|
}
|
|
}
|
|
out := map[string]string{}
|
|
for _, layer := range layers {
|
|
raw, ok := layer.Values[AccessesSetting]
|
|
if !ok {
|
|
continue
|
|
}
|
|
blocks, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s: %s is a { access: path } map, and %q set it to something else",
|
|
m.Module, AccessesSetting, layer.From)
|
|
}
|
|
for id, body := range blocks {
|
|
if !declared[id] {
|
|
return nil, fmt.Errorf(
|
|
"%s places the access %q, which it does not declare — the setting reaches "+
|
|
"nothing. It declares %s", m.Module, id, orNothing(namesOfAccesses(m)))
|
|
}
|
|
path, _ := body.(string)
|
|
path = strings.TrimSpace(path)
|
|
if !strings.HasPrefix(path, "/") {
|
|
return nil, fmt.Errorf("%s places the access %q at %v, which is not an absolute path",
|
|
m.Module, id, body)
|
|
}
|
|
path = filepath.Clean(path)
|
|
if why := systemPath(path); why != "" {
|
|
return nil, fmt.Errorf("%s places the access %q at %s: %s, and an access is mounted into the "+
|
|
"module's container (novox/hq ADR 0266)", m.Module, id, path, why)
|
|
}
|
|
out[id] = path
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, nil
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// placedAccess is one access with the path it resolves to on this node.
|
|
type placedAccess struct {
|
|
ID string
|
|
Path string
|
|
Mode string
|
|
}
|
|
|
|
// accessesFor is every access of a module with its path on this node: the assignment's where it
|
|
// placed one, the definition's where it carries a default, and refused where neither says — an
|
|
// access that resolves to nowhere would reach the machine as a mount of nothing.
|
|
func accessesFor(m Manifest, layers []Layer) ([]placedAccess, map[string]string, error) {
|
|
placed, err := AccessPlaces(m, layers)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
var out []placedAccess
|
|
byID := map[string]string{}
|
|
for _, a := range m.Accesses {
|
|
path := a.Path
|
|
if a.ID != "" {
|
|
if at, said := placed[a.ID]; said {
|
|
path = at
|
|
}
|
|
}
|
|
if path == "" {
|
|
return nil, nil, fmt.Errorf(
|
|
"%s accesses %q, and nothing says where that is on this node — the definition "+
|
|
"carries no path (it must not, novox/hq ADR 0112) and the assignment places "+
|
|
"none. Set %s: {%q: \"/where/it/is\"}",
|
|
m.Module, a.ID, AccessesSetting, a.ID)
|
|
}
|
|
out = append(out, placedAccess{ID: a.ID, Path: path, Mode: a.At()})
|
|
if a.ID != "" {
|
|
byID[a.ID] = path
|
|
}
|
|
}
|
|
return out, byID, nil
|
|
}
|
|
|
|
// accessFill resolves every ${access:…} in one string, or refuses a reference naming no access.
|
|
func accessFill(s string, accesses map[string]string, module string) (string, error) {
|
|
var missing error
|
|
out := accessRef.ReplaceAllStringFunc(s, func(ref string) string {
|
|
id := accessRef.FindStringSubmatch(ref)[1]
|
|
path, has := accesses[id]
|
|
if !has {
|
|
missing = fmt.Errorf(
|
|
"%s says ${access:%s}, and %s declares no access %q. It declares %s",
|
|
module, id, module, id, orNothing(namesOfAccessIDs(accesses)))
|
|
return ref
|
|
}
|
|
return path
|
|
})
|
|
return out, missing
|
|
}
|
|
|
|
// accessInto fills every ${access:…} a resource carries — in its path, its content, its mounts,
|
|
// its environment and its env-files — with the path this node resolved for it. The same walk as
|
|
// dirInto, for the same reason: a literal `${access:x}` reaching the machine would be mounted as
|
|
// a directory called that.
|
|
func accessInto(resource map[string]any, accesses map[string]string, module string) error {
|
|
if !mentionsAccess(resource) {
|
|
return nil
|
|
}
|
|
fill := func(s string) (string, error) { return accessFill(s, accesses, module) }
|
|
var err error
|
|
if path, ok := resource["path"].(string); ok {
|
|
if resource["path"], err = fill(path); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if content, ok := resource["content"].(string); ok {
|
|
if resource["content"], err = fill(content); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, field := range []string{"volumes", "env-file"} {
|
|
list, ok := resource[field].([]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
filled := make([]any, len(list))
|
|
for i, v := range list {
|
|
filled[i] = v
|
|
if s, ok := v.(string); ok {
|
|
if filled[i], err = fill(s); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
resource[field] = filled
|
|
}
|
|
if env, ok := resource["env"].(map[string]any); ok {
|
|
filled := make(map[string]any, len(env))
|
|
for key, v := range env {
|
|
filled[key] = v
|
|
if s, ok := v.(string); ok {
|
|
if filled[key], err = fill(s); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
resource["env"] = filled
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mentionsAccess(resource map[string]any) bool {
|
|
for _, field := range []string{"path", "content"} {
|
|
if s, ok := resource[field].(string); ok && accessRef.MatchString(s) {
|
|
return true
|
|
}
|
|
}
|
|
for _, field := range []string{"volumes", "env-file"} {
|
|
if list, ok := resource[field].([]any); ok {
|
|
for _, v := range list {
|
|
if s, ok := v.(string); ok && accessRef.MatchString(s) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if env, ok := resource["env"].(map[string]any); ok {
|
|
for _, v := range env {
|
|
if s, ok := v.(string); ok && accessRef.MatchString(s) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ownerInto gives a placed directory the owner the assignment said, where it said one. The
|
|
// manifest's owner is what the image expects on any machine; the assignment's is what this
|
|
// machine's data already is.
|
|
func ownerInto(resource map[string]any, placed map[string]Placement) {
|
|
if fmt.Sprint(resource["type"]) != "directory" {
|
|
return
|
|
}
|
|
if p, ok := placed[fmt.Sprint(resource["id"])]; ok && p.Owner != "" {
|
|
resource["owner"] = p.Owner
|
|
}
|
|
}
|
|
|
|
// unknownAccessRefs is every ${access:…} in the definition that names no access the definition
|
|
// declares by id — refused where the author is, as unknownDirRefs does for directories.
|
|
func (m Manifest) unknownAccessRefs() []string {
|
|
declared := map[string]bool{}
|
|
for _, a := range m.Accesses {
|
|
if a.ID != "" {
|
|
declared[a.ID] = true
|
|
}
|
|
}
|
|
seen := map[string]bool{}
|
|
var problems []string
|
|
refuse := func(s string, where any) {
|
|
for _, match := range accessRef.FindAllStringSubmatch(s, -1) {
|
|
id := match[1]
|
|
if declared[id] || seen[id] {
|
|
continue
|
|
}
|
|
seen[id] = true
|
|
problems = append(problems, fmt.Sprintf(
|
|
"%s says ${access:%s} in %v, and declares no access %q — a reference the mesh "+
|
|
"cannot place would reach the machine as a literal path",
|
|
m.Module, id, where, id))
|
|
}
|
|
}
|
|
for _, r := range m.Resources {
|
|
for _, field := range []string{"path", "content"} {
|
|
if s, ok := r[field].(string); ok {
|
|
refuse(s, r["id"])
|
|
}
|
|
}
|
|
for _, field := range []string{"volumes", "env-file"} {
|
|
if list, ok := r[field].([]any); ok {
|
|
for _, v := range list {
|
|
if s, ok := v.(string); ok {
|
|
refuse(s, r["id"])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if env, ok := r["env"].(map[string]any); ok {
|
|
for _, v := range env {
|
|
if s, ok := v.(string); ok {
|
|
refuse(s, r["id"])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
sort.Strings(problems)
|
|
return problems
|
|
}
|
|
|
|
func directoriesOf(m Manifest) map[string]string {
|
|
dirs := map[string]string{}
|
|
for _, r := range m.Resources {
|
|
if fmt.Sprint(r["type"]) == "directory" {
|
|
dirs[fmt.Sprint(r["id"])] = ""
|
|
}
|
|
}
|
|
return dirs
|
|
}
|
|
|
|
func namesOfAccesses(m Manifest) []string {
|
|
var names []string
|
|
for _, a := range m.Accesses {
|
|
if a.ID != "" {
|
|
names = append(names, fmt.Sprintf("%q", a.ID))
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func namesOfAccessIDs(accesses map[string]string) []string {
|
|
var names []string
|
|
for id := range accesses {
|
|
names = append(names, fmt.Sprintf("%q", id))
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|