diff --git a/cmd/mesh-host/file_references_test.go b/cmd/mesh-host/file_references_test.go index 21444ea..0be46f6 100644 --- a/cmd/mesh-host/file_references_test.go +++ b/cmd/mesh-host/file_references_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" "os" "path/filepath" @@ -10,6 +11,7 @@ import ( "testing" "git.novox.be/novox/mesh-sdk/go/declaration" + "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/nats-io/nats.go" "github.com/novox/mesh-host/internal/link" ) @@ -113,6 +115,46 @@ func TestADeclarationWithAFileByDigestAppliesFromTheObject(t *testing.T) { t.Errorf("924: %s: refused %q; want a refusal saying the bucket and the keep both lack it", row.name, report.Refused) } } + + // Issue 924, the live case: what runs came from the kept declaration, whose export names a digest swept long ago, + // while the node wrote a newer export since. Composed as it runs, the file names what the node wrote, and applies with + // no object; a file someone else changed since is left as named. + newer := []byte(strings.Repeat("the export the node wrote since\n", 3000)) + newerName, _ := declaration.ObjectName(declaration.DigestOf(newer)) + ctx = link.WithFiles(context.Background(), aFilesLink{objects: map[string][]byte{newerName: newer}}) + if report = applyAndKeep(ctx, opts, referenceDeclaration(target, newer), nil, nil, nil); report.Refused != "" { + t.Fatalf("924: the newer export: %s", report.Refused) + } + stale := json.RawMessage(`{"id":"m.big","type":"file","path":"` + target + `","digest":"` + declaration.DigestOf(body) + + `","size":` + strconv.Itoa(len(body)) + `}`) + for _, row := range []struct { + name string + edit []byte // what someone else wrote there since; nil for nobody + digest string + }{ + {"the file is what the node wrote", nil, declaration.DigestOf(newer)}, + {"someone changed the file since", []byte("a hand edit"), declaration.DigestOf(body)}, + } { + if row.edit != nil { + if err := os.WriteFile(target, row.edit, 0o600); err != nil { + t.Fatal(err) + } + } + runs := runsAsWritten([]delivery.Composed{{Node: "n", Module: "m", Resources: []json.RawMessage{stale}}}, opts.state) + var got struct { + Digest string `json:"digest"` + } + _ = json.Unmarshal(runs[0].Resources[0], &got) + if got.Digest != row.digest { + t.Errorf("924: %s: what runs names %s, want %s", row.name, got.Digest, row.digest) + } + if row.edit == nil { + raw := []byte(`{"declaration":1,"resources":[` + string(runs[0].Resources[0]) + `]}`) + if report = applyAndKeep(link.WithFiles(context.Background(), gone), opts, raw, nil, nil, nil); report.Refused != "" { + t.Errorf("924: %s: composed as it runs, refused %q; want applied with no object", row.name, report.Refused) + } + } + } } // 1695/AC8: the hand-run `apply ` and `reconcile` have no bus: a reference whose file here verifies is applied diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index 1af795b..5a1f164 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -552,7 +552,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, sess hooks := composerHooks{ running: func() []delivery.Composed { if now := own.get(); now != nil { - return now.Everything() + return runsAsWritten(now.Everything(), opts.state) } return nil }, diff --git a/cmd/mesh-host/runs_as_written.go b/cmd/mesh-host/runs_as_written.go new file mode 100644 index 0000000..a2bca2a --- /dev/null +++ b/cmd/mesh-host/runs_as_written.go @@ -0,0 +1,88 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + + "git.novox.be/novox/mesh-sdk/go/declaration" + "git.novox.be/novox/mesh-sdk/go/delivery" + + "github.com/novox/mesh-host/internal/store" +) + +// runsAsWritten is what the node runs as the composer reads it (selfcompose.Env.Running), with every file given by digest +// named as this node last wrote it, where the two differ (issue 924, novox 2026-10-29 01:16). What runs of a module the +// agent has not applied since its start can come from the kept declaration, older than the compositions applied since: +// its export (mesh-vault.kept) then named a digest the controller's sweep had removed, while the file on disk was the +// newer export the node wrote. A node composing its own carries such a file as it runs (mesh-sdk frombus keptOf), so it +// names what is on disk: **only when the node's record says it wrote exactly that content there** (store Applied.Wrote, +// Target), never a file someone else changed. A file that cannot be read, or that the record does not vouch for, is +// left as the composition names it. +func runsAsWritten(running []delivery.Composed, statePath string) []delivery.Composed { + if len(running) == 0 || statePath == "" { + return running + } + known, err := store.Load(statePath) + if err != nil { + return running + } + out := make([]delivery.Composed, len(running)) + for i, c := range running { + out[i] = c + var changed []json.RawMessage + for j, raw := range c.Resources { + if fixed, ok := asWritten(raw, known); ok { + if changed == nil { + changed = append([]json.RawMessage(nil), c.Resources...) + } + changed[j] = fixed + } + } + if changed != nil { + out[i].Resources = changed + } + } + return out +} + +// asWritten is the file resource raw named by the digest and size of what this node last wrote at its path, when raw is a +// file given by another digest and the file there is what the record says the node wrote. +func asWritten(raw json.RawMessage, known store.State) (json.RawMessage, bool) { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil { + return nil, false + } + var kind, id, path, digest string + _ = json.Unmarshal(fields["type"], &kind) + _ = json.Unmarshal(fields["id"], &id) + _ = json.Unmarshal(fields["path"], &path) + _ = json.Unmarshal(fields["digest"], &digest) + if kind != string(declaration.TypeFile) || digest == "" || path == "" { + return nil, false + } + rec, ok := known.Find(id) + if !ok || rec.Wrote == "" || rec.Target != path { + return nil, false + } + body, err := os.ReadFile(path) + if err != nil { + return nil, false + } + sum := sha256.Sum256(body) + if hex.EncodeToString(sum[:]) != rec.Wrote { + return nil, false // changed since the node wrote it: not the node's to vouch for + } + if now := declaration.DigestOf(body); now == digest { + return nil, false + } else { + fields["digest"], _ = json.Marshal(now) + } + fields["size"], _ = json.Marshal(len(body)) + fixed, err := json.Marshal(fields) + if err != nil { + return nil, false + } + return fixed, true +}