From f5cf9510c1d034861ff314f428e11bd7ea64a6c6 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 15 Sep 2026 10:26:31 +0200 Subject: [PATCH] One kind for the module's own code, with three modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut of this added a `daemon` for the long-running case alone. That would have meant a new vocabulary entry for each of the others — a scheduled task, a run-once migration, a health check — when they are one thing run at different cadences. That is a field, not four entries in a vocabulary where every entry widens what a compromised control plane can express. So it mirrors a container exactly, because it IS a container's twin: the same intent, hosted by the machine's own supervisor instead of a runtime. Stays up, runs once, or runs on a schedule. Tools, hooks and event consumers are not further modes. They are loaded by a tool host, which is itself a process that stays up — so the generic case already covers them, which is the test of whether it is generic. A scheduled process gets a timer and a unit that finishes; a long-running one gets a unit that is restarted when it exits. Getting that wrong either way is a second copy running continuously between fires, or a schedule that never fires. The modes are exclusive and validation says so near the author: something that runs once does not run on a schedule, and something not running between fires cannot be restarted when a file changes. A missed fire happens when the machine comes back rather than being skipped, which is the difference between a machine that was down and a schedule that quietly stopped. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- internal/apply/apply.go | 4 +- internal/apply/daemon_test.go | 82 -------------- internal/apply/{daemon.go => process.go} | 108 +++++++++++++++++-- internal/apply/process_test.go | 132 +++++++++++++++++++++++ internal/declaration/cron_test.go | 40 +++---- internal/declaration/daemon_test.go | 72 ------------- internal/declaration/declaration.go | 97 +++++++++++------ internal/declaration/declaration_test.go | 2 +- internal/declaration/process_test.go | 118 ++++++++++++++++++++ internal/system/system.go | 2 +- 10 files changed, 442 insertions(+), 215 deletions(-) delete mode 100644 internal/apply/daemon_test.go rename internal/apply/{daemon.go => process.go} (57%) create mode 100644 internal/apply/process_test.go delete mode 100644 internal/declaration/daemon_test.go create mode 100644 internal/declaration/process_test.go diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 6435005..a440d23 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -270,8 +270,8 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru return applyUser(ctx, sys, res, run) case *declaration.Archive: return applyArchive(ctx, res, previous) - case *declaration.Daemon: - return applyDaemon(ctx, res, run, changed, previous) + case *declaration.Process: + return applyProcess(ctx, res, run, changed, previous) case *declaration.Action: return applyAction(ctx, res, run) case *declaration.Network: diff --git a/internal/apply/daemon_test.go b/internal/apply/daemon_test.go deleted file mode 100644 index 2103818..0000000 --- a/internal/apply/daemon_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package apply - -import ( - "strings" - "testing" - - "github.com/novox/mesh-host/internal/declaration" -) - -func aDaemon() *declaration.Daemon { - return &declaration.Daemon{ - ID: "server", Type: declaration.TypeDaemon, 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(aDaemon()) - 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(aDaemon()) - 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(aDaemon()) - for i := 0; i < 20; i++ { - if again := unitFor(aDaemon()); 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 TestADaemonsIdentityIncludesHowItIsRun(t *testing.T) { - one := aDaemon() - two := aDaemon() - 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 daemon 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 TestADaemonRunsAsWhoItSaid(t *testing.T) { - as := aDaemon() - 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(aDaemon()), "User=") { - t.Fatalf("a daemon that named no user had one written for it:\n%s", unitFor(aDaemon())) - } -} diff --git a/internal/apply/daemon.go b/internal/apply/process.go similarity index 57% rename from internal/apply/daemon.go rename to internal/apply/process.go index 1d7d0dc..12e833e 100644 --- a/internal/apply/daemon.go +++ b/internal/apply/process.go @@ -35,7 +35,7 @@ const daemonRoot = "/var/lib/mesh/daemons" // unitDir is where the mesh writes the units it owns. const unitDir = "/etc/systemd/system" -func applyDaemon(ctx context.Context, r *declaration.Daemon, run Runner, +func applyProcess(ctx context.Context, r *declaration.Process, run Runner, changed map[string]bool, previous store.Applied) (Outcome, error) { out := begin(r) out.Action = "unchanged" @@ -54,9 +54,9 @@ func applyDaemon(ctx context.Context, r *declaration.Daemon, run Runner, r.Source, r.Digest, got) } - // **The identity of a daemon is its bytes AND how it is run.** Two daemons from one bundle - // differing only in their command are different daemons, and a record that tracked the digest - // alone would call the second one unchanged. + // **Its identity is its bytes AND how it is run.** Two processes from one bundle differing + // only in their command are different, and a record tracking the digest alone would call the + // second one unchanged. want := got + " " + unitFor(r) at := filepath.Join(daemonRoot, r.Name) @@ -105,6 +105,23 @@ func applyDaemon(ctx context.Context, r *declaration.Daemon, run Runner, return out, err } + // **A step is run to completion, not installed.** What follows it is gated on it finishing, + // so the machine is not asked to start something that needed a migration that did not happen. + // Nothing is left behind to ask afterwards: the record that it ran is the digest, which is why + // the identity above includes the command. + if r.RunOnce { + if _, err := run(ctx, r.Run[0], r.Run[1:]...); err != nil { + return out, fmt.Errorf("the %s step did not complete: %w", r.Name, err) + } + out.Action = "created" + if previous.Wrote != "" { + out.Action = "updated" + } + out.Detail = fmt.Sprintf("%d file(s), step completed", written) + out.wrote = want + return out, nil + } + unit := filepath.Join(unitDir, r.Name+".service") if err := os.WriteFile(unit, []byte(unitFor(r)), 0o644); err != nil { return out, err @@ -114,6 +131,35 @@ func applyDaemon(ctx context.Context, r *declaration.Daemon, run Runner, } // Enabled and restarted, in that order: enabled so it survives a reboot, restarted rather than // started because this path is also how a new version arrives and the old one is still running. + // **Scheduled means a timer, not a service that stays up.** The unit above is written either + // way and describes what to run; what differs is whether the machine is asked to keep it + // running or to start it when the timer says so. + if r.Schedule != "" { + timer := filepath.Join(unitDir, r.Name+".timer") + if err := os.WriteFile(timer, []byte(timerFor(r)), 0o644); err != nil { + return out, err + } + if _, err := run(ctx, "systemctl", "daemon-reload"); err != nil { + return out, err + } + // The timer is enabled and started; the service is neither. Enabling the service too + // would have it run at boot as well as on its cadence, which is a second schedule nobody + // asked for. + if _, err := run(ctx, "systemctl", "enable", r.Name+".timer"); err != nil { + return out, err + } + if _, err := run(ctx, "systemctl", "restart", r.Name+".timer"); err != nil { + return out, fmt.Errorf("%s was installed and its timer would not start: %w", r.Name, err) + } + out.Action = "updated" + if previous.Wrote == "" { + out.Action = "created" + } + out.Detail = fmt.Sprintf("%d file(s), scheduled as %s.timer", written, r.Name) + out.wrote = want + return out, nil + } + if _, err := run(ctx, "systemctl", "enable", r.Name+".service"); err != nil { return out, err } @@ -141,7 +187,7 @@ func applyDaemon(ctx context.Context, r *declaration.Daemon, run Runner, // // Deterministic — environment sorted — because this string is half the daemon's identity, and a // map iterated in Go's order would make every apply look like a change. -func unitFor(r *declaration.Daemon) string { +func unitFor(r *declaration.Process) string { var b strings.Builder b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever the\n") b.WriteString("# declaration changes, and an edit would survive until then and vanish.\n") @@ -163,10 +209,58 @@ func unitFor(r *declaration.Daemon) string { fmt.Fprintf(&b, "User=%s\n", r.User) } fmt.Fprintf(&b, "ExecStart=%s\n", strings.Join(r.Run, " ")) - // Restarted when it exits, because a daemon that stops is not a daemon. Delayed, so a process - // that fails at once does not spin the machine. + if r.Schedule != "" { + // Started by its timer and expected to finish. Restarting it would have it run + // continuously between fires, which is the opposite of a schedule. + b.WriteString("Type=oneshot\n") + b.WriteString("\n") + return strings.Replace(b.String(), "Type=simple\n", "", 1) + } + // Restarted when it exits, because something that stops is not something that stays up. + // Delayed, so a process that fails at once does not spin the machine. b.WriteString("Restart=always\nRestartSec=5\n\n") b.WriteString("[Install]\nWantedBy=multi-user.target\n") return b.String() } + +// timerFor is the cadence a scheduled process runs on. +// +// **The mesh's cron expression, handed to the machine's own timer.** The host already parses and +// evaluates five-field cron (ADR 0053) for a scheduled container; a machine with a supervisor can +// be told the cadence directly rather than have the host wake up and decide. +func timerFor(r *declaration.Process) string { + var b strings.Builder + b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever the\n") + b.WriteString("# declaration changes, and an edit would survive until then and vanish.\n") + b.WriteString("[Unit]\n") + fmt.Fprintf(&b, "Description=%s, on a schedule the mesh set\n\n", r.Name) + b.WriteString("[Timer]\n") + fmt.Fprintf(&b, "OnCalendar=%s\n", calendarFor(r.Schedule)) + // A fire missed because the machine was off happens when it comes back, rather than being + // skipped silently — which is the difference between a machine that was down and a schedule + // that quietly stopped. + b.WriteString("Persistent=true\n\n") + b.WriteString("[Install]\nWantedBy=timers.target\n") + return b.String() +} + +// calendarFor turns five-field cron into what a systemd timer reads. +// +// minute hour day-of-month month day-of-week -> DayOfWeek Year-Month-Day Hour:Minute:Second +func calendarFor(cron string) string { + fields := strings.Fields(cron) + if len(fields) != 5 { + // Refused at validation, so this is unreachable — and returning something that would fire + // constantly is worse than returning something that never does. + return "*-*-* 00:00:00" + } + minute, hour, dom, month, dow := fields[0], fields[1], fields[2], fields[3], fields[4] + day := dow + if dow == "*" { + day = "" + } else { + day += " " + } + return fmt.Sprintf("%s*-%s-%s %s:%s:00", day, month, dom, hour, minute) +} diff --git a/internal/apply/process_test.go b/internal/apply/process_test.go new file mode 100644 index 0000000..1fd0826 --- /dev/null +++ b/internal/apply/process_test.go @@ -0,0 +1,132 @@ +package apply + +import ( + "strings" + "testing" + + "github.com/novox/mesh-host/internal/declaration" +) + +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) + } +} diff --git a/internal/declaration/cron_test.go b/internal/declaration/cron_test.go index 881852e..51e3dc0 100644 --- a/internal/declaration/cron_test.go +++ b/internal/declaration/cron_test.go @@ -11,19 +11,19 @@ import ( func TestParseCronRefusesMalformed(t *testing.T) { for _, expr := range []string{ - "", // nothing - "* * * *", // four fields - "* * * * * *", // six fields - "60 * * * *", // minute out of range - "* 24 * * *", // hour out of range - "* * 0 * *", // day-of-month below 1 - "* * 32 * *", // day-of-month above 31 - "* * * 13 *", // month out of range - "* * * * 8", // day-of-week above 7 - "a * * * *", // not a number - "*/0 * * * *", // zero step - "5-1 * * * *", // inverted range - "1,,2 * * * *", // empty element + "", // nothing + "* * * *", // four fields + "* * * * * *", // six fields + "60 * * * *", // minute out of range + "* 24 * * *", // hour out of range + "* * 0 * *", // day-of-month below 1 + "* * 32 * *", // day-of-month above 31 + "* * * 13 *", // month out of range + "* * * * 8", // day-of-week above 7 + "a * * * *", // not a number + "*/0 * * * *", // zero step + "5-1 * * * *", // inverted range + "1,,2 * * * *", // empty element } { if _, err := ParseCron(expr); err == nil { t.Errorf("a malformed cron %q was accepted", expr) @@ -33,13 +33,13 @@ func TestParseCronRefusesMalformed(t *testing.T) { func TestParseCronAcceptsTheOrdinaryForms(t *testing.T) { for _, expr := range []string{ - "* * * * *", // every minute - "0 3 * * *", // 03:00 daily - "*/15 * * * *", // every 15 minutes - "0 0 1 1 *", // new year - "0 9-17 * * 1-5", // business hours, weekdays - "0 0 * * 7", // Sunday as 7 - "0,30 * * * *", // twice an hour + "* * * * *", // every minute + "0 3 * * *", // 03:00 daily + "*/15 * * * *", // every 15 minutes + "0 0 1 1 *", // new year + "0 9-17 * * 1-5", // business hours, weekdays + "0 0 * * 7", // Sunday as 7 + "0,30 * * * *", // twice an hour } { if _, err := ParseCron(expr); err != nil { t.Errorf("a valid cron %q was refused: %v", expr, err) diff --git a/internal/declaration/daemon_test.go b/internal/declaration/daemon_test.go deleted file mode 100644 index d741753..0000000 --- a/internal/declaration/daemon_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package declaration - -import ( - "strings" - "testing" -) - -func aDaemon() *Daemon { - return &Daemon{ - ID: "server", Type: TypeDaemon, Name: "greeter", - Source: "https://store.invalid/greeter/daemon", - Digest: "sha256:" + strings.Repeat("a", 64), - Run: []string{"node", "index.js"}, - } -} - -// A daemon is part of the vocabulary, or a declaration carrying one is refused whole. -func TestADaemonIsSomethingTheHostSpeaks(t *testing.T) { - var found bool - for _, kind := range Vocabulary() { - if kind == TypeDaemon { - found = true - } - } - if !found { - t.Fatal("a daemon cannot be declared, so a module that declares one is refused") - } - if newOf(TypeDaemon) == nil { - t.Fatal("the decoder has no daemon, so one would be refused as an unknown kind") - } -} - -// **Pinned by digest, like everything else that crosses a network.** A bundle fetched by a -// reference somebody can repoint is not pinned, and it is the one thing on a machine that would -// then be running code nobody reviewed. -func TestADaemonsBundleMustBePinned(t *testing.T) { - for _, bad := range []string{"", "latest", "sha256:short", strings.Repeat("a", 64)} { - d := aDaemon() - d.Digest = bad - if problems := d.validate("a daemon", false); len(problems) == 0 { - t.Fatalf("a daemon pinned by %q was accepted", bad) - } - } -} - -// What to run is named, never inferred. Guessing an entrypoint from which files are present makes -// a daemon change what it runs when somebody adds a file. -func TestADaemonMustSayWhatToRun(t *testing.T) { - d := aDaemon() - d.Run = nil - if problems := d.validate("a daemon", false); len(problems) == 0 { - t.Fatal("a daemon with no command was accepted") - } -} - -// Its name becomes a unit name and a path, so a separator in it would write somewhere nobody meant. -func TestADaemonsNameCannotEscapeItsUnit(t *testing.T) { - for _, bad := range []string{"", "../escape", "two words", "a/b"} { - d := aDaemon() - d.Name = bad - if problems := d.validate("a daemon", false); len(problems) == 0 { - t.Fatalf("a daemon called %q was accepted", bad) - } - } -} - -// And a well-formed one is accepted, or the tests above prove only that everything is refused. -func TestAWellFormedDaemonIsAccepted(t *testing.T) { - if problems := aDaemon().validate("a daemon", false); len(problems) != 0 { - t.Fatalf("a well-formed daemon was refused: %v", problems) - } -} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 26c94a3..2c155eb 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -60,19 +60,24 @@ const ( // access is ordinary, because none of them owns it. TypeAccess Type = "access" - // TypeDaemon is a long-running process the mesh keeps running, named by what it runs rather - // than by how it is hosted. + // TypeProcess is the module's own code, run on the machine, in one of three modes. // // **The intent, not the mechanism.** Until this, an author decided the hosting before they // could declare anything: code of their own meant a `container` built from an image, a script - // meant a `service` and a unit somebody else had to install. Same intent — run this and keep - // it running — expressed two unrelated ways, and the choice baked into which kind was picked. + // meant a `service` and a unit somebody else had to install. Same intent — run this — with + // the choice baked into which kind was picked. // - // A daemon names an artifact and what to run. The mesh unpacks the artifact where it keeps - // such things, writes the unit, and puts it in the state asked for. One module may declare - // several, in different languages, because a module is one piece of software and not one - // process (novox/hq ADR 0040). - TypeDaemon Type = "daemon" + // **And three modes rather than three kinds**, exactly as a container has. A first draft of + // this added a `daemon` for the long-running case alone, which would have meant a new kind for + // each of the others — a scheduled task, a run-once migration, a health check. They are one + // thing run at different cadences, and that is a field, not a vocabulary entry. Every addition + // to this vocabulary widens what a compromised control plane can express. + // + // What a module declares is a bundle and a command. The mesh unpacks the bundle where it keeps + // such things and runs it — as a unit that stays up, as a step that must finish, or on a + // cadence. Tools, hooks and event consumers are not separate modes: they are loaded by a tool + // host, which is itself a process that stays up. + TypeProcess Type = "process" ) // Resource is one thing that should be true of the machine. @@ -407,17 +412,22 @@ func (a *Archive) validate(where string, _ bool) []string { return problems } -// Daemon is a long-running process the mesh installs, keeps running, and owns the unit for. +// Process is the module's own code, run on the machine, in one of three modes. // // The difference from Service is who owns the unit: a Service puts an EXISTING unit into a state -// and deliberately does not install one, which is right for software that ships its own. A Daemon -// is the mesh's own code — a bundle it built — so there is no unit until the mesh writes it, and +// and deliberately does not install one, which is right for software that ships its own. This is +// the mesh's own code — a bundle it built — so there is no unit until the mesh writes it, and // nothing else will. // -// The difference from Container is the hosting, and a module should not have to choose: what a -// daemon says is what to run, and the machine's own process supervisor is how. A module whose code -// genuinely needs a container's isolation declares a container and says so. -type Daemon struct { +// The difference from Container is the hosting, and a module should not have to choose: what this +// says is what to run, and the machine's own supervisor is how. Code that genuinely needs a +// container's isolation declares a container and says so. +// +// **Three modes, matching a container's**, because they are the same thing at different cadences: +// stays up, runs once, runs on a schedule. A module's scheduled task, its run-once migration, its +// health check and its tool host are all this — and each being its own resource kind would be four +// entries in a vocabulary where every entry widens what a compromised control plane can express. +type Process struct { ID string `json:"id"` Type Type `json:"type"` // Name is what the unit is called, and what an operator will see in the process table. @@ -444,41 +454,68 @@ type Daemon struct { // configuration, so replacing a file and finding the process already up leaves the machine // behaving the way it did before while every check passes. RestartOn []string `json:"restart-on,omitempty"` + + // RunOnce marks code the host runs to completion rather than leaves running: a migration, a + // seed, a first-boot step. What follows it is gated on it finishing, because a step that did + // not make the machine ready must not be followed by the thing that needed it. + RunOnce bool `json:"run-once,omitempty"` + + // Schedule runs it on a cadence — a five-field cron expression (novox/hq ADR 0053). The + // recurring twin of RunOnce: the same code, run again rather than left running. + // + // Exclusive with RunOnce and with RestartOn, for the same reason a container's is: something + // that runs once does not run on a schedule, and something that is not running cannot be + // restarted when a file changes. + Schedule string `json:"schedule,omitempty"` } -func (d *Daemon) Identity() string { return d.ID } -func (d *Daemon) Kind() Type { return TypeDaemon } -func (d *Daemon) Target() string { return d.Name } +func (d *Process) Identity() string { return d.ID } +func (d *Process) Kind() Type { return TypeProcess } +func (d *Process) Target() string { return d.Name } -func (d *Daemon) validate(where string, _ bool) []string { +func (d *Process) validate(where string, _ bool) []string { var problems []string if d.Name == "" { - problems = append(problems, where+": a daemon needs a name, which is what its unit is called") + problems = append(problems, where+": a process needs a name, which is what its unit is called") } if strings.ContainsAny(d.Name, "/ \t") { // It becomes a unit name and a file on disk. A name with a separator in it would write // somewhere nobody meant. - problems = append(problems, where+": a daemon's name becomes a unit name, so it cannot "+ + problems = append(problems, where+": a process name becomes a unit name, so it cannot "+ "contain a path separator or a space") } if d.Source == "" { - problems = append(problems, where+": a daemon needs somewhere to fetch its bundle from") + problems = append(problems, where+": a process needs somewhere to fetch its bundle from") } if !strings.HasPrefix(d.Digest, "sha256:") || len(d.Digest) != len("sha256:")+64 { // The same rule an archive follows, and for the same reason: this crosses a network the // mesh does not control, and a reference that can be made to point elsewhere is not one. problems = append(problems, where+ - ": a daemon's bundle is pinned by digest, as sha256:<64 hex characters>") + ": a process bundle is pinned by digest, as sha256:<64 hex characters>") } if len(d.Run) == 0 { - problems = append(problems, where+": a daemon needs to say what to run") + problems = append(problems, where+": a process needs to say what to run") } for _, part := range d.Run { if part == "" { - problems = append(problems, where+": a daemon's command has an empty element") + problems = append(problems, where+": a process command has an empty element") break } } + if d.Schedule != "" { + if d.RunOnce { + problems = append(problems, where+ + ": a process runs once or on a schedule, not both") + } + if len(d.RestartOn) > 0 { + problems = append(problems, where+ + ": a scheduled process is not running between its fires, so there is nothing to "+ + "restart when something it reads changes") + } + if _, err := ParseCron(d.Schedule); err != nil { + problems = append(problems, where+": "+err.Error()) + } + } return problems } @@ -743,8 +780,8 @@ func newOf(t Type) Resource { return &Archive{} case TypeAccess: return &Access{} - case TypeDaemon: - return &Daemon{} + case TypeProcess: + return &Process{} } return nil } @@ -752,8 +789,8 @@ func newOf(t Type) Resource { // Vocabulary is every kind this host speaks. func Vocabulary() []Type { return []Type{ - TypeAccess, TypeAction, TypeArchive, TypeContainer, TypeDaemon, TypeDirectory, TypeFile, - TypeNetwork, TypePackage, TypeService, TypeUser, + TypeAccess, TypeAction, TypeArchive, TypeContainer, TypeDirectory, TypeFile, + TypeNetwork, TypePackage, TypeProcess, TypeService, TypeUser, } } diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go index 1f3c025..9a0c27a 100644 --- a/internal/declaration/declaration_test.go +++ b/internal/declaration/declaration_test.go @@ -282,7 +282,7 @@ func TestTheVocabularyIsTheElevenShapesTheMeshNeeds(t *testing.T) { } for _, want := range []Type{ TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction, - TypeUser, TypeArchive, TypeNetwork, TypeAccess, TypeDaemon, + TypeUser, TypeArchive, TypeNetwork, TypeAccess, TypeProcess, } { if !speaks[want] { t.Errorf("the host no longer speaks %q", want) diff --git a/internal/declaration/process_test.go b/internal/declaration/process_test.go new file mode 100644 index 0000000..a8d6c2b --- /dev/null +++ b/internal/declaration/process_test.go @@ -0,0 +1,118 @@ +package declaration + +import ( + "strings" + "testing" +) + +func aProcess() *Process { + return &Process{ + ID: "server", Type: TypeProcess, Name: "greeter", + Source: "https://store.invalid/greeter/daemon", + Digest: "sha256:" + strings.Repeat("a", 64), + Run: []string{"node", "index.js"}, + } +} + +// A process is part of the vocabulary, or a declaration carrying one is refused whole. +func TestAProcessIsSomethingTheHostSpeaks(t *testing.T) { + var found bool + for _, kind := range Vocabulary() { + if kind == TypeProcess { + found = true + } + } + if !found { + t.Fatal("a process cannot be declared, so a module that declares one is refused") + } + if newOf(TypeProcess) == nil { + t.Fatal("the decoder has no daemon, so one would be refused as an unknown kind") + } +} + +// **Pinned by digest, like everything else that crosses a network.** A bundle fetched by a +// reference somebody can repoint is not pinned, and it is the one thing on a machine that would +// then be running code nobody reviewed. +func TestAProcesssBundleMustBePinned(t *testing.T) { + for _, bad := range []string{"", "latest", "sha256:short", strings.Repeat("a", 64)} { + d := aProcess() + d.Digest = bad + if problems := d.validate("a process", false); len(problems) == 0 { + t.Fatalf("a process pinned by %q was accepted", bad) + } + } +} + +// What to run is named, never inferred. Guessing an entrypoint from which files are present makes +// a process change what it runs when somebody adds a file. +func TestAProcessMustSayWhatToRun(t *testing.T) { + d := aProcess() + d.Run = nil + if problems := d.validate("a process", false); len(problems) == 0 { + t.Fatal("a process with no command was accepted") + } +} + +// Its name becomes a unit name and a path, so a separator in it would write somewhere nobody meant. +func TestAProcesssNameCannotEscapeItsUnit(t *testing.T) { + for _, bad := range []string{"", "../escape", "two words", "a/b"} { + d := aProcess() + d.Name = bad + if problems := d.validate("a process", false); len(problems) == 0 { + t.Fatalf("a process called %q was accepted", bad) + } + } +} + +// And a well-formed one is accepted, or the tests above prove only that everything is refused. +func TestAWellFormedDaemonIsAccepted(t *testing.T) { + if problems := aProcess().validate("a process", false); len(problems) != 0 { + t.Fatalf("a well-formed daemon was refused: %v", problems) + } +} + +// **The modes are exclusive, and saying so is the point of having one kind.** Something that runs +// once does not run on a schedule; something not running between fires cannot be restarted when a +// file changes. A container's modes carry the same rule, and this is the same rule because it is +// the same thing hosted differently. +func TestTheModesAreExclusive(t *testing.T) { + both := aProcess() + both.RunOnce = true + both.Schedule = "0 3 * * *" + if problems := both.validate("a process", false); len(problems) == 0 { + t.Fatal("a process that runs once and on a schedule was accepted") + } + + watching := aProcess() + watching.Schedule = "0 3 * * *" + watching.RestartOn = []string{"some-file"} + if problems := watching.validate("a process", false); len(problems) == 0 { + t.Fatal("a scheduled process was given something to restart on, and it is never running") + } +} + +// A cadence that is not a cadence is refused near its author, rather than by a machine at the far +// end of a declaration. +func TestAScheduleMustBeACadence(t *testing.T) { + for _, bad := range []string{"often", "0 3 * *", "99 3 * * *"} { + p := aProcess() + p.Schedule = bad + if problems := p.validate("a process", false); len(problems) == 0 { + t.Fatalf("a process scheduled %q was accepted", bad) + } + } +} + +// And each mode on its own is accepted, or the tests above prove only that everything is refused. +func TestEachModeOnItsOwnIsAccepted(t *testing.T) { + once := aProcess() + once.RunOnce = true + if problems := once.validate("a process", false); len(problems) != 0 { + t.Fatalf("a step was refused: %v", problems) + } + every := aProcess() + every.Schedule = "0 3 * * *" + if problems := every.validate("a process", false); len(problems) != 0 { + t.Fatalf("a scheduled process was refused: %v", problems) + } +} diff --git a/internal/system/system.go b/internal/system/system.go index 03c77d3..3f928e6 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -177,7 +177,7 @@ func everyShape() []declaration.Type { // full host from the floor. It does NOT need a container runtime, which is the point of // it: the mesh's own code runs as a process on the machine, and only software that // genuinely needs isolation asks for a container. - declaration.TypeDaemon, + declaration.TypeProcess, } }