A failed resource of a module gates the rest of that module, which is reported skipped with the failed resource named; the module's result names each one left. A resource of no module, and other modules, go on as before. One table test replaces two in apply_test.go; two fixtures adapted to the rule. Builds: design 42 zfs-owns-zfs §1 (ADR 0679 §16 (a)), A-22
223 lines
9.9 KiB
Go
223 lines
9.9 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 after it is left unapplied, as the rest of its module is (design 42 zfs-owns-zfs §1).
|
|
var applyErr *Error
|
|
if !errors.As(err, &applyErr) || applyErr.Resource != "notes.conf" || applyErr.Others != 0 {
|
|
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)
|
|
}
|
|
}
|