From fcc447c21670fc971357d1c6a73df93f7417626d Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 22 Sep 2026 17:11:26 +0200 Subject: [PATCH] Read a node's adoption from every declaration, so the host knows which modules are untaken (hq ADR 0100) --- internal/declaration/adoption_test.go | 102 ++++++++++++++++++++++++++ internal/declaration/declaration.go | 101 ++++++++++++++++++++++++- 2 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 internal/declaration/adoption_test.go diff --git a/internal/declaration/adoption_test.go b/internal/declaration/adoption_test.go new file mode 100644 index 0000000..8309329 --- /dev/null +++ b/internal/declaration/adoption_test.go @@ -0,0 +1,102 @@ +package declaration + +import ( + "strings" + "testing" +) + +// Defends novox/hq ADR 0100: every declaration says whether the node is adopted and which of its +// modules are taken, and the host refuses one it cannot read that from unambiguously. + +const adoptedResources = `"resources":[ + {"id":"hello-web.page","type":"file","path":"/var/lib/hello-web/index.html","content":"a\n"}, + {"id":"hello-web.server","type":"container","name":"hello-web","image":"sha256:` + sixtyFour + `"}, + {"id":"hello-web.data","type":"directory","path":"/var/lib/hello-web"} + ]` + +const sixtyFour = "0000000000000000000000000000000000000000000000000000000000000000" + +func TestAnAdoptionIsReadWithTheDeclaration(t *testing.T) { + d, err := Parse([]byte(`{"adoption":{"taken":["postgres"],"untaken":{"hello-web":["hello-web.page","hello-web.server"]}}, + "declaration":1,` + adoptedResources + `}`)) + if err != nil { + t.Fatal(err) + } + if d.Adoption == nil { + t.Fatal("the adoption was dropped") + } + if len(d.Adoption.Taken) != 1 || d.Adoption.Taken[0] != "postgres" { + t.Errorf("taken read as %v", d.Adoption.Taken) + } + if module, ok := d.Adoption.UntakenModuleOf("hello-web.server"); !ok || module != "hello-web" { + t.Errorf("the container's untaken module read as %q, %v", module, ok) + } + if _, ok := d.Adoption.UntakenModuleOf("hello-web.data"); ok { + t.Error("a resource the adoption does not name was said to be untaken") + } +} + +func TestADeclarationWithNoAdoptionIsConverged(t *testing.T) { + d, err := Parse([]byte(`{"declaration":1,` + adoptedResources + `}`)) + if err != nil { + t.Fatal(err) + } + if d.Adoption != nil { + t.Errorf("a declaration saying nothing about adoption read as adopted: %+v", d.Adoption) + } + if _, ok := d.Adoption.UntakenModuleOf("hello-web.page"); ok { + t.Error("a converged node has an untaken module") + } +} + +func TestAnAdoptionNamingAnUnknownIDIsRefused(t *testing.T) { + refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"hello-web":["hello-web.missing"]}}, + "declaration":1,`+adoptedResources+`}`) + if !strings.Contains(strings.Join(refusal.Problems, "\n"), "hello-web.missing") { + t.Errorf("the unknown id was not named: %v", refusal.Problems) + } +} + +func TestAnAdoptionMayOnlyHoldFilesAndContainers(t *testing.T) { + refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"hello-web":["hello-web.data"]}}, + "declaration":1,`+adoptedResources+`}`) + if !strings.Contains(strings.Join(refusal.Problems, "\n"), "only a file or a container") { + t.Errorf("a directory was accepted as holdable: %v", refusal.Problems) + } +} + +func TestAnIDUnderTwoModulesIsRefused(t *testing.T) { + refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"a":["hello-web.page"],"b":["hello-web.page"]}}, + "declaration":1,`+adoptedResources+`}`) + if !strings.Contains(strings.Join(refusal.Problems, "\n"), "both") { + t.Errorf("an id under two modules was accepted: %v", refusal.Problems) + } +} + +func TestAModuleBothTakenAndUntakenIsRefused(t *testing.T) { + refusal := refusalFor(t, `{"adoption":{"taken":["hello-web"],"untaken":{"hello-web":["hello-web.page"]}}, + "declaration":1,`+adoptedResources+`}`) + if !strings.Contains(strings.Join(refusal.Problems, "\n"), "both taken and untaken") { + t.Errorf("a module both taken and untaken was accepted: %v", refusal.Problems) + } +} + +func TestTheMeshsOwnResourcesAreNeverUntaken(t *testing.T) { + refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"x":["adoption.guard"]}}, + "declaration":1,"resources":[ + {"id":"adoption.guard","type":"file","path":"/etc/mesh/guard.nft","content":"x"}]}`) + if !strings.Contains(strings.Join(refusal.Problems, "\n"), "belongs to no module") { + t.Errorf("an adoption. id was accepted as untaken: %v", refusal.Problems) + } +} + +func TestAnAdoptionWithAnUnknownFieldIsRefused(t *testing.T) { + refusalFor(t, `{"adoption":{"taken":[],"held":["x"]},"declaration":1,`+adoptedResources+`}`) +} + +func TestACarriedBundleCannotSayTheNodeIsAdopted(t *testing.T) { + _, err := ParseTrusted([]byte(`{"adoption":{"taken":[]},"declaration":1,` + adoptedResources + `}`)) + if err == nil || !strings.Contains(err.Error(), "only the mesh can say") { + t.Fatalf("a bundle claiming adoption was not refused: %v", err) + } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index cd7cbd3..41605f6 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -832,6 +832,103 @@ type Declaration struct { // Resources, in the order they are applied. The host does not sort them: ordering is a // decision, and deciding is not what the host does (novox/hq ADR 0005). Resources []Resource + + // Adoption says this node is adopted, and which of its modules have been taken. Nil is a + // converged node — which is every node the mesh raised before adoption existed, and so the + // only form an older controller ever sends (novox/hq ADR 0100). + Adoption *Adoption +} + +// Adoption is a node's mode, as the controller records it: the node is adopted, and these are +// the modules taken on it so far (novox/hq ADR 0100). +// +// **Authoritative, and only ever stated by the controller.** A host does not work out whether it +// is adopted; it is told, in every declaration, so a host restarted from the declaration it kept +// is in the same mode it was in before. +// +// Untaken names, per module assigned here and not yet taken, the ids of its file and container +// resources — the only shapes a predecessor can already have on the machine. The host cannot +// split a resource id into its module, because module names may contain dots, so the controller +// says which ids belong to which module rather than leaving the host to guess. +type Adoption struct { + Taken []string `json:"taken"` + Untaken map[string][]string `json:"untaken,omitempty"` +} + +// AdoptionPrefix is the id prefix of what the mesh itself declares because a node is adopted — +// its openings and its guard. Nothing under it belongs to a module, so none of it is ever held. +const AdoptionPrefix = "adoption." + +// UntakenModuleOf says which untaken module declares a resource, if any. +func (a *Adoption) UntakenModuleOf(id string) (string, bool) { + if a == nil { + return "", false + } + for module, ids := range a.Untaken { + if slices.Contains(ids, id) { + return module, true + } + } + return "", false +} + +// checkAdoption holds what an adoption says against the resources beside it. Every problem is a +// refusal: a host that misread which module is untaken would replace a predecessor's service the +// operator never took. +func checkAdoption(a *Adoption, resources []Resource, allowActions bool) []string { + if a == nil { + return nil + } + if allowActions { + // The bundle is carried with the binary and raises a foundation before any mesh exists. + // Whether a node is adopted is the controller's record, and a bundle that claimed it would + // be the host deciding its own mode (novox/hq ADR 0100). + return []string{"a carried bundle says the node is adopted, and only the mesh can say " + + "that: a node's mode is the controller's record, sent in every declaration"} + } + kinds := map[string]Type{} + for _, r := range resources { + kinds[r.Identity()] = r.Kind() + } + var problems []string + for _, module := range a.Taken { + if _, both := a.Untaken[module]; both { + problems = append(problems, fmt.Sprintf( + "adoption: the module %q is said to be both taken and untaken", module)) + } + } + owner := map[string]string{} + modules := make([]string, 0, len(a.Untaken)) + for module := range a.Untaken { + modules = append(modules, module) + } + sort.Strings(modules) + for _, module := range modules { + for _, id := range a.Untaken[module] { + if strings.HasPrefix(id, AdoptionPrefix) { + problems = append(problems, fmt.Sprintf( + "adoption: %q is the mesh's own and belongs to no module, so it cannot be untaken", id)) + continue + } + if first, twice := owner[id]; twice { + problems = append(problems, fmt.Sprintf( + "adoption: %q is said to belong to both %q and %q", id, first, module)) + continue + } + owner[id] = module + kind, declared := kinds[id] + switch { + case !declared: + problems = append(problems, fmt.Sprintf( + "adoption: %q of the untaken module %q is not in this declaration", id, module)) + case kind != TypeFile && kind != TypeContainer: + problems = append(problems, fmt.Sprintf( + "adoption: %q of the untaken module %q is a %s, and only a file or a "+ + "container can be found on a machine", id, module, kind)) + } + } + } + return problems } // RefusalError refuses a whole declaration, naming every problem at once. @@ -872,6 +969,7 @@ func ParseTrusted(raw []byte) (*Declaration, error) { return parse(raw, true) } type envelope struct { Version int `json:"declaration"` For string `json:"for,omitempty"` + Adoption *Adoption `json:"adoption,omitempty"` Resources []json.RawMessage `json:"resources"` } @@ -889,7 +987,7 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) { env.Version, Version)}} } - d := &Declaration{Version: env.Version, For: env.For} + d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption} var problems []string if len(env.Resources) == 0 { @@ -952,6 +1050,7 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) { problems = append(problems, resource.validate(where, allowActions)...) d.Resources = append(d.Resources, resource) } + problems = append(problems, checkAdoption(env.Adoption, d.Resources, allowActions)...) if len(problems) > 0 { return nil, &RefusalError{Problems: problems}