Seals are reused and compared by slot (resource, field), never across resources; one rule says which resolved config values are laid; a seat path that steps back is owned by the seat it steps back for, whatever the seat order; a folded module of an old-form build is composed from what runs of it; the operator export's file is carried as it runs; a differing resource names its differing JSON paths, found before its bodies are cut, and an answer is fitted to the node's key.
235 lines
9.4 KiB
Go
235 lines
9.4 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"encoding/json"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// issue 900, novox/hq ADR 0394 §1: node-files is a node seat whose verbs say the commands they replace.
|
|
func TestNodeFilesIsANodeSeatWithEveryVerbOfADR0394(t *testing.T) {
|
|
s, ok := SeatNamed(FilesSeat)
|
|
if !ok {
|
|
t.Fatalf("the mesh defines no seat %s", FilesSeat)
|
|
}
|
|
if s.Scope != ScopeNode || s.Delivers != "" || !strings.Contains(s.Decision, "ADR 0394") {
|
|
t.Errorf("%s: scope %q, delivers %q, decision %q", FilesSeat, s.Scope, s.Delivers, s.Decision)
|
|
}
|
|
have := map[string]bool{}
|
|
replaced := map[string]string{}
|
|
for _, v := range s.Serves {
|
|
have[v.Name] = true
|
|
if v.Optional {
|
|
t.Errorf("%s.%s is optional", FilesSeat, v.Name)
|
|
}
|
|
// upload and download (ADR 0468 §9, issue 1718) move bytes through the object store: no command is theirs.
|
|
if len(v.Replaces) == 0 && v.Name != "upload" && v.Name != "download" {
|
|
t.Errorf("%s.%s says nothing it replaces", FilesSeat, v.Name)
|
|
}
|
|
for _, c := range v.Replaces {
|
|
replaced[c] = v.Name
|
|
}
|
|
}
|
|
for _, name := range []string{"stat", "readlink", "list", "read", "search", "account", "write", "copy", "move",
|
|
"delete", "mkdir", "chmod", "chown", "upload", "download", "trash", "restore"} {
|
|
if !have[name] {
|
|
t.Errorf("%s serves no verb %s", FilesSeat, name)
|
|
}
|
|
}
|
|
for command, verb := range map[string]string{"cat": "read", "tee": "write", "cp": "copy", "mv": "move",
|
|
"rm": "delete", "mkdir -p": "mkdir", "chmod": "chmod", "chgrp": "chown"} {
|
|
if replaced[command] != verb {
|
|
t.Errorf("%q is replaced by %q, want %s.%s", command, replaced[command], FilesSeat, verb)
|
|
}
|
|
}
|
|
}
|
|
|
|
// issue 900, ADR 0394 §2: a holder of node-files runs as the tool runner's account, so it declares no runs-as.
|
|
func TestAHolderOfNodeFilesDeclaringRunsAsIsRefused(t *testing.T) {
|
|
p := FilesHolderProblems(Manifest{Module: "files", RunsAs: "mesh-files", Claims: []Claim{{Name: FilesSeat}}})
|
|
if len(p) != 1 || !strings.Contains(p[0], "0394") || !strings.Contains(p[0], "runs-as") {
|
|
t.Errorf("a holder declaring runs-as was not refused: %v", p)
|
|
}
|
|
if p := FilesHolderProblems(Manifest{Module: "files", Claims: []Claim{{Name: FilesSeat}}}); len(p) != 0 {
|
|
t.Errorf("a holder declaring none was refused: %v", p)
|
|
}
|
|
if p := FilesHolderProblems(Manifest{Module: "other", RunsAs: "x"}); len(p) != 0 {
|
|
t.Errorf("a non-holder was judged: %v", p)
|
|
}
|
|
}
|
|
|
|
// issue 900, ADR 0394 §4: the holder is told the secret places and the paths modules own on its node.
|
|
func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) {
|
|
files := Manifest{Module: "files", Version: "1", Claims: []Claim{{Name: FilesSeat}},
|
|
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/files/broker"}}}
|
|
host := Manifest{Module: "hostnames", Version: "1", Claims: []Claim{{Name: "node-hostname"}},
|
|
Resources: []map[string]any{{"id": "hosts", "type": "file", "path": "/etc/hosts"}}}
|
|
app := Manifest{Module: "app", Version: "1",
|
|
Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/app/app.conf"}}}
|
|
r := Resolution{Node: "anchor", Modules: []Manifest{files, host, app}}
|
|
words, err := r.filesWords(files, nil, Rendering{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var places []string
|
|
if err := json.Unmarshal([]byte(words[ReadsSecretPlaces]), &places); err != nil || len(places) == 0 {
|
|
t.Errorf("no secret places: %v %v", err, words)
|
|
}
|
|
var owned map[string]string
|
|
if err := json.Unmarshal([]byte(words[FilesOwnedPaths]), &owned); err != nil {
|
|
t.Fatalf("no owned paths: %v (%v)", err, words)
|
|
}
|
|
if !strings.HasPrefix(owned["/etc/hosts"], "hostnames") {
|
|
t.Errorf("/etc/hosts is owned by %q, want hostnames", owned["/etc/hosts"])
|
|
}
|
|
if !strings.HasPrefix(owned["/etc/app/app.conf"], "app") {
|
|
t.Errorf("a placed file is owned by %q, want app", owned["/etc/app/app.conf"])
|
|
}
|
|
// Issue 1126, C3: a seat path that steps back for a seat held here is that holder's, whatever the order of the seat
|
|
// table (the controller reads it sorted by name, a node in the order compiled).
|
|
resolver := Manifest{Module: "systemd-resolved", Version: "1", Claims: []Claim{{Name: ResolverSeat}},
|
|
Resources: []map[string]any{{"id": "stub", "type": "file", "path": ResolverFile}}}
|
|
uplink := Manifest{Module: "networkmanager", Version: "1", Claims: []Claim{{Name: "node-uplink"}},
|
|
Resources: []map[string]any{{"id": "resolv", "type": "file", "path": ResolverFile}}}
|
|
compiled := seats
|
|
t.Cleanup(func() { seats = compiled })
|
|
for _, order := range []string{"compiled", "reversed"} {
|
|
if order == "reversed" {
|
|
seats = append([]Seat(nil), compiled...)
|
|
slices.Reverse(seats)
|
|
}
|
|
both := Resolution{Node: "anchor", Modules: []Manifest{files, uplink, resolver}}
|
|
w, err := both.filesWords(files, nil, Rendering{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var o map[string]string
|
|
if err := json.Unmarshal([]byte(w[FilesOwnedPaths]), &o); err != nil || o[ResolverFile] != "systemd-resolved ("+ResolverSeat+")" {
|
|
t.Errorf("seat table %s: %s is owned by %q, want systemd-resolved (%s)", order, ResolverFile, o[ResolverFile], ResolverSeat)
|
|
}
|
|
}
|
|
seats = compiled
|
|
// Issue 1126, C5: a module carried as it runs (compose.Carried) owns the paths it runs at, beside its resources'.
|
|
carried := Manifest{Module: "de-spiegel", Version: "1", RunsAt: []string{"/var/lib/de-spiegel/server.env"}}
|
|
w, err := Resolution{Node: "anchor", Modules: []Manifest{files, carried}}.filesWords(files, nil, Rendering{})
|
|
if err != nil || !strings.Contains(w[FilesOwnedPaths], `"/var/lib/de-spiegel/server.env":"de-spiegel (its own tools)"`) {
|
|
t.Errorf("the paths a carried module runs at are not owned: %v %v", w[FilesOwnedPaths], err)
|
|
}
|
|
if w, _ := r.filesWords(app, nil, Rendering{}); w[FilesOwnedPaths] != "" {
|
|
t.Error("a module not holding node-files is told the owned paths")
|
|
}
|
|
}
|
|
|
|
// issue 900: restore, like every write verb, takes a reason and an override.
|
|
func TestNodeFilesRestoreTakesIdAndReason(t *testing.T) {
|
|
s, _ := SeatNamed(FilesSeat)
|
|
for _, v := range s.Serves {
|
|
if v.Name != "restore" {
|
|
continue
|
|
}
|
|
props, _ := v.Input["properties"].(map[string]any)
|
|
for _, p := range []string{"id", "reason", "override"} {
|
|
if _, has := props[p]; !has {
|
|
t.Errorf("restore has no input %s", p)
|
|
}
|
|
}
|
|
if req, _ := v.Input["required"].([]string); strings.Join(req, ",") != "id,reason" {
|
|
t.Errorf("restore requires %v, want id and reason", req)
|
|
}
|
|
return
|
|
}
|
|
t.Error("no restore verb")
|
|
}
|
|
|
|
// 1718/AC1: node-files names upload and download, in mesh-files' order, before trash.
|
|
func TestNodeFilesNamesUploadAndDownloadBeforeTrash(t *testing.T) {
|
|
s, _ := SeatNamed(FilesSeat)
|
|
var names []string
|
|
for _, v := range s.Serves {
|
|
names = append(names, v.Name)
|
|
}
|
|
joined := strings.Join(names, ",")
|
|
if !strings.Contains(joined, "chown,upload,download,trash") {
|
|
t.Errorf("1718/AC1: node-files' verbs are %s, want upload and download between chown and trash", joined)
|
|
}
|
|
}
|
|
|
|
// 1718/AC2: a holder serving every verb including upload and download is accepted; one naming a verb outside the
|
|
// contract is still refused, and one leaving upload out is refused.
|
|
func TestAHolderOfNodeFilesServesUploadAndDownloadAndNothingElse(t *testing.T) {
|
|
s, _ := SeatNamed(FilesSeat)
|
|
var verbs []string
|
|
for _, v := range s.Serves {
|
|
verbs = append(verbs, v.Name)
|
|
}
|
|
hold := func(serves []string) error {
|
|
return CanHold(Manifest{Module: "mesh-files", Version: "1",
|
|
Claims: []Claim{{Name: FilesSeat, Scope: ScopeNode, Serves: serves}}}, s)
|
|
}
|
|
if err := hold(verbs); err != nil {
|
|
t.Errorf("1718/AC2: a holder serving every verb was refused: %v", err)
|
|
}
|
|
if err := hold(append(append([]string{}, verbs...), "sync")); err == nil || !strings.Contains(err.Error(), "does not promise") {
|
|
t.Errorf("1718/AC2: a verb outside the contract was accepted: %v", err)
|
|
}
|
|
var without []string
|
|
for _, v := range verbs {
|
|
if v != "upload" {
|
|
without = append(without, v)
|
|
}
|
|
}
|
|
if err := hold(without); err == nil || !strings.Contains(err.Error(), "upload") {
|
|
t.Errorf("1718/AC2: a holder leaving upload out was accepted: %v", err)
|
|
}
|
|
}
|
|
|
|
// 1718/AC3: upload requires path and bucket; download requires bucket, key, path and reason; sha256, key (upload)
|
|
// and override are plain strings, not true/false switches.
|
|
func TestNodeFilesUploadAndDownloadInputs(t *testing.T) {
|
|
s, _ := SeatNamed(FilesSeat)
|
|
want := map[string]struct {
|
|
required string
|
|
strs []string
|
|
}{
|
|
"upload": {"path,bucket", []string{"path", "bucket", "key"}},
|
|
"download": {"bucket,key,path,reason", []string{"bucket", "key", "path", "sha256", "reason", "override"}},
|
|
}
|
|
seen := 0
|
|
for _, v := range s.Serves {
|
|
w, ok := want[v.Name]
|
|
if !ok {
|
|
continue
|
|
}
|
|
seen++
|
|
req, _ := v.Input["required"].([]string)
|
|
got := append([]string{}, req...)
|
|
sortStrings(got)
|
|
exp := strings.Split(w.required, ",")
|
|
sortStrings(exp)
|
|
if strings.Join(got, ",") != strings.Join(exp, ",") {
|
|
t.Errorf("1718/AC3: %s requires %v, want %s", v.Name, req, w.required)
|
|
}
|
|
props, _ := v.Input["properties"].(map[string]any)
|
|
if len(props) != len(w.strs) {
|
|
t.Errorf("1718/AC3: %s has %d inputs, want %v", v.Name, len(props), w.strs)
|
|
}
|
|
for _, name := range w.strs {
|
|
p, has := props[name].(map[string]any)
|
|
if !has {
|
|
t.Errorf("1718/AC3: %s has no input %s", v.Name, name)
|
|
continue
|
|
}
|
|
if _, enum := p["enum"]; enum || p["type"] != "string" {
|
|
t.Errorf("1718/AC3: %s input %s is %v, want a plain string", v.Name, name, p)
|
|
}
|
|
}
|
|
}
|
|
if seen != 2 {
|
|
t.Errorf("1718/AC3: found %d of upload and download", seen)
|
|
}
|
|
}
|
|
|
|
func sortStrings(s []string) { sort.Strings(s) }
|