- install/uninstall/start/stop/enable take <name> or --all; *all kept as shortcuts - add enable (enable at boot + start); startall no longer enables - order/delay resolved by config.ResolveOrder and written into systemd, OpenRC and runit service files (After=/sleep), so they apply at boot - install --all stops and disables units before removing their files - update regenerates all installed, enabled units and removes disabled ones - edit creates the config dir; docs and man page updated - tests: fake Init for cmd, ResolveOrder, backend ordering output
325 lines
9.4 KiB
Go
325 lines
9.4 KiB
Go
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)
|
|
}
|
|
}
|