feat: podman-compose stack units and root/user execution scopes

- 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
This commit is contained in:
2026-10-12 19:05:51 +02:00
parent cc4829046d
commit f0827fcdd7
26 changed files with 1397 additions and 122 deletions
+128
View File
@@ -1,6 +1,8 @@
package systemd
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -263,3 +265,129 @@ func TestBuildExecCommands(t *testing.T) {
})
}
}
// 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)
}
}