The process applier's unchanged path returned an outcome that said nothing about what was written; the loop recorded it like any other, erasing the digest. The next cycle found no record and re-created the daemon, the one after found a record again, and so on: the node's runtime restarted every ten minutes on every machine since it arrived. The outcome now carries the digest forward, as a file's does. The test applies one process three times and asserts the record survives an unchanged apply and no restart is asked.
316 lines
13 KiB
Go
316 lines
13 KiB
Go
package apply
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
"github.com/novox/mesh-host/internal/store"
|
|
)
|
|
|
|
func aProcess() *declaration.Process {
|
|
return &declaration.Process{
|
|
ID: "server", Type: declaration.TypeProcess, Name: "greeter",
|
|
Source: "https://store.invalid/greeter/daemon",
|
|
Digest: "sha256:" + strings.Repeat("a", 64),
|
|
Run: []string{"node", "index.js"},
|
|
Env: map[string]string{"MESH_NODE": "anchor", "A_FIRST": "1"},
|
|
}
|
|
}
|
|
|
|
// The unit the mesh writes says what it runs, where, and that it comes back.
|
|
func TestTheUnitRunsWhatTheDaemonSaid(t *testing.T) {
|
|
unit := unitFor(aProcess())
|
|
for _, want := range []string{
|
|
"ExecStart=node index.js",
|
|
"WorkingDirectory=/var/lib/mesh/daemons/greeter",
|
|
"Restart=always",
|
|
"WantedBy=multi-user.target",
|
|
} {
|
|
if !strings.Contains(unit, want) {
|
|
t.Fatalf("the unit does not say %q:\n%s", want, unit)
|
|
}
|
|
}
|
|
}
|
|
|
|
// **Generated whole and saying so.** Every managed file on a machine carries this, because an edit
|
|
// that survives until the next declaration and then vanishes is worse than one that is refused.
|
|
func TestTheUnitSaysItIsTheMeshs(t *testing.T) {
|
|
unit := unitFor(aProcess())
|
|
if !strings.HasPrefix(unit, "#") || !strings.Contains(unit, "Do not edit") {
|
|
t.Fatalf("the unit does not say it is generated:\n%s", unit)
|
|
}
|
|
}
|
|
|
|
// **Deterministic, because the unit is half the daemon's identity.** Environment held in a map
|
|
// would be written in Go's iteration order, so every apply would see a different unit and call an
|
|
// unchanged daemon changed — restarting it on every declaration for ever.
|
|
func TestTheUnitIsTheSameEveryTime(t *testing.T) {
|
|
first := unitFor(aProcess())
|
|
for i := 0; i < 20; i++ {
|
|
if again := unitFor(aProcess()); again != first {
|
|
t.Fatalf("two renderings of one daemon differ:\n%s\n---\n%s", first, again)
|
|
}
|
|
}
|
|
// And sorted, so the order is a decision rather than luck.
|
|
if strings.Index(first, "A_FIRST") > strings.Index(first, "MESH_NODE") {
|
|
t.Fatalf("environment is not in a stable order:\n%s", first)
|
|
}
|
|
}
|
|
|
|
// **Two daemons from one bundle differing only in their command are different daemons.** Tracking
|
|
// the digest alone would call the second one unchanged and leave the first one running.
|
|
func TestAProcesssIdentityIncludesHowItIsRun(t *testing.T) {
|
|
one := aProcess()
|
|
two := aProcess()
|
|
two.Run = []string{"node", "other.js"}
|
|
if unitFor(one) == unitFor(two) {
|
|
t.Fatal("two daemons with different commands render one unit, so a change would be missed")
|
|
}
|
|
}
|
|
|
|
// A process that runs as somebody says so, and one that does not says nothing — rather than naming
|
|
// root explicitly, which would be a claim the mesh does not need to make.
|
|
func TestAProcessRunsAsWhoItSaid(t *testing.T) {
|
|
as := aProcess()
|
|
as.User = "greeter"
|
|
if !strings.Contains(unitFor(as), "User=greeter") {
|
|
t.Fatalf("the unit does not run as the user it named:\n%s", unitFor(as))
|
|
}
|
|
if strings.Contains(unitFor(aProcess()), "User=") {
|
|
t.Fatalf("a process that named no user had one written for it:\n%s", unitFor(aProcess()))
|
|
}
|
|
}
|
|
|
|
// **Three modes, one kind.** A scheduled process is a timer plus a unit that finishes, not a unit
|
|
// that stays up — and the difference has to be in what is written, or a schedule becomes a second
|
|
// copy running continuously between fires.
|
|
func TestAScheduledProcessRunsOnItsCadenceRatherThanContinuously(t *testing.T) {
|
|
every := aProcess()
|
|
every.Schedule = "0 3 * * *"
|
|
|
|
unit := unitFor(every)
|
|
if strings.Contains(unit, "Restart=always") {
|
|
t.Fatalf("a scheduled process is restarted whenever it exits, so it never stops:\n%s", unit)
|
|
}
|
|
if !strings.Contains(unit, "Type=oneshot") {
|
|
t.Fatalf("a scheduled process is not a step that finishes:\n%s", unit)
|
|
}
|
|
|
|
timer := timerFor(every)
|
|
if !strings.Contains(timer, "OnCalendar=") {
|
|
t.Fatalf("a scheduled process has no cadence:\n%s", timer)
|
|
}
|
|
// A fire missed while the machine was off happens when it returns, rather than being skipped —
|
|
// the difference between a machine that was down and a schedule that quietly stopped.
|
|
if !strings.Contains(timer, "Persistent=true") {
|
|
t.Fatalf("a missed fire is skipped silently:\n%s", timer)
|
|
}
|
|
}
|
|
|
|
// Five-field cron becomes what a timer reads, rather than the host waking to decide.
|
|
func TestACronBecomesATimersCalendar(t *testing.T) {
|
|
for cron, want := range map[string]string{
|
|
"0 3 * * *": "*-*-* 3:0:00",
|
|
"30 4 1 * *": "*-*-1 4:30:00",
|
|
"0 0 * * mon": "mon *-*-* 0:0:00",
|
|
} {
|
|
if got := calendarFor(cron); got != want {
|
|
t.Fatalf("%q became %q rather than %q", cron, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// And the long-running mode is unchanged by any of it: it stays up and comes back.
|
|
func TestAProcessThatStaysUpIsStillRestartedWhenItExits(t *testing.T) {
|
|
unit := unitFor(aProcess())
|
|
if !strings.Contains(unit, "Restart=always") {
|
|
t.Fatalf("a process that should stay up is not restarted when it exits:\n%s", unit)
|
|
}
|
|
if strings.Contains(unit, "Type=oneshot") {
|
|
t.Fatalf("a process that should stay up is declared a step:\n%s", unit)
|
|
}
|
|
}
|
|
|
|
// **A unit file reinterprets a value in three ways nothing else does**, and a module's environment
|
|
// routinely contains all three — a generated password is arbitrary bytes.
|
|
func TestAnEnvironmentValueMeansWhatTheDeclarationSaid(t *testing.T) {
|
|
// A percent begins a specifier: %H is the hostname. A password containing one would be
|
|
// silently replaced, failing as an authentication error nobody can explain from the
|
|
// declaration.
|
|
percent := aProcess()
|
|
percent.Env = map[string]string{"PASSWORD": "a%Hb"}
|
|
if !strings.Contains(unitFor(percent), "%%H") {
|
|
t.Fatalf("a percent was left as a systemd specifier:\n%s", unitFor(percent))
|
|
}
|
|
|
|
// Whitespace separates assignments: unquoted, K=a b sets K to "a" and reads "b" as another.
|
|
spaced := aProcess()
|
|
spaced.Env = map[string]string{"GREETING": "hello there"}
|
|
if !strings.Contains(unitFor(spaced), `"GREETING=hello there"`) {
|
|
t.Fatalf("a value with a space was not quoted:\n%s", unitFor(spaced))
|
|
}
|
|
|
|
// A quote would end the quoting early, and what follows would be read as unit syntax.
|
|
quoted := aProcess()
|
|
quoted.Env = map[string]string{"TOKEN": `a"b`}
|
|
line := ""
|
|
for _, l := range strings.Split(unitFor(quoted), "\n") {
|
|
if strings.HasPrefix(l, "Environment=") {
|
|
line = l
|
|
}
|
|
}
|
|
if !strings.Contains(line, `\"`) {
|
|
t.Fatalf("a quote was not escaped, so the value ends early: %s", line)
|
|
}
|
|
}
|
|
|
|
func TestAnUndeclaredProcessIsRemovedWithItsUnitAndBundle(t *testing.T) {
|
|
// novox/hq ADR 0118: a process's unit and bundle are the host's own, so they go with the
|
|
// declaration. Before, there was no way to remove a process at all, and one left undeclared
|
|
// failed every apply on its node.
|
|
units, bundles := t.TempDir(), t.TempDir()
|
|
wasUnits, wasBundles := unitDir, daemonRoot
|
|
unitDir, daemonRoot = units, bundles
|
|
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
|
|
|
|
for _, f := range []string{"mesh-job.service", "mesh-job.timer"} {
|
|
if err := os.WriteFile(filepath.Join(units, f), []byte("[Unit]\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(bundles, "mesh-job", "bin"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var commands []string
|
|
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
|
commands = append(commands, strings.Join(args, " "))
|
|
return "", nil
|
|
}
|
|
known := store.State{Resources: []store.Applied{{ID: "p", Type: "process", Target: "mesh-job"}}}
|
|
d := parse(t, `{"declaration":1,"resources":[
|
|
{"id":"f","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
|
|
]}`)
|
|
report, after, err := Apply(context.Background(), archHost(t), d, known, store.OriginCarried, run, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("an undeclared process failed the apply: %v", err)
|
|
}
|
|
joined := strings.Join(commands, "; ")
|
|
timer, service := strings.Index(joined, "disable --now mesh-job.timer"), strings.Index(joined, "disable --now mesh-job.service")
|
|
if timer < 0 || service < 0 || timer > service {
|
|
t.Errorf("the timer and the unit were not stopped, timer first: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "daemon-reload") {
|
|
t.Errorf("the service manager was not told its units changed: %s", joined)
|
|
}
|
|
for _, gone := range []string{filepath.Join(units, "mesh-job.service"), filepath.Join(units, "mesh-job.timer"), filepath.Join(bundles, "mesh-job")} {
|
|
if _, err := os.Stat(gone); !os.IsNotExist(err) {
|
|
t.Errorf("%s is still there", gone)
|
|
}
|
|
}
|
|
if o := outcomeOf(report, "p"); o.Action != "removed" {
|
|
t.Errorf("outcome %+v, want removed", o)
|
|
}
|
|
if _, still := after.Find("p"); still {
|
|
t.Error("the host still believes it owns the process")
|
|
}
|
|
|
|
// Again, with everything already gone: forgotten, not an error.
|
|
_, _, err = Apply(context.Background(), archHost(t), d, known, store.OriginCarried, run, nil, nil)
|
|
if err != nil {
|
|
t.Errorf("removing a process that is already gone failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAProcessRecordedUnderAPathlikeNameIsRefusedNotRemoved(t *testing.T) {
|
|
// filepath.Join(daemonRoot, "..") is the mesh's own directory, and removal deletes what that
|
|
// names, whole. A record is what some host wrote, perhaps under looser rules than today's, so
|
|
// the removal holds the name to the declaration's rule again (novox/hq ADR 0118).
|
|
root := t.TempDir()
|
|
bundles := filepath.Join(root, "daemons")
|
|
wasUnits, wasBundles := unitDir, daemonRoot
|
|
unitDir, daemonRoot = t.TempDir(), bundles
|
|
t.Cleanup(func() { unitDir, daemonRoot = wasUnits, wasBundles })
|
|
precious := filepath.Join(root, "state.json")
|
|
if err := os.MkdirAll(filepath.Join(bundles, "other"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(precious, []byte("{}"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, name := range []string{"..", ".", "", "-x"} {
|
|
known := store.State{Resources: []store.Applied{{ID: "p", Type: "process", Target: name}}}
|
|
d := parse(t, `{"declaration":1,"resources":[
|
|
{"id":"f","type":"file","path":"`+filepath.Join(t.TempDir(), "a")+`","content":"a\n"}
|
|
]}`)
|
|
if _, _, err := Apply(context.Background(), archHost(t), d, known, store.OriginCarried, noServices, nil, nil); err == nil {
|
|
t.Errorf("a process recorded as %q was removed by name", name)
|
|
}
|
|
for _, still := range []string{precious, filepath.Join(bundles, "other")} {
|
|
if _, err := os.Stat(still); err != nil {
|
|
t.Fatalf("removing a process recorded as %q took %s with it", name, still)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|