package systemd import ( "os" "path/filepath" "strings" "testing" "time" "github.com/warkanum/unitdore/config" ) func TestServiceName(t *testing.T) { u := config.Unit{Name: "nginx"} tests := []struct { prefix, suffix, want string }{ {"", "", "unitdore-nginx.service"}, {"prod-", "", "unitdore-prod-nginx.service"}, {"", "-web", "unitdore-nginx-web.service"}, {"prod-", "-web", "unitdore-prod-nginx-web.service"}, } for _, tt := range tests { got := ServiceName(u, tt.prefix, tt.suffix) if got != tt.want { t.Errorf("ServiceName(%q, %q) = %q, want %q", tt.prefix, tt.suffix, got, tt.want) } } } func TestGenerate_SystemUnit(t *testing.T) { u := config.Unit{ Name: "nginx", Runtime: "podman", Order: 1, Enabled: true, } content, err := Generate(u, "", "") if err != nil { t.Fatalf("Generate() error: %v", err) } checks := []string{ "[Unit]", "[Service]", "[Install]", "unitdore-nginx.service", "Description=Unitdore: nginx (podman)", "After=network.target", "WantedBy=multi-user.target", "ExecStart=/usr/bin/podman start -a nginx", "ExecStop=-/usr/bin/podman stop nginx", "Generated by unitdore", } for _, check := range checks { if !strings.Contains(content, check) { t.Errorf("Generate() missing %q in output:\n%s", check, content) } } if strings.Contains(content, "User=") { t.Errorf("Generate() system unit should not contain User= directive:\n%s", content) } if strings.Contains(content, "Requires=") { t.Errorf("Generate() podman system unit should not contain Requires= (podman is daemonless):\n%s", content) } if strings.Contains(content, "Restart=") { t.Errorf("Generate() restart should be disabled by default:\n%s", content) } } func TestGenerate_UserUnit(t *testing.T) { u := config.Unit{ Name: "myapp", Runtime: "docker", User: "hein", Order: 2, Enabled: true, } content, err := Generate(u, "", "") if err != nil { t.Fatalf("Generate() error: %v", err) } checks := []string{ "After=default.target", "WantedBy=default.target", "ExecStart=/usr/bin/docker start -a myapp", "ExecStop=-/usr/bin/docker stop myapp", } for _, check := range checks { if !strings.Contains(content, check) { t.Errorf("Generate() user unit missing %q in output:\n%s", check, content) } } if strings.Contains(content, "WantedBy=multi-user.target") { t.Error("Generate() user unit should not contain multi-user.target") } } func TestGenerate_WithRestart(t *testing.T) { t.Run("restart with retries", func(t *testing.T) { u := config.Unit{ Name: "nginx", Runtime: "podman", Enabled: true, Restart: true, RestartSec: 5, RestartRetries: 3, } content, err := Generate(u, "", "") if err != nil { t.Fatalf("Generate() error: %v", err) } for _, want := range []string{"Restart=on-failure", "RestartSec=5", "StartLimitBurst=3"} { if !strings.Contains(content, want) { t.Errorf("Generate() missing %q in output:\n%s", want, content) } } }) t.Run("restart without retries", func(t *testing.T) { u := config.Unit{ Name: "nginx", Runtime: "podman", Enabled: true, Restart: true, RestartSec: 10, } content, err := Generate(u, "", "") if err != nil { t.Fatalf("Generate() error: %v", err) } if !strings.Contains(content, "Restart=on-failure") { t.Errorf("Generate() missing Restart=on-failure:\n%s", content) } if !strings.Contains(content, "RestartSec=10") { t.Errorf("Generate() missing RestartSec=10:\n%s", content) } if strings.Contains(content, "StartLimitBurst") { t.Errorf("Generate() should not contain StartLimitBurst when retries=0:\n%s", content) } }) } func TestGenerate_CustomCommand(t *testing.T) { u := config.Unit{ Name: "custom", Runtime: "podman", Command: "/usr/local/bin/mystart.sh", Enabled: true, } content, err := Generate(u, "", "") if err != nil { t.Fatalf("Generate() error: %v", err) } if !strings.Contains(content, "ExecStart=/usr/local/bin/mystart.sh") { t.Errorf("Generate() should use custom command, got:\n%s", content) } } func TestGenerate_DockerRuntime(t *testing.T) { u := config.Unit{ Name: "redis", Runtime: "docker", Enabled: true, } content, err := Generate(u, "", "") if err != nil { t.Fatalf("Generate() error: %v", err) } checks := []string{ "ExecStart=/usr/bin/docker start -a redis", "ExecStop=-/usr/bin/docker stop redis", "After=network.target docker.service", "Requires=docker.service", } for _, check := range checks { if !strings.Contains(content, check) { t.Errorf("Generate() docker runtime missing %q in output:\n%s", check, content) } } } func TestGenerate_UnknownRuntime(t *testing.T) { u := config.Unit{ Name: "weird", Runtime: "containerd", Enabled: true, } content, err := Generate(u, "", "") if err != nil { t.Fatalf("Generate() should not error on unknown runtime: %v", err) } if !strings.Contains(content, "/usr/bin/containerd start weird") { t.Errorf("Generate() should fall back to /usr/bin/ for unknown runtimes:\n%s", content) } } func TestGenerate_WithPrefixSuffix(t *testing.T) { u := config.Unit{ Name: "nginx", Runtime: "podman", Enabled: true, } content, err := Generate(u, "prod-", "-web") if err != nil { t.Fatalf("Generate() error: %v", err) } if !strings.Contains(content, "unitdore-prod-nginx-web.service") { t.Errorf("Generate() prefix+suffix not reflected in service name:\n%s", content) } } // newComposeStack creates a stack unit whose compose binary, working dir and // compose file exist on disk (compose mode). func newComposeStack(t *testing.T) config.Unit { t.Helper() dir := t.TempDir() bin := filepath.Join(dir, "podman-compose") if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte("services: {}\n"), 0644); err != nil { t.Fatal(err) } return config.Unit{ Name: "shop", Runtime: "podman", Kind: config.KindStack, Project: "shop", WorkingDir: dir, ComposeFiles: []string{"compose.yaml"}, ComposeBin: bin, Enabled: true, } } func TestGenerate_StackCompose(t *testing.T) { u := newComposeStack(t) content, err := Generate(u, "", "") if err != nil { t.Fatalf("Generate() error: %v", err) } base := u.ComposeBin + " -p shop -f compose.yaml" for _, want := range []string{ "Description=Unitdore: shop (podman-compose stack)", "Type=simple", "WorkingDirectory=" + u.WorkingDir, "ExecStart=" + base + " up", "ExecStop=-" + base + " stop", "WantedBy=multi-user.target", } { if !strings.Contains(content, want) { t.Errorf("Generate() missing %q in output:\n%s", want, content) } } if strings.Contains(content, "RemainAfterExit") || strings.Contains(content, "Requires=") { t.Errorf("compose stack unit has unexpected directives:\n%s", content) } } func TestGenerate_StackPodFallback(t *testing.T) { u := newComposeStack(t) u.ComposeBin = "" u.User = "hein" content, err := Generate(u, "", "") if err != nil { t.Fatalf("Generate() error: %v", err) } for _, want := range []string{ "Description=Unitdore: shop (podman pod)", "Type=oneshot", "RemainAfterExit=yes", "ExecStart=/usr/bin/podman pod start pod_shop", "ExecStop=-/usr/bin/podman pod stop pod_shop", "WantedBy=default.target", } { if !strings.Contains(content, want) { t.Errorf("Generate() missing %q in output:\n%s", want, content) } } if strings.Contains(content, "WorkingDirectory=") { t.Errorf("pod fallback should not set WorkingDirectory:\n%s", content) } } func TestGenerate_StackCustomCommand(t *testing.T) { u := newComposeStack(t) u.Command = "/usr/local/bin/stack.sh" content, err := Generate(u, "", "") if err != nil { t.Fatalf("Generate() error: %v", err) } if !strings.Contains(content, "ExecStart=/usr/local/bin/stack.sh") { t.Errorf("custom command should override stack commands:\n%s", content) } } func TestGenerate_Script(t *testing.T) { u := config.Unit{ Name: "backup", Runtime: config.RuntimeScript, Script: "/opt/scripts/backup.sh", StopScript: "/opt/scripts/backup-stop.sh", WorkingDir: "/opt/scripts", Enabled: true, } content, err := Generate(u, "", "") if err != nil { t.Fatalf("Generate() error: %v", err) } checks := []string{ "Description=Unitdore: backup (bash script)", "Type=simple", "WorkingDirectory=/opt/scripts", "ExecStart=/bin/bash /opt/scripts/backup.sh", "ExecStop=-/bin/bash /opt/scripts/backup-stop.sh", } for _, check := range checks { if !strings.Contains(content, check) { t.Errorf("Generate() script missing %q in output:\n%s", check, content) } } if strings.Contains(content, "Requires=") { t.Errorf("Generate() script unit should not contain Requires=:\n%s", content) } } func TestGenerate_ScriptOneshotUserQuoted(t *testing.T) { u := config.Unit{ Name: "setup", Runtime: config.RuntimeScript, User: "alice", Script: "/home/alice/my scripts/setup.sh", ServiceType: "oneshot", Enabled: true, } content, err := Generate(u, "", "") if err != nil { t.Fatalf("Generate() error: %v", err) } checks := []string{ "Type=oneshot", "RemainAfterExit=yes", `ExecStart=/bin/bash "/home/alice/my scripts/setup.sh"`, "WantedBy=default.target", } for _, check := range checks { if !strings.Contains(content, check) { t.Errorf("Generate() missing %q in output:\n%s", check, content) } } if strings.Contains(content, "ExecStop=-") { t.Errorf("Generate() should have no stop command without stop_script:\n%s", content) } } func TestGenerate_ScriptMissingPath(t *testing.T) { u := config.Unit{Name: "bad", Runtime: config.RuntimeScript, Enabled: true} if _, err := Generate(u, "", ""); err == nil { t.Error("Generate() should error when script path is empty") } } func TestGenerate_ScriptCustomShell(t *testing.T) { u := config.Unit{ Name: "fishy", Runtime: config.RuntimeScript, Shell: "/usr/bin/zsh", Script: "/opt/run.zsh", Enabled: true, } content, err := Generate(u, "", "") if err != nil { t.Fatalf("Generate() error: %v", err) } for _, check := range []string{"Description=Unitdore: fishy (zsh script)", "ExecStart=/usr/bin/zsh /opt/run.zsh"} { if !strings.Contains(content, check) { t.Errorf("Generate() missing %q in output:\n%s", check, content) } } } func TestGenerate_Executable(t *testing.T) { u := config.Unit{ Name: "agent", Runtime: config.RuntimeExec, Executable: "/opt/agent/bin/agent", Args: []string{"--config", "/etc/my agent.toml"}, StopExecutable: "/opt/agent/bin/agent-stop", WorkingDir: "/opt/agent", ServiceType: "oneshot", Enabled: true, } content, err := Generate(u, "", "") if err != nil { t.Fatalf("Generate() error: %v", err) } checks := []string{ "Description=Unitdore: agent (executable)", "Type=oneshot", "WorkingDirectory=/opt/agent", `ExecStart=/opt/agent/bin/agent --config "/etc/my agent.toml"`, "ExecStop=-/opt/agent/bin/agent-stop", } for _, check := range checks { if !strings.Contains(content, check) { t.Errorf("Generate() missing %q in output:\n%s", check, content) } } } func TestGenerate_ExecutableMissingPath(t *testing.T) { u := config.Unit{Name: "bad", Runtime: config.RuntimeExec, Enabled: true} if _, err := Generate(u, "", ""); err == nil { t.Error("Generate() should error when executable is empty") } } func TestGenerate_OrderAndDelay(t *testing.T) { u := config.Unit{Name: "web", Runtime: "podman", Enabled: true, After: []string{"db", "cache"}, StartDelay: 1500 * time.Millisecond} content, err := Generate(u, "p-", "") if err != nil { t.Fatal(err) } for _, want := range []string{ "After=network.target unitdore-p-db.service unitdore-p-cache.service\n", "ExecStartPre=/usr/bin/env sleep 2\n", "TimeoutStartSec=92\n", } { if !strings.Contains(content, want) { t.Errorf("missing %q in:\n%s", want, content) } } } func TestGenerate_UserUnitOrder(t *testing.T) { u := config.Unit{Name: "web", Runtime: "podman", User: "hein", Enabled: true, After: []string{"db"}} content, err := Generate(u, "", "") if err != nil { t.Fatal(err) } if !strings.Contains(content, "After=default.target unitdore-db.service\n") { t.Errorf("user unit missing ordering:\n%s", content) } if strings.Contains(content, "ExecStartPre") { t.Errorf("no delay expected:\n%s", content) } } func TestGenerate_NoOrderNoExtras(t *testing.T) { content, err := Generate(config.Unit{Name: "a", Runtime: "podman", Enabled: true}, "", "") if err != nil { t.Fatal(err) } if strings.Contains(content, "ExecStartPre") || strings.Contains(content, "TimeoutStartSec") || !strings.Contains(content, "After=network.target\n") { t.Errorf("unexpected ordering output:\n%s", content) } }