diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 1dfede1..8ab57f3 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -473,9 +473,15 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri // find each present — refusing clearly if the operator has not provided it — before it // starts anything that depends on it. The mesh creates, chowns and reconciles none of it; // an `access` resource says only *this path must exist, and this module reaches it*. - for _, a := range m.Accesses { + // Where each is on THIS machine is the assignment's (novox/hq issue 153): placed by id + // where the operator said, the definition's default otherwise, refused where neither. + accesses, accessPaths, err := accessesFor(m, with.Settings[m.Module]) + if err != nil { + return nil, err + } + for _, a := range accesses { first = append(first, map[string]any{ - "id": AccessID(a.Path), "type": "access", "path": a.Path, "mode": a.At(), + "id": AccessID(a.Path), "type": "access", "path": a.Path, "mode": a.Mode, }) } for _, to := range m.SecretRequirements() { @@ -670,6 +676,12 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri } // And where this node places the directories the module declared without a path // (novox/hq ADR 0112) — resolved once per module, named by ${dir:…} from any resource. + // — and, on an adopted machine, where the assignment says they already are, with the + // owner the data already has (novox/hq issue 153). Malformed placements are refused here. + placed, err := Places(m, with.Settings[m.Module]) + if err != nil { + return nil, err + } dirs := dirsFor(m, with) // And the machine underneath, which no binding of its own can tell it. thisMachine := machineFacts(r, with.Names, with.MeshRange) @@ -710,6 +722,12 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri if err := dirInto(copied, dirs, m.Module); err != nil { return nil, err } + // The operator's data the same way: ${access:…} becomes where this node keeps it, + // and a placed directory takes the owner the assignment said (issue 153). + if err := accessInto(copied, accessPaths, m.Module); err != nil { + return nil, err + } + ownerInto(copied, placed) // **After settings, and that is the whole reason it is here.** A module's file // content is where a setting lands, so a placeholder may only exist once the setting // has been put in — filling secrets first would look at content that is not yet what diff --git a/internal/catalogue/dir_into.go b/internal/catalogue/dir_into.go index ba2a4a5..3749e1a 100644 --- a/internal/catalogue/dir_into.go +++ b/internal/catalogue/dir_into.go @@ -67,11 +67,18 @@ func dataRoot(with Rendering) string { func dirsFor(m Manifest, with Rendering) map[string]string { dirs := map[string]string{} var beneath []map[string]any + // The assignment's placement wins over both (novox/hq issue 153). Refused elsewhere when + // malformed; here an invalid setting simply places nothing. + placed, _ := Places(m, with.Settings[m.Module]) for _, r := range m.Resources { if fmt.Sprint(r["type"]) != "directory" { continue } id := fmt.Sprint(r["id"]) + if p, said := placed[id]; said { + dirs[id] = p.Path + continue + } if path, stated := r["path"].(string); stated && path != "" { if strings.HasPrefix(path, "${dir:") { beneath = append(beneath, r) diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 044620f..238a8d6 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -91,8 +91,13 @@ const ( // If the path is absent when a machine applies, the host refuses clearly rather than creating it: // the mesh does not own it, so conjuring it would be a lie the host then acts on. type Access struct { - // Path is the absolute path on the machine, as the operator provides it. - Path string `json:"path"` + // ID is the name the module gives this access, which the assignment places + // (`accesses: {: }`, novox/hq ADR 0112, issue 153) and the module's mounts name as + // ${access:}. The shape a definition should use: it names no path of any machine. + ID string `json:"id,omitempty"` + // Path is the absolute path on the machine. A definition carrying one names an installation; + // tolerated as the default the assignment may replace, for accesses declared before ids. + Path string `json:"path,omitempty"` // Mode is "read" or "read-write". Absent narrows to read. Mode string `json:"mode,omitempty"` } @@ -1525,7 +1530,16 @@ func ParseManifest(raw []byte) (Manifest, error) { } } for _, a := range m.Accesses { - if !strings.HasPrefix(a.Path, "/") { + if a.ID == "" && a.Path == "" { + problems = append(problems, fmt.Sprintf( + "%s declares an access with neither an id nor a path — an id, which the assignment places", + m.Module)) + } + if a.ID != "" && !accessRef.MatchString("${access:"+a.ID+"}") { + problems = append(problems, fmt.Sprintf( + "%s accesses %q; an access id is lowercase letters, digits and dashes", m.Module, a.ID)) + } + if a.Path != "" && !strings.HasPrefix(a.Path, "/") { problems = append(problems, fmt.Sprintf( "%s accesses %q, which is not an absolute path", m.Module, a.Path)) } @@ -1557,6 +1571,7 @@ func ParseManifest(raw []byte) (Manifest, error) { // the time it sees the mount it is being asked to create the directory, which it can do. problems = append(problems, m.undeclaredMounts()...) problems = append(problems, m.unknownDirRefs()...) + problems = append(problems, m.unknownAccessRefs()...) for i, r := range m.Resources { id, _ := r["id"].(string) diff --git a/internal/catalogue/placement.go b/internal/catalogue/placement.go new file mode 100644 index 0000000..6a406a2 --- /dev/null +++ b/internal/catalogue/placement.go @@ -0,0 +1,382 @@ +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 +} diff --git a/internal/catalogue/placement_test.go b/internal/catalogue/placement_test.go new file mode 100644 index 0000000..8cffa5e --- /dev/null +++ b/internal/catalogue/placement_test.go @@ -0,0 +1,172 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The case novox/hq issue 153 records: an adopted machine keeps its data where the predecessor put +// it — a library on its own pool that must never move, a configuration on a second disk owned by +// whoever the predecessor ran as. A definition may name none of that (ADR 0112); the assignment +// says it, by the ids the definition declared, and the machine receives concrete paths as always. +func placeable() Manifest { + m := mod("arr", nil, nil, nil) + m.Resources = []map[string]any{ + {"id": "state", "type": "directory", "place": ".", "mode": "0700"}, + {"id": "config", "type": "directory", "mode": "0755", "owner": "1000:1000"}, + {"id": "server", "type": "container", "name": "arr", "image": "arr:1", + "volumes": []any{"${dir:config}:/config", "${access:series}:/series", "${access:spool}:/downloads:ro"}, + "env": map[string]any{"SPOOL": "${access:spool}"}}, + } + m.Accesses = []Access{{ID: "series", Mode: AccessReadWrite}, {ID: "spool"}} + return m +} + +func placedBy(values map[string]any) Rendering { + return Rendering{Settings: SettingsBy{"arr": {{From: "node anchor", Values: values}}}} +} + +func TestAnAssignmentPlacesDirectoriesAndAccesses(t *testing.T) { + got, err := Resolve(shelf(placeable()), []string{"arr"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := got.Declaration(placedBy(map[string]any{ + PlacesSetting: map[string]any{ + "config": map[string]any{"path": "/services/arr/config/", "owner": "1001:2000"}, + }, + AccessesSetting: map[string]any{ + "series": "/storage/media/series", + "spool": "/storage/downloads", + }, + })) + if err != nil { + t.Fatal(err) + } + seen := map[string]map[string]any{} + for _, r := range out { + seen[r["id"].(string)] = r + } + config := seen["arr.config"] + if config["path"] != "/services/arr/config" || config["owner"] != "1001:2000" { + t.Fatalf("the placed directory is %v %v; want the assignment's path and owner", config["path"], config["owner"]) + } + if seen["arr.state"]["path"] != "/var/lib/arr" { + t.Fatalf("an unplaced directory left the default layout: %v", seen["arr.state"]["path"]) + } + var accesses []string + for _, r := range out { + if r["type"] == "access" { + accesses = append(accesses, r["path"].(string)+" "+r["mode"].(string)) + } + } + if strings.Join(accesses, ",") != "/storage/media/series read-write,/storage/downloads read" { + t.Fatalf("the accesses reached the machine as %v", accesses) + } + server := seen["arr.server"] + mounts := server["volumes"].([]any) + if mounts[0] != "/services/arr/config:/config" || mounts[1] != "/storage/media/series:/series" || + mounts[2] != "/storage/downloads:/downloads:ro" { + t.Fatalf("the mounts were not filled with the placed paths: %v", mounts) + } + if server["env"].(map[string]any)["SPOOL"] != "/storage/downloads" { + t.Fatalf("the environment was not filled: %v", server["env"]) + } +} + +// An access declared by id and placed by nobody resolves to nowhere, and that is refused with the +// setting to write — not mounted as the literal, not skipped. +func TestAnUnplacedAccessIsRefusedByName(t *testing.T) { + got, err := Resolve(shelf(placeable()), []string{"arr"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + _, err = got.Declaration(placedBy(map[string]any{ + AccessesSetting: map[string]any{"series": "/storage/media/series"}, + })) + if err == nil || !strings.Contains(err.Error(), `"spool"`) || !strings.Contains(err.Error(), AccessesSetting) { + t.Fatalf("an access nobody placed was not refused by name: %v", err) + } +} + +// Validated like endpoints: an id the module does not declare reaches nothing, and the refusal +// says what it does declare; a relative path and a non-numeric owner are refused too. +func TestPlacementsAreValidated(t *testing.T) { + m := placeable() + layers := func(values map[string]any) []Layer { return placedBy(values).Settings["arr"] } + + _, err := Places(m, layers(map[string]any{PlacesSetting: map[string]any{"data": "/mnt/data"}})) + if err == nil || !strings.Contains(err.Error(), "does not declare") || !strings.Contains(err.Error(), `"config"`) { + t.Fatalf("placing an undeclared directory was accepted: %v", err) + } + _, err = Places(m, layers(map[string]any{PlacesSetting: map[string]any{"config": "services/arr"}})) + if err == nil || !strings.Contains(err.Error(), "absolute") { + t.Fatalf("a relative placement was accepted: %v", err) + } + _, err = Places(m, layers(map[string]any{PlacesSetting: map[string]any{ + "config": map[string]any{"path": "/services/arr", "owner": "media"}}})) + if err == nil || !strings.Contains(err.Error(), "uid:gid") { + t.Fatalf("a non-numeric owner was accepted: %v", err) + } + _, err = AccessPlaces(m, layers(map[string]any{AccessesSetting: map[string]any{"movies": "/storage/media/movies"}})) + if err == nil || !strings.Contains(err.Error(), "does not declare") || !strings.Contains(err.Error(), `"series"`) { + t.Fatalf("placing an undeclared access was accepted: %v", err) + } + _, err = AccessPlaces(m, layers(map[string]any{AccessesSetting: map[string]any{"series": "media/series"}})) + if err == nil || !strings.Contains(err.Error(), "absolute") { + t.Fatalf("a relative access was accepted: %v", err) + } + // And the two keys are never stray: they are validated here, not merged into a file. + if stray := UnusedSettings(m, layers(map[string]any{ + PlacesSetting: map[string]any{"config": "/services/arr/config"}, + AccessesSetting: map[string]any{"series": "/storage/media/series"}, + })); len(stray) != 0 { + t.Fatalf("the placement keys were reported as unused: %v", stray) + } +} + +// A definition that carries a path still works, as the default the assignment may replace — and +// the assignment's placement wins where both say. +func TestADefinitionsPathIsTheDefaultTheAssignmentReplaces(t *testing.T) { + m := placeable() + m.Accesses = []Access{{ID: "series", Path: "/services/media/series", Mode: AccessReadWrite}, {ID: "spool", Path: "/services/media/downloads"}} + got, err := Resolve(shelf(m), []string{"arr"}, workstation(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := got.Declaration(placedBy(map[string]any{ + PlacesSetting: map[string]any{"config": "/services/arr/config"}, + AccessesSetting: map[string]any{"series": "/storage/media/series"}, + })) + if err != nil { + t.Fatal(err) + } + var paths []string + for _, r := range out { + if r["type"] == "access" { + paths = append(paths, r["path"].(string)) + } + } + if strings.Join(paths, ",") != "/storage/media/series,/services/media/downloads" { + t.Fatalf("placed one, defaulted the other: got %v", paths) + } +} + +// A reference to an access the definition does not declare is refused where the author is. +func TestAnUnknownAccessReferenceIsRefusedAtParse(t *testing.T) { + _, err := ParseManifest([]byte(`{ + "module": "arr", "version": "1", + "accesses": [{"id": "series", "mode": "read-write"}], + "resources": [ + {"id": "state", "type": "directory", "place": ".", "mode": "0700"}, + {"id": "server", "type": "container", "name": "arr", "image": "arr:1", + "volumes": ["${access:movies}:/movies"]} + ]}`)) + if err == nil || !strings.Contains(err.Error(), "${access:movies}") { + t.Fatalf("a reference to an undeclared access was accepted: %v", err) + } + _, err = ParseManifest([]byte(`{"module": "arr", "version": "1", "accesses": [{"mode": "read"}]}`)) + if err == nil || !strings.Contains(err.Error(), "neither an id nor a path") { + t.Fatalf("an access with no id and no path was accepted: %v", err) + } +} diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index b9c3786..6bda01f 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -791,6 +791,9 @@ func checkResources(modules []Manifest) []string { // which is what lets the stack in 04-ISSUES/036 co-resolve. for _, m := range modules { for _, a := range m.Accesses { + if a.Path == "" { + continue // placed by the assignment; nothing to compare at registration + } switch other := ownedPath[a.Path]; other { case "": // Nobody owns it — the ordinary, correct case for shared data. diff --git a/internal/catalogue/settings.go b/internal/catalogue/settings.go index 6ea87ce..cc418e0 100644 --- a/internal/catalogue/settings.go +++ b/internal/catalogue/settings.go @@ -267,6 +267,14 @@ func UnusedSettings(m Manifest, layers []Layer) []string { if key == EndpointsSetting && len(m.Listens) > 0 { continue } + // `places` puts a declared directory where this machine keeps it, `accesses` says where + // the operator's data is (novox/hq issue 153). Validated in Places and AccessPlaces. + if key == PlacesSetting && len(directoriesOf(m)) > 0 { + continue + } + if key == AccessesSetting && len(m.Accesses) > 0 { + continue + } unused = append(unused, fmt.Sprintf( "%s sets %q, and %s has no file that merges it, asks for no ${setting:%s}, and "+ "declares no %q in what it contributes or serves",