diff --git a/internal/apply/process.go b/internal/apply/process.go index b3c5978..f79fd87 100644 --- a/internal/apply/process.go +++ b/internal/apply/process.go @@ -77,6 +77,11 @@ func applyProcess(ctx context.Context, r *declaration.Process, run Runner, // Everything about it is as declared. Still asked whether it is RUNNING, because a // declaration that is satisfied by a record rather than by the machine is how a stopped // service reports success. + // **The record is carried forward, not re-derived.** An unchanged outcome is recorded + // like any other, so one that said nothing about what was written erased the digest; the + // next apply then found no record, re-created the daemon, and the one after that found a + // record again — the node's runtime restarted every other cycle (novox/hq 04-ISSUES/210). + out.wrote = want if active, err := run(ctx, "systemctl", "is-active", "--quiet", r.Name+".service"); err == nil { _ = active return out, nil diff --git a/internal/apply/process_test.go b/internal/apply/process_test.go index 7f055f7..70b9bdb 100644 --- a/internal/apply/process_test.go +++ b/internal/apply/process_test.go @@ -256,3 +256,60 @@ func TestAProcessRecordedUnderAPathlikeNameIsRefusedNotRemoved(t *testing.T) { } } } + +// novox/hq 04-ISSUES/210: the node's runtime was re-created — and restarted — on every reconcile, +// because the host did not find what it wrote for a process the cycle before. Applying the same +// process declaration twice must do no work the second time. +func TestAProcessAppliedAgainIsUnchangedAndNotRestarted(t *testing.T) { + units, bundles := t.TempDir(), t.TempDir() + wasUnits, wasBundles := unitDir, daemonRoot + unitDir, daemonRoot = units, bundles + t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles }) + + body, digest := anArchive(t, map[string]string{"main.js": "console.log(1)\n"}) + var commands []string + run := func(ctx context.Context, name string, args ...string) (string, error) { + commands = append(commands, name+" "+strings.Join(args, " ")) + return "", nil + } + d := declare(t, `{"id":"node-tools.runtime","type":"process","name":"node-tools","source":"`+serving(t, body)+ + `","digest":"`+digest+`","run":["node","main.js"],"env":{"MESH_TOOL_MODULES":"a=/x/index.js"}}`) + + first, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared, run, nil, nil) + if err != nil { + t.Fatal(err) + } + if o := outcomeOf(first, "node-tools.runtime"); o.Action != "created" { + t.Fatalf("first apply: %+v, want created", o) + } + rec, ok := state.Find("node-tools.runtime") + if !ok || rec.Wrote == "" { + t.Fatalf("the host did not record what it wrote for the process: %+v", rec) + } + commands = nil + again, state, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, run, nil, nil) + if err != nil { + t.Fatal(err) + } + if o := outcomeOf(again, "node-tools.runtime"); o.Action != "unchanged" { + t.Errorf("second apply: %+v, want unchanged", o) + } + for _, c := range commands { + if strings.Contains(c, "restart") { + t.Errorf("the second apply restarted the process: %v", commands) + } + } + // And the record survives an unchanged apply: the third cycle is unchanged too. This is the + // cycle the live mesh showed — created, unchanged, created — before the record was carried. + if rec, _ := state.Find("node-tools.runtime"); rec.Wrote == "" { + t.Fatalf("an unchanged apply dropped the digest from the record: %+v", rec) + } + commands = nil + third, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, run, nil, nil) + if err != nil { + t.Fatal(err) + } + if o := outcomeOf(third, "node-tools.runtime"); o.Action != "unchanged" { + t.Errorf("third apply: %+v, want unchanged", o) + } +}