Read a node's adoption from every declaration, so the host knows which modules are untaken (hq ADR 0100)

This commit is contained in:
2026-09-22 17:11:26 +02:00
parent 4fc0330937
commit fcc447c216
2 changed files with 202 additions and 1 deletions
+100 -1
View File
@@ -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}