diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 4577e493..9aa00adc 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -1601,6 +1601,24 @@ func (a *Agent) apply(ctx context.Context, module string, w wantNow, comp compNo ranBefore = ranBefore || a.prior[module] a.mu.Unlock() + // A want with parts is applied only when they and the digests its composition fetches agree (design 47 G §524): the + // module keeps running what it has, and the have says which digest differs. + if words := partsDifference(w.Want.Parts, comp.C.Resources); words != "" { + a.mu.Lock() + prev, runs := a.running[module] + a.attempt[module] = attempt{FP: fp, Composed: dig, WantRev: w.Rev, CompRev: comp.Rev, Held: true, AnsweredRev: w.Rev} + a.mu.Unlock() + if !runs { + if h, found, _ := a.readHave(ctx, module); found { + prev = nodeengine.HaveByNodeModuleValue{Fp: h.FP, Composed: h.Composed} + } + } + a.env.Say(fmt.Sprintf("%s is held: %s", module, words)) + a.writeHave(ctx, Have{Module: module, FP: prev.Fp, Composed: prev.Composed, Phase: string(nodeengineclient.PhaseHeld), + Detail: words}) + return + } + // A data module is backed up before a new composition of it moves (ADR 0429 §14), unless the want says to go on // without one (ADR 0539 §16). if comp.C.Data && ranBefore && a.env.Backup != nil && w.Want.Backup != meshwants.WantsByWantNodeModuleValueBackupSkip { diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 082a3fe4..a609f558 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -322,6 +322,50 @@ func TestAWantAndItsCompositionApplyTheModuleAndTheHaveNamesBoth(t *testing.T) { if got := b.appliedList(); len(got) != 1 { t.Fatalf("a want naming another digest applied the composition: %v", got) } + + // Design 47 G section 524: a want with parts is applied only when its parts and the digests the composition fetches + // agree; a want with no parts is applied as before. + d1, d2, d3 := "sha256:"+strings.Repeat("1", 64), "sha256:"+strings.Repeat("2", 64), "sha256:"+strings.Repeat("3", 64) + archive := func(id, digest string) json.RawMessage { + raw, _ := json.Marshal(map[string]any{"id": "web." + id, "type": "archive", "path": "/tmp/r6/web/" + id, + "source": "https://store/" + id, "digest": digest, "module": "web"}) + return raw + } + for i, row := range []struct { + name string + parts map[string]string + applies bool + detail string + }{ + {"agree", map[string]string{"web:a": d1, "web:b": d2}, true, ""}, + {"no parts", nil, true, ""}, + {"fetched outside", map[string]string{"web:a": d1}, false, PartsDiffer + " fetched and not in the want's parts: " + d2}, + {"part not fetched", map[string]string{"web:a": d1, "web:b": d2, "web:c": d3}, false, + PartsDiffer + " in the want's parts and not fetched: " + d3}, + } { + before := len(b.appliedList()) + fpn := fpOf(string("5678"[i])) + ckn, crawn, dign := composition(t, "web", fpn, false, archive("a", d1), archive("b", d2)) + b.comps.put(ckn, crawn) + wkn := meshwants.WantsByWantNodeModuleKey(node, "web") + wrawn, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fpn, Composed: dign, Parts: row.parts}) + if err != nil { + t.Fatal(err) + } + b.wants.put(wkn, wrawn) + b.idle() + if got := len(b.appliedList()) - before; (got == 1) != row.applies { + t.Fatalf("parts %q: applied %d times, want applies=%v", row.name, got, row.applies) + } + if h := b.haves.last("web"); !row.applies && (h.Phase != string(nodeengineclient.PhaseHeld) || h.Detail != row.detail) { + t.Fatalf("parts %q: the have is %s %q, want held %q", row.name, h.Phase, h.Detail, row.detail) + } + } + // A container's digest is the one after its image's @. + container := json.RawMessage(`{"id":"web.c","type":"container","name":"web-c","image":"store/web@` + d3 + `","module":"web"}`) + if got := partsDifference(map[string]string{"web:c": d3}, []json.RawMessage{container}); got != "" { + t.Fatalf("a container whose digest is in the parts differs: %q", got) + } } // R6-A AC2: given a kept declaration and wants and compositions equal to it, when the agent starts, nothing is applied diff --git a/internal/agent/parts.go b/internal/agent/parts.go new file mode 100644 index 00000000..bb5a1f83 --- /dev/null +++ b/internal/agent/parts.go @@ -0,0 +1,76 @@ +package agent + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/declaration" +) + +// PartsDiffer is the start of the detail of a have held because a want's parts and the digests its composition fetches +// disagree (design 47 G section 524). +const PartsDiffer = "parts-differ:" + +// partsDifference checks a want's `parts` against the digests of the archive, process and container resources of the +// composition, which are the digests the apply fetches and checks (design 47 G section 524). A want with no `parts` (an +// assigner of before G, or a node whose record does not state `want-parts`) is applied as today: it returns "". With +// `parts`, the want is applied only when the digest of every resource fetched is among the values of `parts` and every +// value of `parts` is the digest of a resource fetched; otherwise it returns the detail naming each digest. +func partsDifference(parts map[string]string, resources []json.RawMessage) string { + if len(parts) == 0 { + return "" + } + fetched := map[string]bool{} + for _, raw := range resources { + r, err := declaration.ParseResource(raw) + if err != nil { + continue + } + switch res := r.(type) { + case *declaration.Archive: + if res.Source != "" { + fetched[res.Digest] = true + } + case *declaration.Process: + if res.Source != "" { + fetched[res.Digest] = true + } + case *declaration.Container: + if strings.HasPrefix(res.Image, "sha256:") { + fetched[res.Image] = true + } else if _, digest, ok := strings.Cut(res.Image, "@"); ok { + fetched[digest] = true + } + } + } + named := map[string]bool{} + for _, d := range parts { + named[d] = true + } + var outside, missing []string + for d := range fetched { + if !named[d] { + outside = append(outside, d) + } + } + for d := range named { + if !fetched[d] { + missing = append(missing, d) + } + } + if len(outside) == 0 && len(missing) == 0 { + return "" + } + sort.Strings(outside) + sort.Strings(missing) + var said []string + if len(outside) > 0 { + said = append(said, "fetched and not in the want's parts: "+strings.Join(outside, ", ")) + } + if len(missing) > 0 { + said = append(said, "in the want's parts and not fetched: "+strings.Join(missing, ", ")) + } + return fmt.Sprintf("%s %s", PartsDiffer, strings.Join(said, "; ")) +}