From 4e6b756bd225da1e846ea766e1b7619dab82c9ad Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 25 Oct 2026 01:49:00 +0200 Subject: [PATCH] Refuse an apply that would write a provisioner's file empty, read the node's own filled paths, and name the node's own composition when its apply fails (issue 1944, issue 1945) --- cmd/mesh-host/empty_write_test.go | 33 +++++++++++++++++++++++++++++++ cmd/mesh-host/nodeagent.go | 29 +++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 ++-- internal/agent/agent.go | 6 +++++- 5 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 cmd/mesh-host/empty_write_test.go diff --git a/cmd/mesh-host/empty_write_test.go b/cmd/mesh-host/empty_write_test.go new file mode 100644 index 0000000..80a7f76 --- /dev/null +++ b/cmd/mesh-host/empty_write_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// A composition that names a file the provisioner fills without saying it is filled would have this node-engine write +// that file empty: its apply is refused, loudly, before anything is written (issue 1945, live on 2026-10-25 at 01:22, +// when ace, g14 and shanks wrote claude-code's, node-env's and power's files empty). +func TestAFileItCannotProduceIsNeverWrittenEmpty(t *testing.T) { + unit := json.RawMessage(`{"id":"power.after-boot-unit","type":"file","path":"/etc/systemd/system/mesh-power-after-boot.service","mode":"0644","owner-at-runtime":"owned"}`) + c := delivery.Composed{Node: "ace", Resources: []json.RawMessage{unit}} + d, err := declarationOf(c) + if err != nil { + t.Fatal(err) + } + if err := refuseEmptyWrites(d); err == nil || !strings.Contains(err.Error(), "power.after-boot-unit") { + t.Fatalf("a file the provisioner fills, not filled here: %v", err) + } + c.Filled = []string{"/etc/systemd/system/mesh-power-after-boot.service"} + if d, _ = declarationOf(c); refuseEmptyWrites(d) != nil { + t.Fatalf("a filled file was refused: %v", refuseEmptyWrites(d)) + } + plain := delivery.Composed{Node: "ace", Resources: []json.RawMessage{json.RawMessage( + `{"id":"x.f","type":"file","path":"/etc/x","mode":"0644","content":"x"}`)}} + if d, _ = declarationOf(plain); refuseEmptyWrites(d) != nil { + t.Fatalf("a file with its content was refused: %v", refuseEmptyWrites(d)) + } +} diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 71cc76f..906ddba 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -353,6 +353,9 @@ func applyComposition(ctx context.Context, opts options, c delivery.Composed, ot if err != nil { return agent.Result{Failed: true, Detail: shorten(err.Error())} } + if err := refuseEmptyWrites(d); err != nil { + return agent.Result{Failed: true, Detail: shorten(err.Error())} + } scope := apply.Scope{Module: name, Others: resourcesOf(others)} got := applyModuleHeld(ctx, opts, d, &scope, sched, say) if got.Refused != "" { @@ -478,3 +481,29 @@ func runsFromAgent(current *agentNow) func(string, ed25519.PublicKey) (*declarat return whatRuns(ag.Everything(), ag.HoldsNode(), kept) } } + +// refuseEmptyWrites refuses a declaration that names a file the provisioner fills (owned at runtime, no content, no +// bytes, no digest, no sealed value) without saying it is filled: applied, the file would be written empty over what the +// provisioner wrote (issue 1945, live on 2026-10-25 at 01:22 on ace, g14 and shanks). The apply fails, naming the file, +// and nothing is written. +func refuseEmptyWrites(d *declaration.Declaration) error { + filled := make(map[string]bool, len(d.Filled)) + for _, p := range d.Filled { + filled[p] = true + } + var refused []string + for _, r := range d.Resources { + f, ok := r.(*declaration.File) + if !ok || f.OwnerAtRuntime != declaration.RuntimeOwned || filled[f.Path] { + continue + } + if f.Content == "" && f.Bytes == "" && f.Digest == "" && f.Sealed == "" && f.VaultSealed == nil { + refused = append(refused, fmt.Sprintf("%s (%s)", f.ID, f.Path)) + } + } + if len(refused) > 0 { + return fmt.Errorf("the composition names %s with no content and does not say the provisioner fills it, so it "+ + "would be written empty; nothing was applied", strings.Join(refused, ", ")) + } + return nil +} diff --git a/go.mod b/go.mod index 65c0bb2..f4302b0 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024210118-748c564cdf7c + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024234636-10b6e202320f github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/go.sum b/go.sum index d36d16c..c03eccc 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024210118-748c564cdf7c h1:pY2YpHSSsfDPBrOEiWTNSIPn0zdnAayRmtmmyQ3hCHY= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024210118-748c564cdf7c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024234636-10b6e202320f h1:lUEyNPPF3w4Q0LIgcUUD6ofkKVhCF0n7dVjMoixQmXo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024234636-10b6e202320f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 8c472d9..465ee94 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -533,7 +533,11 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo if res.Failed { a.attempt[module] = attempt{FP: fp, Composed: dig, WantRev: w.Rev, CompRev: comp.Rev} a.mu.Unlock() - a.env.Say(fmt.Sprintf("applying %s at %s failed: %s", module, fp, res.Detail)) + what := fmt.Sprintf("%s at %s", module, fp) + if module == NodeModule { + what = "the node's own composition" + } + a.env.Say(fmt.Sprintf("applying %s failed: %s", what, res.Detail)) a.env.Haves.Write(ctx, Have{Module: module, FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed), Detail: res.Detail}) return }