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
+80 -2
View File
@@ -1,6 +1,8 @@
package runtime
import (
"os"
"reflect"
"testing"
)
@@ -53,7 +55,7 @@ func TestAvailable_ReturnsRuntimes(t *testing.T) {
func TestPodman_ListRunning_NoBinary(t *testing.T) {
// This test is meaningful if podman isn't installed; if it is, we just check no error occurs
p := &Podman{}
containers, err := p.ListRunning()
containers, err := p.ListRunning("")
if err != nil {
t.Errorf("ListRunning() should not error when podman is absent: %v", err)
}
@@ -63,9 +65,85 @@ func TestPodman_ListRunning_NoBinary(t *testing.T) {
func TestDocker_ListRunning_NoBinary(t *testing.T) {
d := &Docker{}
containers, err := d.ListRunning()
containers, err := d.ListRunning("")
if err != nil {
t.Errorf("ListRunning() should not error when docker is absent: %v", err)
}
_ = containers
}
func TestAvailable_PodmanFirst(t *testing.T) {
runtimes := Available()
if len(runtimes) < 2 || runtimes[0].Name() != "podman" || runtimes[1].Name() != "docker" {
t.Errorf("Available() order must be podman, docker; got %v", runtimes)
}
}
// Fixture captured from `podman-compose up -d` of testdata/stack/compose.yaml.
func TestParsePodmanPS_ComposeStack(t *testing.T) {
data, err := os.ReadFile("../testdata/stack/podman_ps.json")
if err != nil {
t.Fatalf("reading fixture: %v", err)
}
containers, err := parsePodmanPS(data)
if err != nil {
t.Fatalf("parsePodmanPS() error: %v", err)
}
if len(containers) != 2 {
t.Fatalf("expected 2 containers, got %d", len(containers))
}
for _, c := range containers {
if c.Project() != "unitdore-test" {
t.Errorf("%s: Project() = %q, want unitdore-test", c.Name, c.Project())
}
if c.WorkingDir() == "" {
t.Errorf("%s: WorkingDir() empty", c.Name)
}
if !reflect.DeepEqual(c.ComposeFiles(), []string{"compose.yaml"}) {
t.Errorf("%s: ComposeFiles() = %v", c.Name, c.ComposeFiles())
}
}
}
func TestContainer_NoProject(t *testing.T) {
c := Container{Name: "plain"}
if c.Project() != "" || c.WorkingDir() != "" || c.ComposeFiles() != nil {
t.Errorf("container without labels should have no stack info: %+v", c)
}
}
func TestContainer_ComposeFilesMultiple(t *testing.T) {
c := Container{Labels: map[string]string{LabelComposeConfigFiles: "compose.yaml, ./override.yaml"}}
want := []string{"compose.yaml", "override.yaml"}
if got := c.ComposeFiles(); !reflect.DeepEqual(got, want) {
t.Errorf("ComposeFiles() = %v, want %v", got, want)
}
}
func TestParseDockerPS_Labels(t *testing.T) {
out := []byte(`{"ID":"abc","Names":"web","Image":"nginx","Command":"nginx","State":"running","Labels":"com.docker.compose.project=shop,com.docker.compose.project.config_files=/s/a.yml,/s/b.yml,com.docker.compose.service=web"}`)
cs := parseDockerPS(out)
if len(cs) != 1 {
t.Fatalf("expected 1 container, got %d", len(cs))
}
l := cs[0].Labels
if l["com.docker.compose.project"] != "shop" {
t.Errorf("project label = %q", l["com.docker.compose.project"])
}
if l["com.docker.compose.project.config_files"] != "/s/a.yml,/s/b.yml" {
t.Errorf("config_files label = %q", l["com.docker.compose.project.config_files"])
}
if l["com.docker.compose.service"] != "web" {
t.Errorf("service label = %q", l["com.docker.compose.service"])
}
// Docker compose stacks are not grouped yet (podman-compose only)
if cs[0].Project() != "" {
t.Errorf("docker compose container should not report a podman-compose project")
}
}
func TestPodName(t *testing.T) {
if PodName("shop") != "pod_shop" {
t.Errorf("PodName() = %q", PodName("shop"))
}
}