Merge main into the branch: the assignment's placement sits beside the mesh's own place (issue 174)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {<id>: <path>}`, novox/hq ADR 0112, issue 153) and the module's mounts name as
|
||||
// ${access:<id>}. 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)
|
||||
|
||||
@@ -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 — <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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user