A definition names no host path (ADR 0112); an adopted machine keeps its
data where the predecessor put it. Two settings, validated like endpoints:
places: {<directory id>: <path> | {path, owner}}
accesses: {<access id>: <path>}
An access may now be declared by id (`{"id": "series", "mode": "read-write"}`)
and named in mounts, env and content as ${access:<id>}; the assignment
says where it is on this node, and an access nobody placed is refused by
name. A definition still carrying a path keeps it as the default the
assignment replaces. A placed directory takes the assignment's owner
where it says one. Resolved in composition, so the host receives paths
and owners exactly as before.
383 lines
12 KiB
Go
383 lines
12 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"fmt"
|
|
"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]+$`)
|
|
|
|
// 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 = strings.TrimRight(p.Path, "/")
|
|
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)
|
|
}
|
|
out[id] = strings.TrimRight(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
|
|
}
|