package runit import ( "os" "path/filepath" "strings" "testing" "time" "github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/service" ) // setup points the backend at temp dirs with recorded commands. func setup(t *testing.T) (b Backend, calls *[]string) { t.Helper() oldSv, oldRun, oldOut, oldRoot, oldWait := svDir, runCmd, outCmd, service.IsRoot, superviseWait t.Cleanup(func() { svDir, runCmd, outCmd, service.IsRoot, superviseWait = oldSv, oldRun, oldOut, oldRoot, oldWait }) svDir = t.TempDir() superviseWait = 300 * time.Millisecond service.IsRoot = func() bool { return true } var recorded []string runCmd = func(name string, args ...string) error { recorded = append(recorded, name+" "+strings.Join(args, " ")) return nil } return Backend{ServiceDir: t.TempDir()}, &recorded } func mustContain(t *testing.T, content string, wants ...string) { t.Helper() for _, w := range wants { if !strings.Contains(content, w) { t.Errorf("missing %q in:\n%s", w, content) } } } func read(t *testing.T, path string) string { t.Helper() b, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(b) } func TestGenerate_Container(t *testing.T) { b, _ := setup(t) u := config.Unit{Name: "nginx", Runtime: "podman"} got, err := b.Generate(u, "", "") if err != nil { t.Fatal(err) } mustContain(t, got, "===== "+filepath.Join(svDir, "unitdore-nginx", "run")+" =====", "exec 2>&1", "exec /usr/bin/podman start -a nginx", "===== "+filepath.Join(svDir, "unitdore-nginx", "finish")+" =====", "/usr/bin/podman stop nginx || true", "exec sv down .", ) } func TestGenerate_RestartPolicy(t *testing.T) { b, _ := setup(t) u := config.Unit{Name: "app", Runtime: "podman", Restart: true, RestartSec: 7} got, err := b.Generate(u, "", "") if err != nil { t.Fatal(err) } mustContain(t, got, `[ "$1" = 0 ] && exec sv down .`, "sleep 7") if strings.Contains(got, "\nexec sv down .\n") { t.Errorf("restarting unit must not always go down:\n%s", got) } } func TestGenerate_ScriptExecAndWorkingDir(t *testing.T) { b, _ := setup(t) u := config.Unit{ Name: "agent", Runtime: config.RuntimeExec, Executable: "/opt/agent", Args: []string{"--config", "/etc/my agent.toml"}, WorkingDir: "/opt", } got, err := b.Generate(u, "", "") if err != nil { t.Fatal(err) } mustContain(t, got, "cd /opt || exit 1", `exec /opt/agent --config '/etc/my agent.toml'`) } func TestGenerate_Oneshot(t *testing.T) { b, _ := setup(t) u := config.Unit{Name: "shop", Runtime: "podman", Kind: config.KindStack, Project: "shop"} got, err := b.Generate(u, "", "") if err != nil { t.Fatal(err) } mustContain(t, got, "/usr/bin/podman pod start pod_shop || exit 1\nexec tail -f /dev/null", "/usr/bin/podman pod stop pod_shop || true", ) } func TestGenerate_RootlessUser(t *testing.T) { b, _ := setup(t) u := config.Unit{Name: "app", Runtime: "podman", User: "root"} got, err := b.Generate(u, "", "") if err != nil { t.Fatal(err) } mustContain(t, got, "[ -d /run/user/0 ] || install -d -m 0700 -o root /run/user/0", "export HOME=/root", "export XDG_RUNTIME_DIR=/run/user/0", "exec chpst -u root /usr/bin/podman start -a app", "chpst -u root /usr/bin/podman stop app || true", ) } func TestGenerate_Invalid(t *testing.T) { b, _ := setup(t) if _, err := b.Generate(config.Unit{Name: "x", Runtime: config.RuntimeScript}, "", ""); err == nil { t.Error("expected validation error") } } func TestInstall_WritesDirLinkAndDown(t *testing.T) { b, _ := setup(t) u := config.Unit{Name: "nginx", Runtime: "podman"} dir := filepath.Join(svDir, "unitdore-nginx") link := filepath.Join(b.ServiceDir, "unitdore-nginx") if b.IsInstalled(u, "", "") { t.Fatal("should not be installed yet") } if err := b.Install(u, "", ""); err != nil { t.Fatal(err) } for _, name := range []string{"run", "finish"} { fi, err := os.Stat(filepath.Join(dir, name)) if err != nil || fi.Mode().Perm()&0111 == 0 { t.Errorf("%s missing or not executable: %v", name, err) } } if _, err := os.Stat(filepath.Join(dir, "down")); err != nil { t.Error("fresh install must write a down file") } if target, err := os.Readlink(link); err != nil || target != dir { t.Errorf("link = %q, %v", target, err) } if !b.IsInstalled(u, "", "") { t.Error("should be installed") } } func TestInstall_ReinstallKeepsEnabled(t *testing.T) { b, _ := setup(t) u := config.Unit{Name: "nginx", Runtime: "podman"} dir := filepath.Join(svDir, "unitdore-nginx") if err := b.Install(u, "", ""); err != nil { t.Fatal(err) } if err := b.Enable(u, "", ""); err == nil { t.Log("enable ok") // may time out waiting for supervise; down file still removed } if _, err := os.Stat(filepath.Join(dir, "down")); !os.IsNotExist(err) { t.Fatal("enable must remove the down file") } u.Restart = true if err := b.Install(u, "", ""); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(dir, "down")); !os.IsNotExist(err) { t.Error("re-install must keep an enabled service enabled") } if !strings.Contains(read(t, filepath.Join(dir, "finish")), `"$1" = 0`) { t.Error("re-install must rewrite scripts") } } func TestDisable_WritesDownAndStops(t *testing.T) { b, calls := setup(t) u := config.Unit{Name: "nginx", Runtime: "podman"} dir := filepath.Join(svDir, "unitdore-nginx") if err := b.Install(u, "", ""); err != nil { t.Fatal(err) } *calls = nil if err := b.Disable(u, "", ""); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(dir, "down")); err != nil { t.Error("disable must write a down file") } if got := strings.Join(*calls, "|"); got != "sv stop "+filepath.Join(b.ServiceDir, "unitdore-nginx") { t.Errorf("calls = %q", got) } } func TestStartStop_UseSv(t *testing.T) { b, calls := setup(t) u := config.Unit{Name: "nginx", Runtime: "podman"} link := filepath.Join(b.ServiceDir, "unitdore-nginx") if err := b.Install(u, "", ""); err != nil { t.Fatal(err) } // Not supervised yet: Start must give up with a clear error if err := b.Start(u, "", ""); err == nil || !strings.Contains(err.Error(), "not supervised") { t.Errorf("Start() err = %v", err) } if err := os.MkdirAll(filepath.Join(link, "supervise"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(link, "supervise", "ok"), nil, 0644); err != nil { t.Fatal(err) } *calls = nil if err := b.Start(u, "", ""); err != nil { t.Fatal(err) } if err := b.Stop(u, "", ""); err != nil { t.Fatal(err) } if got := strings.Join(*calls, "|"); got != "sv start "+link+"|sv stop "+link { t.Errorf("calls = %q", got) } } func TestUninstall_RemovesDirAndLink(t *testing.T) { b, calls := setup(t) u := config.Unit{Name: "nginx", Runtime: "podman"} if err := b.Install(u, "", ""); err != nil { t.Fatal(err) } *calls = nil if err := b.Uninstall(u, "", ""); err != nil { t.Fatal(err) } if b.IsInstalled(u, "", "") { t.Error("should be uninstalled") } if _, err := os.Lstat(filepath.Join(b.ServiceDir, "unitdore-nginx")); !os.IsNotExist(err) { t.Error("link should be gone") } if len(*calls) != 1 || !strings.HasPrefix((*calls)[0], "sv stop ") { t.Errorf("calls = %q", *calls) } } func TestServiceDirAutodetect(t *testing.T) { old := serviceDirCandidates t.Cleanup(func() { serviceDirCandidates = old }) existing := t.TempDir() serviceDirCandidates = []string{filepath.Join(existing, "nope"), existing} if got := (Backend{}).serviceDir(); got != existing { t.Errorf("serviceDir() = %q, want %q", got, existing) } if got := (Backend{ServiceDir: "/custom"}).serviceDir(); got != "/custom" { t.Errorf("explicit serviceDir() = %q", got) } } func TestRequiresRoot(t *testing.T) { b, _ := setup(t) service.IsRoot = func() bool { return false } u := config.Unit{Name: "nginx", Runtime: "podman"} for name, err := range map[string]error{ "install": b.Install(u, "", ""), "uninstall": b.Uninstall(u, "", ""), "start": b.Start(u, "", ""), "stop": b.Stop(u, "", ""), "enable": b.Enable(u, "", ""), "disable": b.Disable(u, "", ""), } { if err == nil { t.Errorf("%s should require root", name) } } } func TestParseStatus(t *testing.T) { tests := map[string]string{ "run: /var/service/x: (pid 123) 5s": "active", "down: /var/service/x: 3s, normally up": "inactive", "finish: /var/service/x: (pid 9) 1s": "deactivating", "fail: /var/service/x: runsv not running": "inactive", "warning: weird": "unknown", } for in, want := range tests { if got := parseStatus(in); got != want { t.Errorf("parseStatus(%q) = %q, want %q", in, got, want) } } } func TestGenerate_OrderAndDelay(t *testing.T) { b, _ := setup(t) u := config.Unit{Name: "web", Runtime: "podman", Enabled: true, After: []string{"db"}, StartDelay: 3 * time.Second} got, err := b.Generate(u, "", "") if err != nil { t.Fatal(err) } link := filepath.Join(b.ServiceDir, "unitdore-db") wait := "sv -w 30 check " + link + " >/dev/null 2>&1 || true\nsleep 3\n" if !strings.Contains(got, wait) { t.Errorf("missing %q in:\n%s", wait, got) } // waiting happens before the service command runs if strings.Index(got, "sleep 3") > strings.Index(got, "exec /usr/bin/podman") { t.Errorf("delay must precede exec:\n%s", got) } } func TestGenerate_NoOrderNoExtras(t *testing.T) { b, _ := setup(t) got, err := b.Generate(config.Unit{Name: "a", Runtime: "podman", Enabled: true}, "", "") if err != nil { t.Fatal(err) } if strings.Contains(got, "sv -w") || strings.Contains(got, "sleep") { t.Errorf("unexpected ordering output:\n%s", got) } }