Found by being asked whether processes and containers handle environment the
same way. They do not, and the difference is not cosmetic.
Docker passes --env through literally. A unit file reads three things out of a
value that nothing else does, and a module's environment routinely contains all
three because a generated password is arbitrary bytes:
- % begins a specifier. %H is the hostname. A password containing one is
silently replaced, and it fails later as an authentication error nobody can
explain by reading the declaration.
- whitespace separates assignments. Unquoted, K=a b sets K to "a" and reads
"b" as another assignment.
- a newline ends the line, and what follows is read as a unit DIRECTIVE.
The first two are escaped: quoted, with quotes and backslashes escaped and
percent doubled. The third cannot be — a unit's environment has no way to carry
a line break — so it is refused in validation, near whoever wrote it. Without
that, an environment value could write ExecStart= and have the machine run
something nobody declared.
Ordinary awkward values stay accepted, because refusing those too would leave a
module unable to hold a generated password.
Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
166 lines
6.2 KiB
Go
166 lines
6.2 KiB
Go
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)
|
|
}
|
|
}
|
|
|
|
// **A unit file reinterprets a value in three ways nothing else does**, and a module's environment
|
|
// routinely contains all three — a generated password is arbitrary bytes.
|
|
func TestAnEnvironmentValueMeansWhatTheDeclarationSaid(t *testing.T) {
|
|
// A percent begins a specifier: %H is the hostname. A password containing one would be
|
|
// silently replaced, failing as an authentication error nobody can explain from the
|
|
// declaration.
|
|
percent := aProcess()
|
|
percent.Env = map[string]string{"PASSWORD": "a%Hb"}
|
|
if !strings.Contains(unitFor(percent), "%%H") {
|
|
t.Fatalf("a percent was left as a systemd specifier:\n%s", unitFor(percent))
|
|
}
|
|
|
|
// Whitespace separates assignments: unquoted, K=a b sets K to "a" and reads "b" as another.
|
|
spaced := aProcess()
|
|
spaced.Env = map[string]string{"GREETING": "hello there"}
|
|
if !strings.Contains(unitFor(spaced), `"GREETING=hello there"`) {
|
|
t.Fatalf("a value with a space was not quoted:\n%s", unitFor(spaced))
|
|
}
|
|
|
|
// A quote would end the quoting early, and what follows would be read as unit syntax.
|
|
quoted := aProcess()
|
|
quoted.Env = map[string]string{"TOKEN": `a"b`}
|
|
line := ""
|
|
for _, l := range strings.Split(unitFor(quoted), "\n") {
|
|
if strings.HasPrefix(l, "Environment=") {
|
|
line = l
|
|
}
|
|
}
|
|
if !strings.Contains(line, `\"`) {
|
|
t.Fatalf("a quote was not escaped, so the value ends early: %s", line)
|
|
}
|
|
}
|