- syncup: podman first, docker only as fallback (podman wins on name clash;
existing docker units found in podman switch to podman)
- syncup: group podman-compose containers into one `kind: stack` unit per
project (project, working_dir, compose_files, compose_bin from labels);
disable existing member units ("part of stack <project>")
- generator: compose mode (podman-compose up/stop, WorkingDirectory) with
pod start/stop fallback when compose binary/dir/files are missing
- reconcile: stacks disabled with "stack not found" when neither compose
files nor pod exist
- root: scan root + rootless users listed in `users:` (podman only)
- non-root: config ~/.config/unitdore/units.yaml, own user units only
- sysuser package: runuser with XDG_RUNTIME_DIR/DBUS env, home/linger lookup
- install: warn when linger is not enabled for user units
- testdata/stack: podman-compose test stack and captured podman ps fixture
- docs: README, generated-units, man page, AGENTS, PLAN
394 lines
10 KiB
Go
394 lines
10 KiB
Go
package systemd
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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)
|
|
}
|
|
}
|
|
|
|
func TestBuildExecCommands(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
unit config.Unit
|
|
wantStart string
|
|
wantStop string
|
|
}{
|
|
{
|
|
name: "podman",
|
|
unit: config.Unit{Name: "app", Runtime: "podman"},
|
|
wantStart: "/usr/bin/podman start -a app",
|
|
wantStop: "/usr/bin/podman stop app",
|
|
},
|
|
{
|
|
name: "docker",
|
|
unit: config.Unit{Name: "app", Runtime: "docker"},
|
|
wantStart: "/usr/bin/docker start -a app",
|
|
wantStop: "/usr/bin/docker stop app",
|
|
},
|
|
{
|
|
name: "custom command",
|
|
unit: config.Unit{Name: "app", Runtime: "podman", Command: "/bin/custom"},
|
|
wantStart: "/bin/custom",
|
|
wantStop: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
start, stop := buildExecCommands(tt.unit)
|
|
if start != tt.wantStart {
|
|
t.Errorf("start = %q, want %q", start, tt.wantStart)
|
|
}
|
|
if stop != tt.wantStop {
|
|
t.Errorf("stop = %q, want %q", stop, tt.wantStop)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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 TestStackMode(t *testing.T) {
|
|
u := newComposeStack(t)
|
|
if StackMode(u) != StackModeCompose {
|
|
t.Errorf("StackMode() = %q, want compose", StackMode(u))
|
|
}
|
|
|
|
missingFile := u
|
|
missingFile.ComposeFiles = []string{"gone.yaml"}
|
|
if StackMode(missingFile) != StackModePod {
|
|
t.Error("missing compose file should fall back to pod mode")
|
|
}
|
|
|
|
missingBin := u
|
|
missingBin.ComposeBin = ""
|
|
if StackMode(missingBin) != StackModePod {
|
|
t.Error("missing compose binary should fall back to pod mode")
|
|
}
|
|
|
|
missingDir := u
|
|
missingDir.WorkingDir = "/nonexistent/unitdore-test"
|
|
if StackMode(missingDir) != StackModePod {
|
|
t.Error("missing working dir should fall back to pod mode")
|
|
}
|
|
}
|
|
|
|
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 TestBuildComposeCommands_Quoting(t *testing.T) {
|
|
u := config.Unit{
|
|
Project: "shop",
|
|
ComposeBin: "/usr/bin/podman-compose",
|
|
ComposeFiles: []string{"compose.yaml", "my override.yaml"},
|
|
}
|
|
start, stop := buildComposeCommands(u)
|
|
wantBase := `/usr/bin/podman-compose -p shop -f compose.yaml -f "my override.yaml"`
|
|
if start != wantBase+" up" {
|
|
t.Errorf("start = %q", start)
|
|
}
|
|
if stop != wantBase+" stop" {
|
|
t.Errorf("stop = %q", stop)
|
|
}
|
|
}
|