From 1cc588ebc0606413cafed86ba4ef471203855a05 Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Tue, 13 Oct 2026 23:47:45 +0000 Subject: [PATCH] Say who owns a placed file at runtime, and which files the provisioner fills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provisioner (ADR 0349 §5) needs each file it places to say whether it is owned, a seed or managed, and the node-engine needs to know which paths it fills. The words map onto today's forms (drift written back, create-once, into), so no write changes; the reader accepts the fields and states OwnershipContract before any controller sends them. Issue 667. Co-Authored-By: Claude Opus 5.5 --- cmd/mesh-host/main.go | 7 +- cmd/mesh-host/units_test.go | 8 +- internal/apply/apply.go | 2 +- internal/apply/ownership_test.go | 129 ++++++++++++++++++++++++ internal/declaration/declaration.go | 126 +++++++++++++++++++++++- internal/declaration/ownership_test.go | 130 +++++++++++++++++++++++++ internal/link/messages.go | 7 ++ validate/validate_test.go | 7 ++ 8 files changed, 407 insertions(+), 9 deletions(-) create mode 100644 internal/apply/ownership_test.go create mode 100644 internal/declaration/ownership_test.go diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index eb779bd..0837e9a 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1647,7 +1647,7 @@ func declaredResolvConf(d *declaration.Declaration) (string, string, bool) { } for _, r := range d.Resources { f, ok := r.(*declaration.File) - if !ok || f.Path != network.ResolvConf || f.CreateOnce || f.Into != "" { + if !ok || f.Path != network.ResolvConf || f.Seeded() || f.Into != "" { continue } module, ok := liveness.ModuleOf(f.ID) @@ -1769,8 +1769,9 @@ func judgeWhatRuns(ctx context.Context, j *liveness.Judge, queue *link.Queue, sa func healthAsReported(st liveness.Statement, ns *network.Statement) *link.Health { // LibraryContract: this engine reads a resource's declared `health` and judges it (ADR 0240 Phase B), judges a // user's declared `root` (novox/hq ADR 0266), and reads a declaration's identities, groups and libraries - // (novox/hq ADR 0328), which is what tells the controller it may be sent any of those fields. - h := &link.Health{Contract: link.BusyContract, At: st.At.UTC(), Resources: []link.ResourceHealth{}} + // (novox/hq ADR 0328), a container's busy (ADR 0335) and a file's owner-at-runtime and the filled files (ADR + // 0349 §5), which is what tells the controller it may be sent any of those fields. + h := &link.Health{Contract: link.OwnershipContract, At: st.At.UTC(), Resources: []link.ResourceHealth{}} for _, r := range st.Resources { h.Resources = append(h.Resources, link.ResourceHealth{Module: r.Module, Resource: r.ID, Kind: r.Kind, Target: r.Target, State: r.State, Reason: r.Reason, Since: r.Since.UTC(), Streak: r.Streak, diff --git a/cmd/mesh-host/units_test.go b/cmd/mesh-host/units_test.go index 61e98a0..a81df0e 100644 --- a/cmd/mesh-host/units_test.go +++ b/cmd/mesh-host/units_test.go @@ -87,10 +87,10 @@ func TestTheStatementSaysAnAccountJudgedForRoot(t *testing.T) { Groups: []string{"openrazer"}}, State: accounts.Healthy, Since: at}, }} h := withAccounts(healthAsReported(liveness.Statement{At: at}, nil), as) - // At RootContract or later: an engine that also reads a container's busy says BusyContract (novox/hq ADR - // 0335), one above ADR 0334's OwnGroupContract. - if h.Contract < link.RootContract || link.RootContract != 3 || h.Contract != link.BusyContract || - link.BusyContract != link.OwnGroupContract+1 { + // At RootContract or later: an engine that also reads a file's owner-at-runtime and the filled files says + // OwnershipContract (novox/hq ADR 0349 §5), one above ADR 0335's BusyContract. + if h.Contract < link.RootContract || link.RootContract != 3 || h.Contract != link.OwnershipContract || + link.OwnershipContract != link.BusyContract+1 || link.BusyContract != link.OwnGroupContract+1 { t.Fatalf("contract %d", h.Contract) } got := map[string]string{} diff --git a/internal/apply/apply.go b/internal/apply/apply.go index a750c84..0305047 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -1617,7 +1617,7 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF // A seed that is already there is left exactly as it is — whatever has grown in it since is // not the mesh's to put back (novox/hq issue 035). What this host records is what it once // wrote, so a later declaration that changes the seed is not mistaken for drift either. - if r.CreateOnce && existed { + if r.Seeded() && existed { out.wrote = previous.Wrote if out.wrote == "" { out.wrote = digestOf(string(existing)) diff --git a/internal/apply/ownership_test.go b/internal/apply/ownership_test.go new file mode 100644 index 0000000..774cdd3 --- /dev/null +++ b/internal/apply/ownership_test.go @@ -0,0 +1,129 @@ +package apply + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/novox/mesh-host/internal/store" +) + +// Defends novox/hq ADR 0349 §5 (issue 667): a file that says who owns it at runtime is written as the +// form it names always was — owned as a whole file whose drift is written back, seed as create-once, +// managed as only its keys or its marked block. Each is applied beside its old form, through the same +// drift, and the two must end the same: the same actions, the same bytes on disk. + +// ownershipCase is one ownership word and the old form it maps onto. +type ownershipCase struct { + name string + // said and old are the resource's fields after id, type and path: with the word, and in the old form. + said, old string + // before is what is at the path before the first apply; "" leaves the path absent. + before string + // drift is what the machine writes there between the two applies. + drift string + // first and second are the actions the old form is known to report. + first, second string +} + +func TestEachOwnershipBehavesAsItsOldForm(t *testing.T) { + for _, c := range []ownershipCase{ + { + name: "owned is a whole file whose drift is written back", + said: `"content":"correct\n","mode":"0644","owner-at-runtime":"owned"`, + old: `"content":"correct\n","mode":"0644"`, + drift: "someone edited this\n", + first: "created", second: "corrected", + }, + { + name: "seed is written once and then the program's", + said: `"content":"user default on\n","mode":"0600","owner-at-runtime":"seed"`, + old: `"content":"user default on\n","mode":"0600","create-once":true`, + drift: "user default on\nuser app-one on\n", + first: "created", second: "kept", + }, + { + name: "managed json sets only its keys", + said: `"into":"json","content":"{\"insecure-registries\":[\"10.42.0.1:5000\"]}","owner-at-runtime":"managed"`, + old: `"into":"json","content":"{\"insecure-registries\":[\"10.42.0.1:5000\"]}"`, + before: machinesOwn, + drift: `{"data-root":"/srv/other","log-opts":{"max-size":"10m"},"insecure-registries":["192.0.2.7:5000"]}`, + first: "updated", second: "corrected", + }, + { + name: "managed block holds only its region", + said: `"into":"block","content":"10.42.0.1 ace\n","owner-at-runtime":"managed"`, + old: `"into":"block","content":"10.42.0.1 ace\n"`, + before: workstationHosts, + drift: workstationHosts + "\n" + marked("f", "6.6.6.6 evil\n") + "192.0.2.9 the-machines-own\n", + first: "updated", second: "corrected", + }, + } { + t.Run(c.name, func(t *testing.T) { + saidActions, saidBytes := applyThroughDrift(t, c, c.said) + oldActions, oldBytes := applyThroughDrift(t, c, c.old) + if saidActions != oldActions { + t.Errorf("with the word the actions were %s; the old form's were %s", saidActions, oldActions) + } + if want := c.first + " " + c.second; oldActions != want { + t.Errorf("the old form's actions were %s; known to be %s", oldActions, want) + } + if saidBytes != oldBytes { + t.Errorf("with the word the file ends\n%q\nthe old form's ends\n%q", saidBytes, oldBytes) + } + }) + } +} + +// applyThroughDrift applies one file, lets the machine write the case's drift into it, applies again, +// and says the two actions and what the file holds at the end. +func applyThroughDrift(t *testing.T, c ownershipCase, fields string) (string, string) { + t.Helper() + path := filepath.Join(t.TempDir(), "the-file") + if c.before != "" { + if err := os.WriteFile(path, []byte(c.before), 0o644); err != nil { + t.Fatal(err) + } + } + d := parse(t, fmt.Sprintf(`{"declaration":1,"resources":[{"id":"f","type":"file","path":%q,%s}]}`, path, fields)) + first, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(c.drift), 0o644); err != nil { + t.Fatal(err) + } + second, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + return first.Outcomes[0].Action + " " + second.Outcomes[0].Action, readText(t, path) +} + +// A seed said by the word alone, with no create-once beside it, is still kept once present: the word is +// enough, and the file the program grew is not written back. +func TestASeedByTheWordAloneKeepsWhatGrew(t *testing.T) { + path := filepath.Join(t.TempDir(), "acl.conf") + d := parse(t, fmt.Sprintf(`{"declaration":1,"resources":[ + {"id":"acl","type":"file","path":%q,"content":"user default on\n","owner-at-runtime":"seed"} + ]}`, path)) + _, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("grown\n"), 0o600); err != nil { + t.Fatal(err) + } + report, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, noServices, nil, nil) + if err != nil { + t.Fatal(err) + } + if got := report.Outcomes[0].Action; got != "kept" { + t.Errorf("a seed was %q", got) + } + if got := readText(t, path); got != "grown\n" { + t.Errorf("what grew in the seed was written over: %q", got) + } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index a265afa..0c165c3 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -237,8 +237,45 @@ type File struct { // Owner is the user this belongs to, by name. Absent means root. Owner string `json:"owner,omitempty"` + + // OwnerAtRuntime says who owns the file once it is placed (novox/hq ADR 0349 §5): "owned", the mesh's + // whole, written back whenever it differs; "seed", written once and then the program's; or "managed", + // only its named keys or its marked block the mesh's, the program keeping the rest. + // + // **Three words for what the forms above already do.** Owned is an ordinary file, seed is create-once + // and managed is a file written into, so the word changes no write: a file that says it is applied + // exactly as its old form is, and one that does not say is owned by its old form (Ownership). The word + // is what the provisioner's files say of themselves, which the old forms only implied. Seed is enough + // alone; managed still needs `into`, which says the format. A word that contradicts the form is refused. + OwnerAtRuntime string `json:"owner-at-runtime,omitempty"` } +// The words a file says who owns it at runtime with (novox/hq ADR 0349 §5). +const ( + RuntimeOwned = "owned" + RuntimeSeed = "seed" + RuntimeManaged = "managed" +) + +// Ownership is who owns the file at runtime: the word it says, or the one its old form implies — a file +// written into is managed, one created once is a seed, and any other is owned. +func (f *File) Ownership() string { + switch { + case f.OwnerAtRuntime != "": + return f.OwnerAtRuntime + case f.Into != "": + return RuntimeManaged + case f.CreateOnce: + return RuntimeSeed + default: + return RuntimeOwned + } +} + +// Seeded reports whether the file is written only when absent: created once, by create-once or by the +// word seed. +func (f *File) Seeded() bool { return f.CreateOnce || f.OwnerAtRuntime == RuntimeSeed } + // Secret reports whether this file arrived sealed, which is what decides both that it must be // opened before writing and that its contents must never appear in a report. func (f *File) Secret() bool { return f.Sealed != "" } @@ -320,6 +357,7 @@ func (f *File) validate(where string, _ bool) []string { "secrets or create-once") } } + problems = append(problems, f.checkOwnership(where)...) var said []string for name, value := range map[string]string{ "content": f.Content, "sealed": f.Sealed, "bytes": f.Bytes, @@ -361,6 +399,42 @@ func (f *File) validate(where string, _ bool) []string { return append(problems, checkMode(where, f.Mode)...) } +// checkOwnership refuses a word that contradicts the file's form (novox/hq ADR 0349 §5): owned is a whole +// file written back, so not created once nor written into; a seed is a whole file created once, so not +// written into; managed is a file written into, which says its format. Managed and created once together +// is already refused by the rule that a file written into is not created once. +func (f *File) checkOwnership(where string) []string { + switch f.OwnerAtRuntime { + case "": + case RuntimeOwned: + var also []string + if f.CreateOnce { + also = append(also, "create-once") + } + if f.Into != "" { + also = append(also, "into") + } + if len(also) > 0 { + return []string{fmt.Sprintf("%s: owned at runtime is the mesh's whole file, written back whenever it "+ + "differs, and this also says %s", where, strings.Join(also, " and "))} + } + case RuntimeSeed: + if f.Into != "" { + return []string{where + ": a seed is a whole file written once, and this says into; a file the " + + "program shares with the mesh is managed"} + } + case RuntimeManaged: + if f.Into == "" { + return []string{where + ": a managed file names the format its keys or block are written into, " + + "with into \"json\" or \"block\""} + } + default: + return []string{fmt.Sprintf("%s: owner-at-runtime %q; a placed file is %q, %q or %q, or omits it to be "+ + "owned by its form", where, f.OwnerAtRuntime, RuntimeOwned, RuntimeSeed, RuntimeManaged)} + } + return nil +} + // User is a login on the machine. // // The thing that makes a shell, a chat client or a desktop expressible at all: each is a package @@ -1565,6 +1639,52 @@ type Declaration struct { // this is its home, whether the operator's warrant to take it was carried out, the sub-paths its modules ask for, // and the tool runner's account to put in its group (§11). Absent is a controller older than ADR 0328. Libraries []Library + + // Filled is the paths of the files the provisioner fills on this machine (novox/hq ADR 0349 §4, §5): each a + // file this declaration declares, which says who owns it at runtime. The provisioner's client has the + // node-engine write only these. Absent is a controller that hands no file to the provisioner, and every + // file is written from the declaration as before. + Filled []string +} + +// Fills reports whether the provisioner fills the file at path on this machine. +func (d *Declaration) Fills(path string) bool { return slices.Contains(d.Filled, path) } + +// checkFilled refuses a filled path that is not a declared file saying who owns it at runtime, or that is named +// twice or empty. Every file declared at the path must say it: the provisioner writes the path, and a file there +// the mesh writes some other way would be a second writer of it (ADR 0349 §5). +func checkFilled(filled []string, resources []Resource) []string { + var problems []string + files := map[string][]*File{} + for _, r := range resources { + if f, ok := r.(*File); ok { + files[f.Path] = append(files[f.Path], f) + } + } + seen := map[string]bool{} + for _, path := range filled { + switch { + case path == "": + problems = append(problems, "filled_files names a file with no path") + continue + case seen[path]: + problems = append(problems, fmt.Sprintf("filled_files names %q twice", path)) + continue + } + seen[path] = true + if len(files[path]) == 0 { + problems = append(problems, fmt.Sprintf("filled_files names %q, and this declaration declares no "+ + "file there", path)) + continue + } + for _, f := range files[path] { + if f.OwnerAtRuntime == "" { + problems = append(problems, fmt.Sprintf("resource %q: the provisioner fills %q, and a file it "+ + "fills says who owns it at runtime with owner-at-runtime", f.ID, path)) + } + } + } + return problems } // Identity is one module's identity, as the controller allocated it (ADR 0328 §4): its user `mesh-` with @@ -1904,6 +2024,9 @@ type envelope struct { // sends them only to a host whose health says link.LibraryContract. Groups []Group `json:"groups,omitempty"` Libraries []Library `json:"libraries,omitempty"` + // Filled is optional too (novox/hq ADR 0349 §5), as is a file's `owner-at-runtime`. **An older host refuses + // these keys**; a controller sends them only to a host whose health says link.OwnershipContract. + Filled []string `json:"filled_files,omitempty"` } func parse(raw []byte, allowActions bool) (*Declaration, error) { @@ -1922,7 +2045,7 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) { d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption, Sequence: env.Sequence, Epoch: env.Epoch, LeftOut: env.LeftOut, Generation: env.Generation, PutBack: env.PutBack, - Identities: env.Identities, Groups: env.Groups, Libraries: env.Libraries} + Identities: env.Identities, Groups: env.Groups, Libraries: env.Libraries, Filled: env.Filled} problems := checkIdentities(env.Identities, env.Groups) problems = append(problems, checkLibraries(env.Groups, env.Libraries)...) if env.Generation < 0 { @@ -2015,6 +2138,7 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) { problems = append(problems, checkWhileStopped(d.Resources)...) problems = append(problems, checkAdoption(env.Adoption, d.Resources, allowActions)...) problems = append(problems, checkReplaces(d.Resources)...) + problems = append(problems, checkFilled(env.Filled, d.Resources)...) if env.Adoption == nil { for _, r := range d.Resources { if r.Kind() == TypeOpening { diff --git a/internal/declaration/ownership_test.go b/internal/declaration/ownership_test.go new file mode 100644 index 0000000..76b0674 --- /dev/null +++ b/internal/declaration/ownership_test.go @@ -0,0 +1,130 @@ +package declaration + +import ( + "strings" + "testing" +) + +// Defends novox/hq ADR 0349 §5 (issue 667): a file the provisioner places says who owns it at runtime +// — owned, seed or managed — and the declaration names the files the provisioner fills. The words are +// read and judged here before any controller sends them. + +func TestAFileSaysWhoOwnsItAtRuntime(t *testing.T) { + d, err := Parse([]byte(`{"declaration":1,"resources":[ + {"id":"o","type":"file","path":"/etc/o","content":"a","owner-at-runtime":"owned"}, + {"id":"s","type":"file","path":"/etc/s","content":"a","owner-at-runtime":"seed"}, + {"id":"s2","type":"file","path":"/etc/s2","content":"a","owner-at-runtime":"seed","create-once":true}, + {"id":"mj","type":"file","path":"/etc/mj","into":"json","content":"{\"k\":1}","owner-at-runtime":"managed"}, + {"id":"mb","type":"file","path":"/etc/mb","into":"block","content":"a\n","owner-at-runtime":"managed"} + ]}`)) + if err != nil { + t.Fatal(err) + } + want := map[string]string{"o": RuntimeOwned, "s": RuntimeSeed, "s2": RuntimeSeed, "mj": RuntimeManaged, + "mb": RuntimeManaged} + for _, r := range d.Resources { + f := r.(*File) + if f.OwnerAtRuntime != want[f.ID] || f.Ownership() != want[f.ID] { + t.Errorf("%s: read as %q, ownership %q; want %q", f.ID, f.OwnerAtRuntime, f.Ownership(), want[f.ID]) + } + } +} + +// A file that does not say is owned by its old form, so the word means exactly what that form does. +func TestAFileThatDoesNotSayIsOwnedByItsOldForm(t *testing.T) { + d, err := Parse([]byte(`{"declaration":1,"resources":[ + {"id":"o","type":"file","path":"/etc/o","content":"a"}, + {"id":"s","type":"file","path":"/etc/s","content":"a","create-once":true}, + {"id":"mj","type":"file","path":"/etc/mj","into":"json","content":"{}"}, + {"id":"mb","type":"file","path":"/etc/mb","into":"block","content":"a\n"} + ]}`)) + if err != nil { + t.Fatal(err) + } + want := map[string]string{"o": RuntimeOwned, "s": RuntimeSeed, "mj": RuntimeManaged, "mb": RuntimeManaged} + for _, r := range d.Resources { + f := r.(*File) + if f.OwnerAtRuntime != "" || f.Ownership() != want[f.ID] { + t.Errorf("%s: said %q, ownership %q; want unsaid and %q", f.ID, f.OwnerAtRuntime, f.Ownership(), want[f.ID]) + } + if f.Seeded() != (want[f.ID] == RuntimeSeed) { + t.Errorf("%s: seeded %v", f.ID, f.Seeded()) + } + } +} + +func TestAnOwnershipThatContradictsTheFileIsRefused(t *testing.T) { + for name, c := range map[string]struct{ resource, refusal string }{ + "another word": {`{"id":"f","type":"file","path":"/etc/x","content":"a","owner-at-runtime":"mine"}`, + `owner-at-runtime "mine"`}, + "owned and create-once": {`{"id":"f","type":"file","path":"/etc/x","content":"a","owner-at-runtime":"owned","create-once":true}`, + "create-once"}, + "owned and into": {`{"id":"f","type":"file","path":"/etc/x","into":"json","content":"{}","owner-at-runtime":"owned"}`, + "into"}, + "seed and into": {`{"id":"f","type":"file","path":"/etc/x","into":"block","content":"a","owner-at-runtime":"seed"}`, + "into"}, + "managed and whole": {`{"id":"f","type":"file","path":"/etc/x","content":"a","owner-at-runtime":"managed"}`, + "into"}, + "managed and create-once": {`{"id":"f","type":"file","path":"/etc/x","into":"json","content":"{}","owner-at-runtime":"managed","create-once":true}`, + "create-once"}, + // The rule that was there before stays: a file written into is never sealed or created once. + "managed and sealed": {`{"id":"f","type":"file","path":"/etc/x","into":"json","content":"{}","sealed":"x","owner-at-runtime":"managed"}`, + "sealed"}, + } { + _, err := Parse([]byte(`{"declaration":1,"resources":[` + c.resource + `]}`)) + if err == nil || !strings.Contains(err.Error(), c.refusal) { + t.Errorf("%s: want a refusal naming %q, got %v", name, c.refusal, err) + } + } +} + +func TestTheDeclarationNamesTheFilesTheProvisionerFills(t *testing.T) { + d, err := Parse([]byte(`{"declaration":1,"filled_files":["/etc/a","/etc/b"],"resources":[ + {"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}, + {"id":"b","type":"file","path":"/etc/b","into":"block","content":"b\n","owner-at-runtime":"managed"}, + {"id":"c","type":"file","path":"/etc/c","content":"c"} + ]}`)) + if err != nil { + t.Fatal(err) + } + if len(d.Filled) != 2 || d.Filled[0] != "/etc/a" || d.Filled[1] != "/etc/b" { + t.Fatalf("filled files read as %v", d.Filled) + } + if !d.Fills("/etc/a") || !d.Fills("/etc/b") || d.Fills("/etc/c") { + t.Errorf("Fills: a %v, b %v, c %v", d.Fills("/etc/a"), d.Fills("/etc/b"), d.Fills("/etc/c")) + } + + // A declaration that names none fills none, as every declaration before this did. + d, err = Parse([]byte(`{"declaration":1,"resources":[{"id":"c","type":"file","path":"/etc/c","content":"c"}]}`)) + if err != nil { + t.Fatal(err) + } + if d.Filled != nil || d.Fills("/etc/c") { + t.Errorf("a declaration naming no filled files fills %v", d.Filled) + } +} + +func TestAFilledFileMustBeADeclaredFileThatSaysItsOwner(t *testing.T) { + for name, c := range map[string]struct{ filled, resources, refusal string }{ + "not declared": {`["/etc/none"]`, + `{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}`, + `"/etc/none"`}, + "a directory, not a file": {`["/etc/d"]`, + `{"id":"d","type":"directory","path":"/etc/d"}`, + `"/etc/d"`}, + "says no owner": {`["/etc/a"]`, + `{"id":"a","type":"file","path":"/etc/a","content":"a"}`, + "owner-at-runtime"}, + "named twice": {`["/etc/a","/etc/a"]`, + `{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}`, + "twice"}, + "no path": {`[""]`, + `{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}`, + "no path"}, + } { + _, err := Parse([]byte(`{"declaration":1,"filled_files":` + c.filled + `,"resources":[` + c.resources + `]}`)) + if err == nil || !strings.Contains(err.Error(), c.refusal) { + t.Errorf("%s: want a refusal naming %q, got %v", name, c.refusal, err) + } + } +} diff --git a/internal/link/messages.go b/internal/link/messages.go index 0dc192e..5bc1828 100644 --- a/internal/link/messages.go +++ b/internal/link/messages.go @@ -295,6 +295,13 @@ const OwnGroupContract = 7 // for it. const BusyContract = 8 +// OwnershipContract is the statement of a host that also reads a file's `owner-at-runtime` and a declaration's +// `filled_files` (novox/hq ADR 0349 §5): it writes an owned file back on drift, a seed once and a managed file's +// keys or block only, exactly as their old forms, and refuses a filled path that is not a declared file saying +// its owner. Like the fields before it, its presence is what tells the controller this host may be sent those +// fields; an older host refuses the whole declaration for them. +const OwnershipContract = 9 + // Health is one statement of every long-running resource's state on this machine (to-be 48 §4). Said in // every report, as an event on each change, and again every minute while anything is not healthy — so a // lost statement is not a lost fault. diff --git a/validate/validate_test.go b/validate/validate_test.go index dba68e4..433850c 100644 --- a/validate/validate_test.go +++ b/validate/validate_test.go @@ -18,7 +18,14 @@ func TestTheValidatorIsTheHosts(t *testing.T) { if problems := Declaration([]byte(ordered)); problems != nil { t.Fatalf("a declaration carrying its order was refused: %v", problems) } + // And one carrying a file's owner-at-runtime and the files the provisioner fills (novox/hq ADR 0349 §5): the + // reader takes them before any controller sends them. + owned := `{"declaration": 1, "filled_files": ["/etc/x"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}` + if problems := Declaration([]byte(owned)); problems != nil { + t.Fatalf("a declaration saying who owns a file at runtime was refused: %v", problems) + } for _, bad := range []string{ + `{"declaration": 1, "filled_files": ["/etc/none"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}`, `{"declaration": 1, "resources": []}`, `{"declaration": 99, "resources": [{"id": "d", "type": "directory", "path": "/x"}]}`, `{"declaration": 1, "resources": [{"id": "d", "type": "nothing-the-host-knows"}]}`,