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.
173 lines
7.3 KiB
Go
173 lines
7.3 KiB
Go
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)
|
|
}
|
|
}
|