An unplaceable line of a left-out module, such as an access nobody placed, failed the whole machine's declaration. Say it among what could not be placed instead, never copy the definition's path past a placement that does not read, and accept a removal only when the decoder is past it.
451 lines
22 KiB
Go
451 lines
22 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// A preference has a default in the definition; the mesh's layer, then the node's, override it; a key
|
|
// with a default is not stray; and a value that is the operator's still has none (novox/hq ADR 0262).
|
|
|
|
func notifier() Manifest {
|
|
return Manifest{Module: "notifier",
|
|
Settings: map[string]SettingDeclaration{
|
|
"font-size": {Kind: KindPreference, Default: float64(10), Why: "readable at a scale of one"},
|
|
"width": {Kind: KindPreference, Default: float64(250), Why: "fits a title of forty characters"},
|
|
},
|
|
Resources: []map[string]any{{"id": "configuration", "type": "file", "path": "/x/notifierrc",
|
|
"content": "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n"}},
|
|
}
|
|
}
|
|
|
|
func parsed(t *testing.T, m Manifest) error {
|
|
t.Helper()
|
|
raw, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = ParseManifest(raw)
|
|
return err
|
|
}
|
|
|
|
func TestAnUnsetPreferenceTakesItsDefault(t *testing.T) {
|
|
m := notifier()
|
|
for _, layers := range [][]Layer{nil, {{From: MeshWideLayer, Values: map[string]any{}}}} {
|
|
file := map[string]any{}
|
|
for k, v := range m.Resources[0] {
|
|
file[k] = v
|
|
}
|
|
if err := settingInto(file, WithDefaults(m, layers), m.Module); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if file["content"] != "font = Inter 10\nwidth = 250\n" {
|
|
t.Fatalf("filled as %q", file["content"])
|
|
}
|
|
}
|
|
if err := JudgeSettings(m, nil, false); err != nil {
|
|
t.Fatalf("a module whose every key has a default does not compose with no layer: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTheNodeOverTheMeshOverTheDefault(t *testing.T) {
|
|
m := notifier()
|
|
layers := []Layer{
|
|
{From: MeshWideLayer, Values: map[string]any{"width": float64(300)}},
|
|
{From: "laptop", Values: map[string]any{"font-size": float64(13), "width": float64(340)}},
|
|
}
|
|
file := map[string]any{"type": "file", "content": m.Resources[0]["content"]}
|
|
if err := settingInto(file, WithDefaults(m, layers), m.Module); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if file["content"] != "font = Inter 13\nwidth = 340\n" {
|
|
t.Fatalf("filled as %q", file["content"])
|
|
}
|
|
file = map[string]any{"type": "file", "content": m.Resources[0]["content"]}
|
|
if err := settingInto(file, WithDefaults(m, layers[:1]), m.Module); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if file["content"] != "font = Inter 10\nwidth = 300\n" {
|
|
t.Fatalf("the mesh's layer over the default filled as %q", file["content"])
|
|
}
|
|
}
|
|
|
|
// Composed for a machine, the way a push writes it: the default reaches the file, and the node's
|
|
// layer overrides it.
|
|
func TestAComposedMachineGetsTheDefaultAndTheNodesValue(t *testing.T) {
|
|
r := anAdoptedAnchor()
|
|
r.Modules = append(r.Modules, notifier())
|
|
with := anchorRendering(false)
|
|
composed, err := r.Compose(with)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if why, left := composed.LeftOut["notifier"]; left {
|
|
t.Fatalf("the notifier was left out: %s", why)
|
|
}
|
|
if c := byID(composed.Resources)["notifier.configuration"]["content"]; c != "font = Inter 10\nwidth = 250\n" {
|
|
t.Fatalf("composed with no layer as %q", c)
|
|
}
|
|
with.Settings["notifier"] = []Layer{{From: "anchor", Values: map[string]any{"font-size": float64(13)}}}
|
|
if composed, err = r.Compose(with); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c := byID(composed.Resources)["notifier.configuration"]["content"]; c != "font = Inter 13\nwidth = 250\n" {
|
|
t.Fatalf("composed with the node's font size as %q", c)
|
|
}
|
|
}
|
|
|
|
// Setting a key the module gives a default is not refused as reaching nothing: it overrides the
|
|
// default, which is how a running module gains a setting with no gap between.
|
|
func TestAKeyWithADefaultIsNotStray(t *testing.T) {
|
|
m := notifier()
|
|
m.Resources[0]["content"] = "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n"
|
|
stray := UnusedSettings(m, []Layer{{From: "laptop", Values: map[string]any{"font-size": float64(13), "colour": "red"}}})
|
|
joined := strings.Join(stray, "; ")
|
|
if strings.Contains(joined, `"font-size"`) || !strings.Contains(joined, `"colour"`) {
|
|
t.Fatalf("stray: %s", joined)
|
|
}
|
|
}
|
|
|
|
// A default fills ${setting:…} only: it never becomes a key of a mergeable file (issue 168).
|
|
func TestADefaultIsNotMergedIntoAJSONFile(t *testing.T) {
|
|
m := notifier()
|
|
m.Resources = append(m.Resources, map[string]any{"id": "other", "type": "file", "path": "/x/other.json",
|
|
"merge": MergeJSON, "content": `{"keep": 1}`})
|
|
out, err := ApplySettings(m.Resources[1], WithDefaults(m, nil))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(out["content"].(string), "font-size") {
|
|
t.Fatalf("a default reached a mergeable file: %s", out["content"])
|
|
}
|
|
}
|
|
|
|
// A value that is the operator's has no default: no layer setting it is refused by name, as before.
|
|
func TestAnOperatorsValueWithoutADefaultIsStillRefused(t *testing.T) {
|
|
m := notifier()
|
|
m.Resources[0]["content"] = "font = Inter ${setting:font-size}\nwidth = ${setting:width}\nfrom = ${setting:domain}\n"
|
|
err := JudgeSettings(m, nil, false)
|
|
if err == nil || !strings.Contains(err.Error(), "${setting:domain}") {
|
|
t.Fatalf("judged %v", err)
|
|
}
|
|
if strings.Contains(err.Error(), "font-size") {
|
|
t.Fatalf("a default was named as set today: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTheParserTakesAPreferenceAndRefusesTheRest(t *testing.T) {
|
|
if err := parsed(t, notifier()); err != nil {
|
|
t.Fatalf("a preference with a default was refused: %v", err)
|
|
}
|
|
for _, c := range []struct {
|
|
name string
|
|
key string
|
|
d SettingDeclaration
|
|
refuse string
|
|
}{
|
|
{"no kind", "font-size", SettingDeclaration{Default: float64(10), Why: "x"}, `only a "preference" has a default`},
|
|
{"another kind", "font-size", SettingDeclaration{Kind: "operator", Default: float64(10), Why: "x"}, `only a "preference"`},
|
|
{"no default", "font-size", SettingDeclaration{Kind: KindPreference, Why: "x"}, "no default"},
|
|
{"an empty default", "font-size", SettingDeclaration{Kind: KindPreference, Default: " ", Why: "x"}, "an empty default"},
|
|
{"an object", "font-size", SettingDeclaration{Kind: KindPreference, Default: map[string]any{"a": 1.0}, Why: "x"}, "a string, a number or a boolean"},
|
|
{"no why", "font-size", SettingDeclaration{Kind: KindPreference, Default: float64(10)}, "no why"},
|
|
{"the operator's", "mail-domain", SettingDeclaration{Kind: KindPreference, Default: "example.tld", Why: "x"}, "is the operator's value"},
|
|
{"a secret", "api-token", SettingDeclaration{Kind: KindPreference, Default: "x", Why: "x"}, "is the operator's value"},
|
|
{"the mesh's word", PortsSetting, SettingDeclaration{Kind: KindPreference, Default: float64(1), Why: "x"}, "the mesh reads"},
|
|
{"read by nothing", "height", SettingDeclaration{Kind: KindPreference, Default: float64(300), Why: "x"}, "reaches nothing"},
|
|
} {
|
|
m := notifier()
|
|
m.Resources[0]["content"] = m.Resources[0]["content"].(string) + "x = ${setting:" + c.key + "}\n"
|
|
if c.name == "read by nothing" {
|
|
m.Resources[0]["content"] = "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n"
|
|
}
|
|
m.Settings[c.key] = c.d
|
|
err := parsed(t, m)
|
|
if err == nil || !strings.Contains(err.Error(), c.refuse) {
|
|
t.Errorf("%s: parsed %v, want %q", c.name, err, c.refuse)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEveryValueSaysWhereItCameFrom(t *testing.T) {
|
|
m := notifier()
|
|
m.Resources[0]["content"] = m.Resources[0]["content"].(string) + "x = ${setting:position}\n"
|
|
got := Effective(m, []Layer{
|
|
{From: MeshWideLayer, Values: map[string]any{"width": float64(300), "position": "top-right"}},
|
|
{From: "laptop", Values: map[string]any{"font-size": float64(13)}},
|
|
})
|
|
want := map[string]string{"font-size": "laptop", "position": MeshWideLayer, "width": MeshWideLayer}
|
|
if len(got) != 3 {
|
|
t.Fatalf("effective: %+v", got)
|
|
}
|
|
for _, s := range got {
|
|
if s.From != want[s.Key] {
|
|
t.Errorf("%s from %q, want %q", s.Key, s.From, want[s.Key])
|
|
}
|
|
}
|
|
if got[0].Key != "font-size" || got[0].Value != float64(13) || got[0].Default != float64(10) {
|
|
t.Fatalf("font-size: %+v", got[0])
|
|
}
|
|
only := Effective(m, nil)
|
|
if only[0].From != DefaultLayer || only[0].Value != float64(10) {
|
|
t.Fatalf("with no layer: %+v", only)
|
|
}
|
|
}
|
|
|
|
// A node may be called `default`. Its layer is a node's like any other: it overrides the module's
|
|
// default, it merges into a mergeable file, and it is named among what is set.
|
|
func TestANodeCalledDefaultIsANodesLayer(t *testing.T) {
|
|
m := notifier()
|
|
node := []Layer{{From: DefaultLayer, Values: map[string]any{"font-size": float64(13)}}}
|
|
file := map[string]any{"type": "file", "content": m.Resources[0]["content"]}
|
|
if err := settingInto(file, WithDefaults(m, node), m.Module); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if file["content"] != "font = Inter 13\nwidth = 250\n" {
|
|
t.Fatalf("the node called default was dropped: %q", file["content"])
|
|
}
|
|
out, err := ApplySettings(map[string]any{"id": "j", "type": "file", "merge": MergeJSON, "content": `{}`}, node)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(out["content"].(string), `"font-size": 13`) {
|
|
t.Fatalf("the node called default did not merge: %s", out["content"])
|
|
}
|
|
if got := Effective(m, node); got[0].FromDefault || got[0].Value != float64(13) {
|
|
t.Fatalf("the node's value read as the default: %+v", got[0])
|
|
}
|
|
}
|
|
|
|
// The operator's own value is told by what a key's name is about: its last word, or its last two as
|
|
// a known compound; a plural as its singular; and never a number or a switch.
|
|
func TestAKeyIsTheOperatorsByWhatItIsAbout(t *testing.T) {
|
|
for _, key := range []string{"max-tokens", "show-hostname", "ghost-opacity", "users-per-page", "mailbox-size",
|
|
"font-size", "width", "keyboard-delay", "ipv6-preferred", "client-width", "user-agent", "url-timeout",
|
|
"site-title", "cert-renewal-days", "name", "font-name", "allow-resize", "use-gpu", "disable-sender-check",
|
|
"max-recipients", "gateway-timeout", "sender-delay", "upstream-resolvers", "mirror-countries",
|
|
"pool-region", "proxy-timeout", "listen-backlog", "peer-keepalive", "admin-theme", "log-file",
|
|
"cache-path"} {
|
|
if w := operatorsWord(key); w != "" {
|
|
t.Errorf("%s read as the operator's (%s)", key, w)
|
|
}
|
|
}
|
|
for key, word := range map[string]string{"mail-domain": "domain", "api-key": "key", "admin-password": "password",
|
|
"oauth-client-id": "client-id", "db-dsn": "dsn", "public-ip": "ip", "puid": "puid", "dns-zone": "zone",
|
|
"webhook": "webhook", "notify_phone": "phone", "cors.origin": "origin", "smtp-pass": "pass",
|
|
"backup-passphrase": "passphrase", "data-owner": "owner", "fqdn": "fqdn", "tenant": "tenant", "host": "host",
|
|
"allowed-hosts": "host", "admin-emails": "email", "tokens": "token", "hostname": "hostname",
|
|
"apikey": "apikey", "servername": "servername", "tls-cert": "cert", "site": "site", "timezone": "timezone",
|
|
"bearer": "bearer", "bind-ipv4": "ipv4", "listen-ipv6": "ipv6", "site-name": "site-name",
|
|
"server-name": "server-name", "public-name": "public-name", "smtp-relay": "smtp-relay",
|
|
"host-name": "host-name", "user-name": "user-name", "domain-name": "domain-name",
|
|
"nameserver": "nameserver", "upstream-nameservers": "nameserver", "dns-server": "dns-server",
|
|
"dns-servers": "dns-server", "default-gateway": "gateway", "lan-subnet": "subnet", "sender": "sender",
|
|
"notify-recipients": "recipient", "contact": "contact", "tls-certificate": "certificate",
|
|
"allow-from": "allow-from", "deny-from": "deny-from", "allow-hosts": "host", "use-host": "host",
|
|
"trusted": "trusted", "allow-list": "allow-list", "ip-whitelist": "whitelist", "peer": "peer",
|
|
"wireguard-peers": "peer", "bind": "bind", "listen": "listen", "upstream": "upstream", "http-proxy": "proxy",
|
|
"trusted-proxies": "proxy", "admin": "admin", "notify-admin": "admin", "wake-mac": "mac",
|
|
"password-file": "password-file", "key-file": "key-file", "token-path": "token-path",
|
|
"secret-file": "secret-file", "cert-path": "cert-path"} {
|
|
if w := operatorsWord(key); w != word {
|
|
t.Errorf("%s: read %q, want %q", key, w, word)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A default is a value the mesh writes, so the installation check reads it, even under a setting
|
|
// called `description` or `why`; a setting's own why is prose and is not read.
|
|
func TestTheInstallationCheckReadsADefault(t *testing.T) {
|
|
m := notifier()
|
|
m.Settings["relay"] = SettingDeclaration{Kind: KindPreference, Default: "relay.acme.be", Why: "as at relay.acme.be"}
|
|
m.Settings["description"] = SettingDeclaration{Kind: KindPreference, Default: "notes.acme.be", Why: "x"}
|
|
problems := strings.Join(InstallationProblems(m), "; ")
|
|
for _, want := range []string{"relay.acme.be at settings.relay.default", "notes.acme.be at settings.description.default"} {
|
|
if !strings.Contains(problems, want) {
|
|
t.Errorf("not reported: %q in %s", want, problems)
|
|
}
|
|
}
|
|
if strings.Contains(problems, "settings.relay.why") {
|
|
t.Errorf("a why was read as a value: %s", problems)
|
|
}
|
|
}
|
|
|
|
// A default fills ${setting:…} in what a module contributes and serves, and never replaces a value a
|
|
// contribution states itself.
|
|
func TestADefaultFillsAContributionAndAServedFactButNotALiteral(t *testing.T) {
|
|
m := notifier()
|
|
m.Settings["site-title"] = SettingDeclaration{Kind: KindPreference, Default: "Notes", Why: "x"}
|
|
contribution := map[string]any{"title": "${setting:site-title}", "width": "fixed", "port": float64(8080)}
|
|
got, err := overridden(contribution, WithDefaults(m, nil), "a contribution")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got["title"] != "Notes" || got["width"] != "fixed" {
|
|
t.Fatalf("contribution: %v", got)
|
|
}
|
|
got, err = overridden(contribution, WithDefaults(m, []Layer{{From: "laptop", Values: map[string]any{"width": "wide"}}}), "a contribution")
|
|
if err != nil || got["width"] != "wide" {
|
|
t.Fatalf("a node's value did not override a contribution's own key: %v %v", got, err)
|
|
}
|
|
served, err := Settle(map[string]any{"name": "${setting:site-title}"}, WithDefaults(m, nil))
|
|
if err != nil || served["name"] != "Notes" {
|
|
t.Fatalf("served: %v %v", served, err)
|
|
}
|
|
if _, err := Settle(map[string]any{"name": "${setting:site-title}"}, nil); err == nil {
|
|
t.Fatal("a served fact without the defaults was filled")
|
|
}
|
|
}
|
|
|
|
// A stored manifest with a key this controller does not know, at the top or inside any block, is read
|
|
// and its module is left out of every declaration by name; the rest of the catalogue is read; the module
|
|
// check refuses it. One case per block whose decoder wraps the decoder's words in its own.
|
|
func TestAStoredManifestWithAnUnknownKeyIsLeftOutAndRegistrationRefusesIt(t *testing.T) {
|
|
for where, raw := range map[string]string{
|
|
"the top": `{"module": "later", "version": "2", "a-field-from-later": {"x": 1}}`,
|
|
"a state": `{"module": "later", "version": "2", "state": [{"name": "s", "a-field-from-later": 1}]}`,
|
|
"a provided name": `{"module": "later", "version": "2", "provides": [{"name": "p", "a-field-from-later": 1}]}`,
|
|
"an offer's identity": `{"module": "later", "version": "2", "provides": [{"name": "p", "identity": {"in": "x", "a-field-from-later": 1}}]}`,
|
|
"a backup": `{"module": "later", "version": "2", "data": {"own": [{"id": "d", "path": "${dir:d}", "class": "valuable", "backup": {"dump": "x", "into": "y", "a-field-from-later": 1}}]}}`,
|
|
"an own secret": `{"module": "later", "version": "2", "own-secrets": {"s": {"path": "/x", "a-field-from-later": 1}}}`,
|
|
"a seat's verb": `{"module": "later", "version": "2", "seats": [{"name": "later-seat", "serves": [{"name": "v", "a-field-from-later": 1}]}]}`,
|
|
} {
|
|
var m Manifest
|
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
|
t.Errorf("%s: the stored manifest was not read: %v", where, err)
|
|
continue
|
|
}
|
|
if m.Module != "later" || m.Version != "2" || !strings.Contains(m.UnknownField(), `"a-field-from-later"`) {
|
|
t.Errorf("%s: read as %q %q, unknown %q", where, m.Module, m.Version, m.UnknownField())
|
|
continue
|
|
}
|
|
if where != "the top" && !strings.Contains(m.UnknownField(), ": json: unknown field") {
|
|
t.Errorf("%s: the block's decoder did not say it: %q", where, m.UnknownField())
|
|
}
|
|
left := Resolution{Node: "laptop", Modules: []Manifest{m, notifier()}}.LeftOut(nil, false)
|
|
if why := left["later"]; !strings.Contains(why, "uses a field this controller does not know") ||
|
|
!strings.Contains(why, "a-field-from-later") {
|
|
t.Errorf("%s: not left out by name: %v", where, left)
|
|
}
|
|
if _, notifierLeft := left["notifier"]; notifierLeft {
|
|
t.Errorf("%s: another module was left out with it: %v", where, left)
|
|
}
|
|
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "a-field-from-later") {
|
|
t.Errorf("%s: the module check took it: %v", where, err)
|
|
}
|
|
}
|
|
var known Manifest
|
|
if err := json.Unmarshal([]byte(`{"module": "now", "state": [{"name": "s"}]}`), &known); err != nil || known.UnknownField() != "" {
|
|
t.Fatalf("a known manifest: %v %q", err, known.UnknownField())
|
|
}
|
|
// A malformed manifest is still refused: only an unknown key is read past.
|
|
var bad Manifest
|
|
if err := json.Unmarshal([]byte(`{"module": "bad", "state": [{"name": 3}]}`), &bad); err == nil {
|
|
t.Fatal("a malformed stored manifest was read")
|
|
}
|
|
}
|
|
|
|
// A module left out for a key this controller does not know, inside an entry, is read past that key
|
|
// alone: it still provides what it provides, its other entries are whole, and a key of the same name
|
|
// that another entry knows is kept (novox/hq ADR 0262).
|
|
func TestALeftOutModuleStillProvidesWhatItProvides(t *testing.T) {
|
|
var m Manifest
|
|
raw := `{"module": "later", "version": "2",
|
|
"provides": [{"name": "db", "scope": "mesh"}, {"name": "cache", "a-field-from-later": 1}],
|
|
"state": [{"name": "s", "history": 3}],
|
|
"data": {"own": [{"id": "d", "path": "${dir:d}", "class": "valuable", "backup": {"dump": "x", "into": "d", "class": "later"}}]},
|
|
"resources": [{"id": "d", "type": "directory", "mode": "0700"}]}`
|
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(m.Provides) != 2 || m.Provides[0].Name != "db" || m.Provides[1].Name != "cache" {
|
|
t.Fatalf("provides: %+v", m.Provides)
|
|
}
|
|
if len(m.State) != 1 || m.State[0].History != 3 {
|
|
t.Fatalf("state: %+v", m.State)
|
|
}
|
|
if m.Data == nil || len(m.Data.Own) != 1 || m.Data.Own[0].Class != "valuable" {
|
|
t.Fatalf("data: the item's own class was taken for the backup's unknown one: %+v", m.Data)
|
|
}
|
|
for _, want := range []string{"provides[1].a-field-from-later", "data.own[0].backup.class"} {
|
|
if !strings.Contains(m.UnknownField(), want) {
|
|
t.Errorf("unknown %q does not say it read without %s", m.UnknownField(), want)
|
|
}
|
|
}
|
|
if !strings.Contains(UnknownFieldReason(m), "left out") {
|
|
t.Fatal(UnknownFieldReason(m))
|
|
}
|
|
}
|
|
|
|
// A left-out module's data is still copied: the backup holder on its machine keeps its lines while every
|
|
// other thing of it is left out.
|
|
func TestALeftOutModulesDataIsStillBackedUp(t *testing.T) {
|
|
var later Manifest
|
|
if err := json.Unmarshal([]byte(`{"module": "later", "version": "2", "a-field-from-later": 1,
|
|
"resources": [{"id": "d", "type": "directory", "mode": "0700"}, {"id": "rc", "type": "file", "path": "/etc/later.conf", "content": "x\n"}],
|
|
"data": {"own": [{"id": "d", "path": "${dir:d}", "class": "valuable"}]}}`), &later); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
holder := Manifest{Module: "backups", Version: "1",
|
|
Claims: []Claim{{Name: BackupSeat, Scope: ScopeNode}},
|
|
Resources: []map[string]any{{"id": "list", "type": "file", "path": "/etc/backups.list", "mode": "0644",
|
|
"content": "${contribution:" + BackupSeat + ":backup}"}}}
|
|
r := anAdoptedAnchor()
|
|
r.Modules = append(r.Modules, holder, later)
|
|
composed, err := r.Compose(anchorRendering(false))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, left := composed.LeftOut["later"]; !left {
|
|
t.Fatalf("not left out: %v", composed.LeftOut)
|
|
}
|
|
got := byID(composed.Resources)
|
|
if _, declared := got["later.rc"]; declared {
|
|
t.Fatal("the left-out module's file is still declared")
|
|
}
|
|
list, _ := got["backups.list"]["content"].(string)
|
|
if !strings.Contains(list, "# later") || !strings.Contains(list, "path /var/lib/later/d") {
|
|
t.Fatalf("the left-out module's data is no longer backed up:\n%s", list)
|
|
}
|
|
}
|
|
|
|
// A left-out module's backup lines are best effort: a line that cannot be placed — an access nobody
|
|
// placed, a placement setting that does not read — is said among what could not be placed, and the
|
|
// machine is declared (novox/hq ADR 0262).
|
|
func TestALeftOutModulesUnplaceableBackupLineCostsOnlyThatLine(t *testing.T) {
|
|
holder := Manifest{Module: "backups", Version: "1",
|
|
Claims: []Claim{{Name: BackupSeat, Scope: ScopeNode}},
|
|
Resources: []map[string]any{{"id": "list", "type": "file", "path": "/etc/backups.list", "mode": "0644",
|
|
"content": "${contribution:" + BackupSeat + ":backup}"}}}
|
|
media := Manifest{Module: "media", Version: "1",
|
|
Accesses: []Access{{ID: "library", Mode: "read"}},
|
|
Data: &Data{Own: []DataItem{{ID: "library", Path: "${access:library}", Class: "valuable"}}}}
|
|
placedBadly := Manifest{Module: "notes", Version: "1",
|
|
Resources: []map[string]any{{"id": "d", "type": "directory", "path": "/srv/notes", "mode": "0700"}},
|
|
Data: &Data{Own: []DataItem{{ID: "d", Path: "${dir:d}", Class: "valuable"}}}}
|
|
r := anAdoptedAnchor()
|
|
r.Modules = append(r.Modules, holder, media, placedBadly)
|
|
with := anchorRendering(false)
|
|
with.Settings["notes"] = []Layer{{From: "anchor", Values: map[string]any{PlacesSetting: "not a map"}}}
|
|
composed, err := r.Compose(with)
|
|
if err != nil {
|
|
t.Fatalf("an unplaceable line of a left-out module failed the machine: %v", err)
|
|
}
|
|
for _, m := range []string{"media", "notes"} {
|
|
if _, left := composed.LeftOut[m]; !left {
|
|
t.Errorf("%s is not left out: %v", m, composed.LeftOut)
|
|
}
|
|
}
|
|
unplaced := strings.Join(composed.Unplaced, "\n")
|
|
for _, want := range []string{"media's backup for node-backup", "notes's backup for node-backup", "placement setting does not read"} {
|
|
if !strings.Contains(unplaced, want) {
|
|
t.Errorf("not said among what could not be placed: %q in\n%s", want, unplaced)
|
|
}
|
|
}
|
|
list, _ := byID(composed.Resources)["backups.list"]["content"].(string)
|
|
if strings.Contains(list, "/srv/notes") || strings.Contains(list, "${") {
|
|
t.Fatalf("a line was placed from the definition's default or unfilled:\n%s", list)
|
|
}
|
|
}
|