From 2722e7b36e2c15f0ee9caa961c4f18f715f55f61 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 23:39:32 +0200 Subject: [PATCH] A host accepts an explicitly-empty declaration (hq 127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty-resources guard refused every empty body as a likely mistake, with no way to say emptiness was meant — so the control plane could never tell a node to drop its last resource. The envelope gains owns_nothing: with it, an empty declaration is applied (the node drops what the mesh owned); without it, empty is still refused, so a truncated or mis-composed body cannot silently strip a machine. One test, both directions. --- internal/declaration/declaration.go | 17 ++++++++++++----- internal/declaration/declaration_test.go | 13 +++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 21b3b0c..5fa7f27 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -1142,10 +1142,17 @@ func ParseTrusted(raw []byte) (*Declaration, error) { return parse(raw, true) } // first pass takes the envelope and each resource's bytes; the second decodes each one into // the struct for its kind, strictly. type envelope struct { - Version int `json:"declaration"` - For string `json:"for,omitempty"` - Adoption *Adoption `json:"adoption,omitempty"` - Resources []json.RawMessage `json:"resources"` + Version int `json:"declaration"` + For string `json:"for,omitempty"` + Adoption *Adoption `json:"adoption,omitempty"` + // OwnsNothing is the control plane saying, explicitly, that this node's declaration is empty + // on purpose — it owns nothing the mesh put there (novox/hq issue 127). Without it an empty + // resources list is refused as a likely mistake; with it the node applies the empty + // declaration and drops what it last held. The two are distinguished because a truncated or + // mis-composed body arrives as empty too, and a host that could not tell them apart would let + // a bug quietly strip a machine. + OwnsNothing bool `json:"owns_nothing,omitempty"` + Resources []json.RawMessage `json:"resources"` } func parse(raw []byte, allowActions bool) (*Declaration, error) { @@ -1165,7 +1172,7 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) { d := &Declaration{Version: env.Version, For: env.For, Adoption: env.Adoption} var problems []string - if len(env.Resources) == 0 { + if len(env.Resources) == 0 && !env.OwnsNothing { problems = append(problems, "no resources. An empty declaration is a mistake, not a "+ "machine with nothing on it — say so with an explicit empty list if that is meant") } diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go index bb4e7fa..95c8a16 100644 --- a/internal/declaration/declaration_test.go +++ b/internal/declaration/declaration_test.go @@ -451,3 +451,16 @@ func TestAContainersResolverAndAddressAreAddressesOrRefused(t *testing.T) { t.Errorf("a well-formed resolver and address were refused: %v", p) } } + +func TestAnExplicitlyEmptyDeclarationIsAccepted(t *testing.T) { + // A deliberately-empty declaration (novox/hq issue 127) says owns_nothing, and is applied so + // the node drops what it last held — distinct from an accidental empty body, which is refused. + if _, err := Parse([]byte(`{"declaration":1,"owns_nothing":true,"resources":[]}`)); err != nil { + t.Fatalf("an explicitly-empty declaration must be accepted: %v", err) + } + // Without the marker, an empty declaration is still refused as a likely mistake. + _, err := Parse([]byte(`{"declaration":1,"resources":[]}`)) + if err == nil || !strings.Contains(err.Error(), "no resources") { + t.Fatalf("an unmarked empty declaration must still be refused; got %v", err) + } +}