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:
@@ -270,8 +270,8 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru
|
|||||||
return applyUser(ctx, sys, res, run)
|
return applyUser(ctx, sys, res, run)
|
||||||
case *declaration.Archive:
|
case *declaration.Archive:
|
||||||
return applyArchive(ctx, res, previous)
|
return applyArchive(ctx, res, previous)
|
||||||
case *declaration.Daemon:
|
case *declaration.Process:
|
||||||
return applyDaemon(ctx, res, run, changed, previous)
|
return applyProcess(ctx, res, run, changed, previous)
|
||||||
case *declaration.Action:
|
case *declaration.Action:
|
||||||
return applyAction(ctx, res, run)
|
return applyAction(ctx, res, run)
|
||||||
case *declaration.Network:
|
case *declaration.Network:
|
||||||
|
|||||||
@@ -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.
|
// unitDir is where the mesh writes the units it owns.
|
||||||
const unitDir = "/etc/systemd/system"
|
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) {
|
changed map[string]bool, previous store.Applied) (Outcome, error) {
|
||||||
out := begin(r)
|
out := begin(r)
|
||||||
out.Action = "unchanged"
|
out.Action = "unchanged"
|
||||||
@@ -54,9 +54,9 @@ func applyDaemon(ctx context.Context, r *declaration.Daemon, run Runner,
|
|||||||
r.Source, r.Digest, got)
|
r.Source, r.Digest, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// **The identity of a daemon is its bytes AND how it is run.** Two daemons from one bundle
|
// **Its identity is its bytes AND how it is run.** Two processes from one bundle differing
|
||||||
// differing only in their command are different daemons, and a record that tracked the digest
|
// only in their command are different, and a record tracking the digest alone would call the
|
||||||
// alone would call the second one unchanged.
|
// second one unchanged.
|
||||||
want := got + " " + unitFor(r)
|
want := got + " " + unitFor(r)
|
||||||
at := filepath.Join(daemonRoot, r.Name)
|
at := filepath.Join(daemonRoot, r.Name)
|
||||||
|
|
||||||
@@ -105,6 +105,23 @@ func applyDaemon(ctx context.Context, r *declaration.Daemon, run Runner,
|
|||||||
return out, err
|
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")
|
unit := filepath.Join(unitDir, r.Name+".service")
|
||||||
if err := os.WriteFile(unit, []byte(unitFor(r)), 0o644); err != nil {
|
if err := os.WriteFile(unit, []byte(unitFor(r)), 0o644); err != nil {
|
||||||
return out, err
|
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
|
// 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.
|
// 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 {
|
if _, err := run(ctx, "systemctl", "enable", r.Name+".service"); err != nil {
|
||||||
return out, err
|
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
|
// 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.
|
// 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
|
var b strings.Builder
|
||||||
b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever the\n")
|
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("# 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, "User=%s\n", r.User)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "ExecStart=%s\n", strings.Join(r.Run, " "))
|
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
|
if r.Schedule != "" {
|
||||||
// that fails at once does not spin the machine.
|
// 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("Restart=always\nRestartSec=5\n\n")
|
||||||
|
|
||||||
b.WriteString("[Install]\nWantedBy=multi-user.target\n")
|
b.WriteString("[Install]\nWantedBy=multi-user.target\n")
|
||||||
return b.String()
|
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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,19 +11,19 @@ import (
|
|||||||
|
|
||||||
func TestParseCronRefusesMalformed(t *testing.T) {
|
func TestParseCronRefusesMalformed(t *testing.T) {
|
||||||
for _, expr := range []string{
|
for _, expr := range []string{
|
||||||
"", // nothing
|
"", // nothing
|
||||||
"* * * *", // four fields
|
"* * * *", // four fields
|
||||||
"* * * * * *", // six fields
|
"* * * * * *", // six fields
|
||||||
"60 * * * *", // minute out of range
|
"60 * * * *", // minute out of range
|
||||||
"* 24 * * *", // hour out of range
|
"* 24 * * *", // hour out of range
|
||||||
"* * 0 * *", // day-of-month below 1
|
"* * 0 * *", // day-of-month below 1
|
||||||
"* * 32 * *", // day-of-month above 31
|
"* * 32 * *", // day-of-month above 31
|
||||||
"* * * 13 *", // month out of range
|
"* * * 13 *", // month out of range
|
||||||
"* * * * 8", // day-of-week above 7
|
"* * * * 8", // day-of-week above 7
|
||||||
"a * * * *", // not a number
|
"a * * * *", // not a number
|
||||||
"*/0 * * * *", // zero step
|
"*/0 * * * *", // zero step
|
||||||
"5-1 * * * *", // inverted range
|
"5-1 * * * *", // inverted range
|
||||||
"1,,2 * * * *", // empty element
|
"1,,2 * * * *", // empty element
|
||||||
} {
|
} {
|
||||||
if _, err := ParseCron(expr); err == nil {
|
if _, err := ParseCron(expr); err == nil {
|
||||||
t.Errorf("a malformed cron %q was accepted", expr)
|
t.Errorf("a malformed cron %q was accepted", expr)
|
||||||
@@ -33,13 +33,13 @@ func TestParseCronRefusesMalformed(t *testing.T) {
|
|||||||
|
|
||||||
func TestParseCronAcceptsTheOrdinaryForms(t *testing.T) {
|
func TestParseCronAcceptsTheOrdinaryForms(t *testing.T) {
|
||||||
for _, expr := range []string{
|
for _, expr := range []string{
|
||||||
"* * * * *", // every minute
|
"* * * * *", // every minute
|
||||||
"0 3 * * *", // 03:00 daily
|
"0 3 * * *", // 03:00 daily
|
||||||
"*/15 * * * *", // every 15 minutes
|
"*/15 * * * *", // every 15 minutes
|
||||||
"0 0 1 1 *", // new year
|
"0 0 1 1 *", // new year
|
||||||
"0 9-17 * * 1-5", // business hours, weekdays
|
"0 9-17 * * 1-5", // business hours, weekdays
|
||||||
"0 0 * * 7", // Sunday as 7
|
"0 0 * * 7", // Sunday as 7
|
||||||
"0,30 * * * *", // twice an hour
|
"0,30 * * * *", // twice an hour
|
||||||
} {
|
} {
|
||||||
if _, err := ParseCron(expr); err != nil {
|
if _, err := ParseCron(expr); err != nil {
|
||||||
t.Errorf("a valid cron %q was refused: %v", expr, err)
|
t.Errorf("a valid cron %q was refused: %v", expr, err)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -60,19 +60,24 @@ const (
|
|||||||
// access is ordinary, because none of them owns it.
|
// access is ordinary, because none of them owns it.
|
||||||
TypeAccess Type = "access"
|
TypeAccess Type = "access"
|
||||||
|
|
||||||
// TypeDaemon is a long-running process the mesh keeps running, named by what it runs rather
|
// TypeProcess is the module's own code, run on the machine, in one of three modes.
|
||||||
// than by how it is hosted.
|
|
||||||
//
|
//
|
||||||
// **The intent, not the mechanism.** Until this, an author decided the hosting before they
|
// **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
|
// 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
|
// meant a `service` and a unit somebody else had to install. Same intent — run this — with
|
||||||
// it running — expressed two unrelated ways, and the choice baked into which kind was picked.
|
// 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
|
// **And three modes rather than three kinds**, exactly as a container has. A first draft of
|
||||||
// such things, writes the unit, and puts it in the state asked for. One module may declare
|
// this added a `daemon` for the long-running case alone, which would have meant a new kind for
|
||||||
// several, in different languages, because a module is one piece of software and not one
|
// each of the others — a scheduled task, a run-once migration, a health check. They are one
|
||||||
// process (novox/hq ADR 0040).
|
// thing run at different cadences, and that is a field, not a vocabulary entry. Every addition
|
||||||
TypeDaemon Type = "daemon"
|
// 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.
|
// 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
|
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
|
// 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
|
// and deliberately does not install one, which is right for software that ships its own. This is
|
||||||
// is the mesh's own code — a bundle it built — so there is no unit until the mesh writes it, and
|
// the mesh's own code — a bundle it built — so there is no unit until the mesh writes it, and
|
||||||
// nothing else will.
|
// nothing else will.
|
||||||
//
|
//
|
||||||
// The difference from Container is the hosting, and a module should not have to choose: what a
|
// The difference from Container is the hosting, and a module should not have to choose: what this
|
||||||
// daemon says is what to run, and the machine's own process supervisor is how. A module whose code
|
// says is what to run, and the machine's own supervisor is how. Code that genuinely needs a
|
||||||
// genuinely needs a container's isolation declares a container and says so.
|
// container's isolation declares a container and says so.
|
||||||
type Daemon struct {
|
//
|
||||||
|
// **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"`
|
ID string `json:"id"`
|
||||||
Type Type `json:"type"`
|
Type Type `json:"type"`
|
||||||
// Name is what the unit is called, and what an operator will see in the process table.
|
// 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
|
// configuration, so replacing a file and finding the process already up leaves the machine
|
||||||
// behaving the way it did before while every check passes.
|
// behaving the way it did before while every check passes.
|
||||||
RestartOn []string `json:"restart-on,omitempty"`
|
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 *Process) Identity() string { return d.ID }
|
||||||
func (d *Daemon) Kind() Type { return TypeDaemon }
|
func (d *Process) Kind() Type { return TypeProcess }
|
||||||
func (d *Daemon) Target() string { return d.Name }
|
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
|
var problems []string
|
||||||
if d.Name == "" {
|
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") {
|
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
|
// It becomes a unit name and a file on disk. A name with a separator in it would write
|
||||||
// somewhere nobody meant.
|
// 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")
|
"contain a path separator or a space")
|
||||||
}
|
}
|
||||||
if d.Source == "" {
|
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 {
|
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
|
// 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.
|
// mesh does not control, and a reference that can be made to point elsewhere is not one.
|
||||||
problems = append(problems, where+
|
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 {
|
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 {
|
for _, part := range d.Run {
|
||||||
if part == "" {
|
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
|
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
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,8 +780,8 @@ func newOf(t Type) Resource {
|
|||||||
return &Archive{}
|
return &Archive{}
|
||||||
case TypeAccess:
|
case TypeAccess:
|
||||||
return &Access{}
|
return &Access{}
|
||||||
case TypeDaemon:
|
case TypeProcess:
|
||||||
return &Daemon{}
|
return &Process{}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -752,8 +789,8 @@ func newOf(t Type) Resource {
|
|||||||
// Vocabulary is every kind this host speaks.
|
// Vocabulary is every kind this host speaks.
|
||||||
func Vocabulary() []Type {
|
func Vocabulary() []Type {
|
||||||
return []Type{
|
return []Type{
|
||||||
TypeAccess, TypeAction, TypeArchive, TypeContainer, TypeDaemon, TypeDirectory, TypeFile,
|
TypeAccess, TypeAction, TypeArchive, TypeContainer, TypeDirectory, TypeFile,
|
||||||
TypeNetwork, TypePackage, TypeService, TypeUser,
|
TypeNetwork, TypePackage, TypeProcess, TypeService, TypeUser,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ func TestTheVocabularyIsTheElevenShapesTheMeshNeeds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, want := range []Type{
|
for _, want := range []Type{
|
||||||
TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction,
|
TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction,
|
||||||
TypeUser, TypeArchive, TypeNetwork, TypeAccess, TypeDaemon,
|
TypeUser, TypeArchive, TypeNetwork, TypeAccess, TypeProcess,
|
||||||
} {
|
} {
|
||||||
if !speaks[want] {
|
if !speaks[want] {
|
||||||
t.Errorf("the host no longer speaks %q", want)
|
t.Errorf("the host no longer speaks %q", want)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// 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
|
// it: the mesh's own code runs as a process on the machine, and only software that
|
||||||
// genuinely needs isolation asks for a container.
|
// genuinely needs isolation asks for a container.
|
||||||
declaration.TypeDaemon,
|
declaration.TypeProcess,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user