Files
mesh-host/internal/apply/refused_directory_test.go
T
jschoubben 4695dfc0a2
mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Say both a gated module and a machine stop, and count skipped apart (review of #83)
A module-less action that stopped the apply after a module was gated was reported as
everything else attempted. The failure now names the gated modules and the stop, counts
skipped resources apart from applied ones, and the tests cover an archive below a refused
path, a gated module tried again by the next apply, and a refused file among the failures.
2026-10-11 18:51:04 +02:00

223 lines
9.8 KiB
Go

package apply
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-host/internal/store"
)
// A refused directory gates its module, and a file is judged by the same guard as a directory (novox/hq
// issue 495).
//
// The shape it was found in, on a server during mesh-catalog #205's walk (issue 452): the docker module
// declared a directory at Docker's data root, a file below it, and a run-once step after both. The engine
// refused the directory as not placed and said nothing was touched, then wrote the file into the very tree
// it had refused and ran the step.
// aRuntimeRoot makes a temporary directory the container runtime's data root, as its daemon.json says, so
// the guard refuses it as it refused /var/lib/docker.
func aRuntimeRoot(t *testing.T) string {
t.Helper()
dir := t.TempDir()
root := filepath.Join(dir, "docker")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
daemon := filepath.Join(dir, "daemon.json")
if err := os.WriteFile(daemon, []byte(`{"data-root":"`+root+`"}`), 0o644); err != nil {
t.Fatal(err)
}
before := runtimeConfigs
runtimeConfigs = runtimeFiles{daemonJSON: daemon, storageConf: filepath.Join(dir, "none.conf")}
t.Cleanup(func() {
runtimeConfigs = before
runtimeRootsMu.Lock()
runtimeRoots = nil
runtimeRootsMu.Unlock()
})
return root
}
func TestARefusedDirectoryGatesItsModule(t *testing.T) {
root := aRuntimeRoot(t)
b := newStepBed(t)
elsewhere := filepath.Join(b.dir, "other.conf")
report, _, err := Apply(context.Background(), archHost(t), declare(t,
`{"id":"mod.state","type":"directory","path":"`+root+`"},`+
`{"id":"mod.rotation","type":"file","path":"`+root+`/log-rotation.conf","content":"max-size=10m\n"},`+
`{"id":"mod.check","type":"process","name":"mod-check","source":"`+b.body+`","digest":"`+b.digest+`",`+
`"run":["./check"],"run-once":true,"restart-on":["mod.rotation"]},`+
`{"id":"other.conf","type":"file","path":"`+elsewhere+`","content":"kept apart\n"}`),
store.State{}, store.OriginDeclared, b.run, nil, nil)
var refusal *PlacementRefusedError
if err == nil || !errors.As(err, &refusal) {
t.Fatalf("the refused directory was not reported as refused: %v", err)
}
if _, statErr := os.Stat(filepath.Join(root, "log-rotation.conf")); statErr == nil {
t.Error("a file was written below the directory the engine had just refused")
}
if b.starts != 0 {
t.Errorf("the module's step ran %d time(s) after its directory was refused", b.starts)
}
for _, id := range []string{"mod.rotation", "mod.check"} {
o := outcomeOf(report, id)
if o.Action != "skipped" || !strings.Contains(o.Detail, root) {
t.Errorf("%s is not reported as skipped behind the refused directory: %+v", id, o)
}
}
// The gate reaches the module, not the machine.
if got, readErr := os.ReadFile(elsewhere); readErr != nil || string(got) != "kept apart\n" {
t.Errorf("another module's file was not applied: %q, %v", got, readErr)
}
// And the report says truthfully what was not attempted.
msg := err.Error()
if strings.Contains(msg, "everything was attempted") || !strings.Contains(msg, "the rest of mod") {
t.Errorf("the report does not say the rest of the module was not attempted: %s", msg)
}
}
// A file is judged as a directory is: refused in a tree nothing is placed in, and below a directory the
// engine refused, whichever module declares it and in whatever order.
func TestAFileIsJudgedByTheSameGuardAsADirectory(t *testing.T) {
root := aRuntimeRoot(t)
_, _, err := applyOnce(t, `{"id":"notes.conf","type":"file","path":"`+root+`/notes.conf","content":"x\n"}`,
nil, store.State{})
refusedFor339(t, "a file in the container runtime's data", err)
if _, statErr := os.Stat(filepath.Join(root, "notes.conf")); statErr == nil {
t.Error("a file was written into the container runtime's data")
}
// Below a directory refused for another reason (a home's directory declared root's), declared before it.
dir := t.TempDir()
home := filepath.Join(dir, "home", "alice")
if err := os.MkdirAll(home, 0o755); err != nil {
t.Fatal(err)
}
passwd := filepath.Join(dir, "passwd")
if err := os.WriteFile(passwd, []byte("alice:x:4242:4242::"+home+":/bin/sh\n"), 0o644); err != nil {
t.Fatal(err)
}
before := passwdFile
passwdFile = passwd
t.Cleanup(func() { passwdFile = before })
if err := os.MkdirAll(filepath.Join(home, "data"), 0o755); err != nil {
t.Fatal(err)
}
report, _, err := applyOnce(t,
`{"id":"notes.conf","type":"file","path":"`+home+`/data/notes.conf","content":"x\n"},`+
`{"id":"notes.data","type":"directory","path":"`+home+`/data"}`, nil, store.State{})
refusedFor339(t, "a file below a refused directory", err)
if _, statErr := os.Stat(filepath.Join(home, "data", "notes.conf")); statErr == nil {
t.Error("a file was written below a directory the engine refused")
}
// A refused file has no outcome: it is a failure. Declared first, it is the failure the apply reports,
// and the directory the other.
var applyErr *Error
if !errors.As(err, &applyErr) || applyErr.Resource != "notes.conf" || applyErr.Others != 1 {
t.Errorf("the file below a refused directory is not among the failures: %v", err)
}
if o := outcomeOf(report, "notes.conf"); o.ID != "" {
t.Errorf("a refused file has an outcome as if applied: %+v", o)
}
}
// An archive below a refused path is refused with it, whichever module declares it and in whatever order.
func TestAnArchiveBelowARefusedPathIsRefused(t *testing.T) {
dir := t.TempDir()
home := filepath.Join(dir, "home", "alice")
if err := os.MkdirAll(home, 0o755); err != nil {
t.Fatal(err)
}
passwd := filepath.Join(dir, "passwd")
if err := os.WriteFile(passwd, []byte("alice:x:4242:4242::"+home+":/bin/sh\n"), 0o644); err != nil {
t.Fatal(err)
}
before := passwdFile
passwdFile = passwd
t.Cleanup(func() { passwdFile = before })
body, digest := anArchive(t, map[string]string{"theme.css": "x\n"})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(body) }))
t.Cleanup(server.Close)
_, _, err := applyOnce(t,
`{"id":"theme.files","type":"archive","path":"`+home+`/data/theme","source":"`+server.URL+`/t.tar.gz",`+
`"digest":"`+digest+`"},`+
`{"id":"notes.data","type":"directory","path":"`+home+`/data"}`, nil, store.State{})
refusedFor339(t, "an archive below a refused directory", err)
var applyErr *Error
if !errors.As(err, &applyErr) || applyErr.Resource != "theme.files" || applyErr.Others != 1 {
t.Errorf("the archive below a refused directory is not among the failures: %v", err)
}
if _, statErr := os.Stat(filepath.Join(home, "data")); statErr == nil {
t.Error("an archive was unpacked below a directory the engine refused")
}
}
// Gated is per apply: a module gated behind its refused directory is tried again by the next apply, gated
// again while the directory is refused, and applied whole once it is not.
func TestAGatedModuleIsTriedAgainByTheNextApply(t *testing.T) {
root := aRuntimeRoot(t)
b := newStepBed(t)
declared := func() string {
return `{"id":"mod.state","type":"directory","path":"` + root + `"},` +
`{"id":"mod.rotation","type":"file","path":"` + root + `/log-rotation.conf","content":"max-size=10m\n"},` +
`{"id":"mod.check","type":"process","name":"mod-check","source":"` + b.body + `","digest":"` + b.digest + `",` +
`"run":["./check"],"run-once":true,"restart-on":["mod.rotation"]}`
}
apply := func(known store.State) (Report, store.State, error) {
return Apply(context.Background(), archHost(t), declare(t, declared()), known, store.OriginDeclared, b.run, nil, nil)
}
report, known, err := apply(store.State{})
if err == nil || !skippedIn(report, "mod.check") || b.starts != 0 {
t.Fatalf("apply 1: err %v, skipped %v, starts %d", err, skippedIn(report, "mod.check"), b.starts)
}
report, known, err = apply(known)
if err == nil || !skippedIn(report, "mod.check") || b.starts != 0 {
t.Fatalf("apply 2, still refused: err %v, skipped %v, starts %d", err, skippedIn(report, "mod.check"), b.starts)
}
// The runtime moves its data elsewhere: the directory is no longer refused, and the module is applied.
elsewhere := filepath.Join(t.TempDir(), "docker")
if err := os.WriteFile(runtimeConfigs.daemonJSON, []byte(`{"data-root":"`+elsewhere+`"}`), 0o644); err != nil {
t.Fatal(err)
}
report, _, err = apply(known)
if err != nil || skippedIn(report, "mod.check") || b.starts != 1 {
t.Fatalf("apply 3, no longer refused: err %v, skipped %v, starts %d", err, skippedIn(report, "mod.check"), b.starts)
}
if _, statErr := os.Stat(filepath.Join(root, "log-rotation.conf")); statErr != nil {
t.Errorf("the module's file was not written once its directory was placed: %v", statErr)
}
}
// A module gated behind its refused directory, then a step of no module that stops the machine: the report
// names both, and says nothing after the step was attempted (novox/hq issue 495).
func TestAMachineStopAfterAGatedModuleSaysBoth(t *testing.T) {
root := aRuntimeRoot(t)
after := filepath.Join(t.TempDir(), "after.conf")
_, _, err := Apply(context.Background(), archHost(t), parseTrusted(t, `{"declaration":1,"resources":[
{"id":"mod.state","type":"directory","path":"`+root+`"},
{"id":"gate","type":"action","command":["false"],"verify":["false"]},
{"id":"other.after","type":"file","path":"`+after+`","content":"b\n"}
]}`), store.State{}, store.OriginCarried, ExecRunner, nil, nil)
var applyErr *Error
if !errors.As(err, &applyErr) || !applyErr.Stopped {
t.Fatalf("the failed action did not stop the apply: %v", err)
}
if _, statErr := os.Stat(after); statErr == nil {
t.Error("the apply continued past a failed action")
}
msg := err.Error()
if !strings.Contains(msg, "the rest of mod") || !strings.Contains(msg, "nothing after it was attempted") ||
strings.Contains(msg, "everything else was attempted") {
t.Errorf("the report does not say both what was gated and that the machine stopped: %s", msg)
}
}