One kind for the module's own code, with three modes

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
This commit is contained in:
2026-09-15 10:26:31 +02:00
parent 1f0fb85128
commit f5cf9510c1
10 changed files with 442 additions and 215 deletions
+2 -2
View File
@@ -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:
-82
View File
@@ -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()))
}
}
@@ -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)
}
+132
View File
@@ -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)
}
}