222 lines
9.4 KiB
Go
222 lines
9.4 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": {LayerOf("node anchor", 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. The refusal costs the module its
|
|
// place in the declaration, not the machine its declaration (novox/hq ADR 0163, rule 6).
|
|
func TestAnUnplacedAccessIsRefusedByName(t *testing.T) {
|
|
got, err := Resolve(shelf(placeable()), []string{"arr"}, workstation(), World{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
with := placedBy(map[string]any{
|
|
AccessesSetting: map[string]any{"series": "/storage/media/series"},
|
|
})
|
|
composed, err := got.Compose(with)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
why := composed.LeftOut["arr"]
|
|
if why == "" || !strings.Contains(why, `"spool"`) || !strings.Contains(why, AccessesSetting) {
|
|
t.Fatalf("an access nobody placed was not refused by name: %v", composed.LeftOut)
|
|
}
|
|
if _, declared := byID(composed.Resources)["arr.server"]; declared {
|
|
t.Fatal("the module with the unplaced access was declared anyway")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// **Placement is read from the placement, never from a layer's values** (novox/hq ADR 0348 clause 11, issue
|
|
// 651). A `places` or `accesses` among the config values places nothing; the same in Placement does. A
|
|
// mergeable file of today's form still takes every key of the layer as it is said, placement too, so it
|
|
// composes as it did while placement was kept in the layer.
|
|
func TestPlacementIsReadFromThePlacementOnly(t *testing.T) {
|
|
m := placeable()
|
|
said := map[string]any{
|
|
PlacesSetting: map[string]any{"config": "/services/arr/config"},
|
|
AccessesSetting: map[string]any{"series": "/storage/media/series", "spool": "/storage/downloads"},
|
|
"log": "warn",
|
|
}
|
|
among := []Layer{{From: "anchor", Values: said}}
|
|
if placed, err := Places(m, among); err != nil || placed != nil {
|
|
t.Fatalf("a place among the config values placed %v (%v)", placed, err)
|
|
}
|
|
if _, _, err := accessesFor(m, among); err == nil {
|
|
t.Fatal("an access among the config values placed it")
|
|
}
|
|
beside := []Layer{LayerOf("anchor", said)}
|
|
if beside[0].Values["log"] != "warn" || beside[0].Values[PlacesSetting] != nil || beside[0].Placement[PlacesSetting] == nil {
|
|
t.Fatalf("LayerOf split %+v", beside[0])
|
|
}
|
|
if placed, err := Places(m, beside); err != nil || placed["config"].Path != "/services/arr/config" {
|
|
t.Fatalf("the placement did not place: %v (%v)", placed, err)
|
|
}
|
|
|
|
file := map[string]any{"id": "runtime", "type": "file", "path": "/x", "content": `{"a":1}`, "merge": "json"}
|
|
whole, err := ApplySettings(file, among)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
split, err := ApplySettings(file, beside)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if whole["content"] != split["content"] || !strings.Contains(split["content"].(string), `"places"`) {
|
|
t.Fatalf("today's mergeable file composes %q, was %q", split["content"], whole["content"])
|
|
}
|
|
}
|