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 — // — 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:}. 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 }