The engine's Composed keeps the catalogue's Plan and the declaration types and goes through nodeengineclient's codec; the fingerprint key part is meshcatalogclient's. The candidate answer is nodeengine.CandidateAnswer; the have still holds it through an interim JSON bridge (link.interimAnswers) until the Have group moves. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
190 lines
8.3 KiB
Go
190 lines
8.3 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
engcomp "github.com/novox/mesh-host/internal/compositions"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/declaration"
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/novox/mesh-host/internal/link"
|
|
)
|
|
|
|
// aFilesLink is a link that serves objects by name, or fails every read with err.
|
|
type aFilesLink struct {
|
|
objects map[string][]byte
|
|
err error
|
|
}
|
|
|
|
func (a aFilesLink) FileObject(_ context.Context, name string, _ int64) ([]byte, error) {
|
|
if a.err != nil {
|
|
return nil, a.err
|
|
}
|
|
if b, ok := a.objects[name]; ok {
|
|
return b, nil
|
|
}
|
|
return nil, os.ErrNotExist
|
|
}
|
|
|
|
func referenceDeclaration(path string, body []byte) []byte {
|
|
return []byte(`{"declaration":1,"resources":[{"id":"m.big","type":"file","path":"` + path + `","digest":"` +
|
|
declaration.DigestOf(body) + `","size":` + strconv.Itoa(len(body)) + `}]}`)
|
|
}
|
|
|
|
// 1695/AC1 and AC2, through the whole apply: a reference is fetched, verified and written; a differing object
|
|
// refuses the declaration whole and writes nothing.
|
|
func TestADeclarationWithAFileByDigestAppliesFromTheObject(t *testing.T) {
|
|
unansweringPacman(t)
|
|
body := []byte(strings.Repeat("a line of a big file\n", 4000))
|
|
name, _ := declaration.ObjectName(declaration.DigestOf(body))
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "big")
|
|
opts := options{state: filepath.Join(dir, "state.json"), out: &strings.Builder{}}
|
|
raw := referenceDeclaration(target, body)
|
|
|
|
bad := append([]byte(nil), body...)
|
|
bad[10] ^= 1
|
|
ctx := link.WithFiles(context.Background(), aFilesLink{objects: map[string][]byte{name: bad}})
|
|
report := applyAndKeep(ctx, opts, raw, nil, nil, nil)
|
|
if report.Refused == "" || !strings.Contains(report.Refused, "m.big") {
|
|
t.Errorf("1695/AC2: a differing object did not refuse the declaration: %+v", report)
|
|
}
|
|
if _, err := os.Stat(target); err == nil {
|
|
t.Error("1695/AC2: a file was written from an object that differs")
|
|
}
|
|
if failedWith(report.Refused).Transient {
|
|
t.Errorf("an object that differs is a real failure, and was taken as the bus not answering: %s", report.Refused)
|
|
}
|
|
|
|
// The bus answers no JetStream request while it reloads its accounts (2026-10-25 23:04:24, mesh-vault on the bus
|
|
// node): the failure is transient, so the agent tries it again, and the detail kept is its cause, the tail.
|
|
noResponders := fmt.Errorf("fetching the object 2bd98ebd of this node's own object bucket: reading the chunks of "+
|
|
"object %s: %w", strings.Repeat("x", 300), nats.ErrNoResponders)
|
|
ctx = link.WithFiles(context.Background(), aFilesLink{err: noResponders})
|
|
report = applyAndKeep(ctx, opts, raw, nil, nil, nil)
|
|
failed := failedWith("a file of this composition could not be had by its digest: file mesh-vault.kept: " + report.Refused)
|
|
if !failed.Transient || !strings.HasSuffix(failed.Detail, nats.ErrNoResponders.Error()) || len(failed.Detail) > 310 ||
|
|
!strings.HasPrefix(failed.Detail, "a file of this composition") {
|
|
t.Errorf("a fetch that met no responders: transient %v, detail %q (%d bytes); want transient, its head and its cause",
|
|
failed.Transient, failed.Detail, len(failed.Detail))
|
|
}
|
|
|
|
ctx = link.WithFiles(context.Background(), aFilesLink{objects: map[string][]byte{name: body}})
|
|
report = applyAndKeep(ctx, opts, raw, nil, nil, nil)
|
|
if report.Refused != "" {
|
|
t.Fatalf("1695/AC1: refused: %s", report.Refused)
|
|
}
|
|
got, err := os.ReadFile(target)
|
|
if err != nil || string(got) != string(body) {
|
|
t.Errorf("1695/AC1: the file holds %d bytes (%v), want %d", len(got), err, len(body))
|
|
}
|
|
|
|
// Issue 924 (novox, 2026-10-29 01:16): the controller's sweep removed an object a composition the node ran still named,
|
|
// and the module's apply failed while it ran. Content the node once had is its own keep's, and the bus is not asked.
|
|
gone := aFilesLink{err: fmt.Errorf("the object %s was deleted from the bucket declaration-files-novox", name)}
|
|
never := []byte(strings.Repeat("never had here\n", 6000))
|
|
for _, row := range []struct {
|
|
name string
|
|
raw []byte
|
|
onDisk []byte // nil removes the file
|
|
refused string // "" applies
|
|
}{
|
|
{"the file here drifted, its object deleted", raw, []byte("the module wrote it since"), ""},
|
|
{"the file here is gone, its object deleted", raw, nil, ""},
|
|
{"never had here, its object deleted", referenceDeclaration(target, never), nil, "never held it"},
|
|
} {
|
|
if row.onDisk == nil {
|
|
_ = os.Remove(target)
|
|
} else if err := os.WriteFile(target, row.onDisk, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
report = applyAndKeep(link.WithFiles(context.Background(), gone), opts, row.raw, nil, nil, nil)
|
|
if row.refused == "" {
|
|
got, _ := os.ReadFile(target)
|
|
if report.Refused != "" || string(got) != string(body) {
|
|
t.Errorf("924: %s: refused %q, the file holds %d bytes; want it applied from the node's own keep",
|
|
row.name, report.Refused, len(got))
|
|
}
|
|
} else if !strings.Contains(report.Refused, row.refused) || !strings.Contains(report.Refused, "was deleted") {
|
|
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([]engcomp.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 <file>` and `reconcile` have no bus: a reference whose file here verifies is applied
|
|
// as it is, any other refuses and writes nothing — never an empty file.
|
|
func TestTheHandApplyNeverWritesAReferenceEmpty(t *testing.T) {
|
|
unansweringPacman(t)
|
|
body := []byte(strings.Repeat("a line of a big file\n", 4000))
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "big")
|
|
opts := options{state: filepath.Join(dir, "state.json"), out: &strings.Builder{}}
|
|
raw := referenceDeclaration(target, body)
|
|
d, err := declaration.ParseFileTrusted(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := runApply(context.Background(), opts, d, raw, fromFile); err == nil || !strings.Contains(err.Error(), "digest") {
|
|
t.Errorf("1695/AC8: a reference with no file and no bus was not refused: %v", err)
|
|
}
|
|
if _, err := os.Stat(target); err == nil {
|
|
t.Error("1695/AC8: a file was written for a reference that could not be had")
|
|
}
|
|
if err := os.WriteFile(target, body, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
d, _ = declaration.ParseFileTrusted(raw)
|
|
if err := runApply(context.Background(), opts, d, raw, fromFile); err != nil && !strings.Contains(err.Error(), "applied only in part") {
|
|
t.Fatalf("1695/AC8: %v", err)
|
|
}
|
|
if got, _ := os.ReadFile(target); string(got) != string(body) {
|
|
t.Errorf("1695/AC8: the file holds %d bytes, want %d", len(got), len(body))
|
|
}
|
|
}
|