Files
unitdore/systemd/generator_test.go
T
warkanum 91811b4fa1 feat: order/delay in service files, enable cmd, --all on unit commands
- 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
2026-10-12 21:31:41 +02:00

482 lines
13 KiB
Go

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/<runtime> 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)
}
}