diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 2e570db..4128d54 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -199,6 +199,22 @@ func ApplyKeeping( } else { action, detail, err = remove(ctx, sys, orphan, run, made) } + if errors.Is(err, errNoRemoval) && store.IsFormer(orphan.ID) { + // **A former target of a kind the host cannot remove is left in place and forgotten, + // never fatal.** The host's own archive is the case: every version it delivers itself + // has a new target, so the one before is a former target on the first apply of the new + // host — and a removal that refused there stopped every machine applying anything, the + // moment the host that carried former targets (novox/hq ADR 0163, rule 5) first + // replaced itself. What was written stays where it is, said, and the record no longer + // names it; whether an archive gets a removal is issue 162's question, not this apply's. + known.Forget(orphan.ID) + report.Outcomes = append(report.Outcomes, Outcome{ + ID: orphan.ID, Type: orphan.Type, Target: orphan.Target, + Action: "forgotten", Detail: "a former target left in place: " + err.Error() + " (novox/hq issue 162)", + }) + log(fmt.Sprintf(" forgotten %s (%s): a former target left in place: %v", orphan.ID, orphan.Target, err)) + return nil + } if err != nil { return &Error{Resource: orphan.ID, Err: err, Done: report} } @@ -1340,10 +1356,15 @@ func remove(ctx context.Context, sys system.System, a store.Applied, run Runner, return "removed", "no longer declared", nil default: - return "", "", fmt.Errorf("no way to remove a %q", a.Type) + return "", "", fmt.Errorf("%w: a %q", errNoRemoval, a.Type) } } +// errNoRemoval is remove's answer for a kind the host has no removal for (novox/hq issue 162): an +// archive, among others. Fatal for an orphan the declaration dropped, so an unassignment nothing can +// undo is never reported as done; not fatal for a former target, which was never dropped by anyone. +var errNoRemoval = errors.New("no way to remove") + // ExecRunner runs a real command, with stdin closed and output captured. func ExecRunner(ctx context.Context, name string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, name, args...) diff --git a/internal/apply/former_archive_test.go b/internal/apply/former_archive_test.go new file mode 100644 index 0000000..9c7d401 --- /dev/null +++ b/internal/apply/former_archive_test.go @@ -0,0 +1,75 @@ +package apply + +import ( + "context" + "strings" + "testing" + + "github.com/novox/mesh-host/internal/store" +) + +// The host's own former archive stops nothing (novox/hq issue 194). A new host's first apply finds +// the version before it as a former target of the archive that delivered it; an archive has no +// removal (issue 162), and the refusal stopped every machine applying anything. A former target of +// such a kind is left in place, said, and forgotten. An archive the declaration dropped still fails, +// as 162 has it. +func TestTheHostsOwnFormerArchiveIsLeftInPlaceNotFatal(t *testing.T) { + run := func(_ context.Context, name string, args ...string) (string, error) { + if name == "docker" && args[0] == "info" { + return "29.0.0\n", nil + } + return "", nil + } + dir := t.TempDir() + former := store.FormerID("mesh-host.next", dir+"/versions/old") + known := store.State{Resources: []store.Applied{ + {ID: "mesh-host.next", Type: "archive", Target: dir + "/versions/new", Origin: store.OriginDeclared}, + {ID: former, Type: "archive", Target: dir + "/versions/old", Origin: store.OriginDeclared}, + }} + body, digest := anArchive(t, map[string]string{"nox-mesh-host": "#!/bin/sh\n"}) + d := parse(t, `{"declaration":1,"resources":[ + {"id":"mesh-host.next","type":"archive","path":"`+dir+`/versions/new","source":"`+serving(t, body)+`","digest":"`+digest+`"}, + {"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"x"} + ]}`) + report, state, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil) + if err != nil { + t.Fatalf("the apply failed: %v", err) + } + if _, still := state.HeldAt(former); still { + t.Fatal("held?") + } + for _, r := range state.Resources { + if r.ID == former { + t.Fatal("the former archive is still on record") + } + } + said := false + for _, o := range report.Outcomes { + if o.ID == former && o.Action == "forgotten" && strings.Contains(o.Detail, "left in place") { + said = true + } + } + if !said { + t.Fatalf("leaving the former archive was not said: %+v", report.Outcomes) + } + applied := false + for _, o := range report.Outcomes { + if o.ID == "notes.conf" && o.Action == "created" { + applied = true + } + } + if !applied { + t.Fatalf("the rest of the declaration was not applied: %+v", report.Outcomes) + } + + // An archive the declaration dropped is a different matter: nothing can undo it, and saying + // it was would report an effect the host declined to have (issue 162). + dropped := store.State{Resources: []store.Applied{ + {ID: "tool.next", Type: "archive", Target: "/usr/lib/tool/versions/old", Origin: store.OriginDeclared}, + }} + only := parse(t, `{"declaration":1,"resources":[{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"x"}]}`) + if _, _, err := Apply(context.Background(), archHost(t), only, dropped, store.OriginDeclared, run, nil, nil); err == nil || + !strings.Contains(err.Error(), "no way to remove") { + t.Fatalf("a dropped archive was passed over: %v", err) + } +}