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
+59 -7
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"os/exec"
"strings"
"github.com/warkanum/unitdore/sysuser"
)
type Docker struct{}
@@ -17,21 +19,44 @@ type dockerContainer struct {
Image string `json:"Image"`
Command string `json:"Command"`
State string `json:"State"`
Labels string `json:"Labels"`
}
func (d *Docker) ListRunning() ([]Container, error) {
// dockerCmd builds a docker command for the given user scope.
// Returns nil if docker is not installed.
func dockerCmd(user string, args ...string) (*exec.Cmd, error) {
bin, err := exec.LookPath("docker")
if err != nil {
return nil, nil
}
return sysuser.Command(user, bin, args...)
}
func (d *Docker) ListRunning(user string) ([]Container, error) {
cmd, err := dockerCmd(user, "ps", "--format", "json")
if err != nil {
return nil, err
}
if cmd == nil {
return nil, nil // docker not installed — not an error
}
out, err := exec.Command(bin, "ps", "--format", "json").Output()
out, err := cmd.Output()
if err != nil {
// Docker installed but daemon not running — treat as no containers
return nil, nil
}
// Docker outputs one JSON object per line (not a JSON array)
containers := parseDockerPS(out)
for i := range containers {
containers[i].User = user
}
return containers, nil
}
// parseDockerPS parses `docker ps --format json` output
// (one JSON object per line, not a JSON array).
func parseDockerPS(out []byte) []Container {
var containers []Container
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
@@ -57,18 +82,45 @@ func (d *Docker) ListRunning() ([]Container, error) {
Image: c.Image,
Command: c.Command,
Runtime: "docker",
Labels: parseDockerLabels(c.Labels),
})
}
return containers, nil
return containers
}
func (d *Docker) Exists(name string) (bool, error) {
bin, err := exec.LookPath("docker")
// parseDockerLabels parses docker's "k=v,k=v" label string. A segment without
// "=" is a continuation of the previous value (values may contain commas,
// e.g. multiple compose config files).
func parseDockerLabels(s string) map[string]string {
labels := map[string]string{}
last := ""
for _, part := range strings.Split(s, ",") {
if part == "" {
continue
}
k, v, ok := strings.Cut(part, "=")
if !ok {
if last != "" {
labels[last] += "," + part
}
continue
}
labels[k] = v
last = k
}
return labels
}
func (d *Docker) Exists(user, name string) (bool, error) {
cmd, err := dockerCmd(user, "ps", "--format", "{{.Names}}")
if err != nil {
return false, err
}
if cmd == nil {
return false, nil
}
out, err := exec.Command(bin, "ps", "--format", "{{.Names}}").Output()
out, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("docker ps: %w", err)
}
+65 -12
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"os/exec"
"strings"
"github.com/warkanum/unitdore/sysuser"
)
type Podman struct{}
@@ -12,30 +14,56 @@ type Podman struct{}
func (p *Podman) Name() string { return "podman" }
type podmanContainer struct {
ID string `json:"Id"`
Names []string `json:"Names"`
Image string `json:"Image"`
Command []string `json:"Command"`
State string `json:"State"`
ID string `json:"Id"`
Names []string `json:"Names"`
Image string `json:"Image"`
Command []string `json:"Command"`
State string `json:"State"`
Labels map[string]string `json:"Labels"`
}
func (p *Podman) ListRunning() ([]Container, error) {
// podmanCmd builds a podman command for the given user scope.
// Returns nil if podman is not installed.
func podmanCmd(user string, args ...string) (*exec.Cmd, error) {
bin, err := exec.LookPath("podman")
if err != nil {
return nil, nil
}
return sysuser.Command(user, bin, args...)
}
func (p *Podman) ListRunning(user string) ([]Container, error) {
cmd, err := podmanCmd(user, "ps", "--format", "json")
if err != nil {
return nil, err
}
if cmd == nil {
return nil, nil // podman not installed — not an error
}
out, err := exec.Command(bin, "ps", "--format", "json").Output()
out, err := cmd.Output()
if err != nil {
// Podman installed but not usable — treat as no containers
return nil, nil
}
var raw []podmanContainer
if err := json.Unmarshal(out, &raw); err != nil {
containers, err := parsePodmanPS(out)
if err != nil {
// Podman installed but output is not valid JSON (e.g. OCI runtime misconfigured)
return nil, nil
}
for i := range containers {
containers[i].User = user
}
return containers, nil
}
// parsePodmanPS parses `podman ps --format json` output (a JSON array).
func parsePodmanPS(out []byte) ([]Container, error) {
var raw []podmanContainer
if err := json.Unmarshal(out, &raw); err != nil {
return nil, err
}
var containers []Container
for _, c := range raw {
@@ -58,18 +86,22 @@ func (p *Podman) ListRunning() ([]Container, error) {
Image: c.Image,
Command: strings.Join(c.Command, " "),
Runtime: "podman",
Labels: c.Labels,
})
}
return containers, nil
}
func (p *Podman) Exists(name string) (bool, error) {
bin, err := exec.LookPath("podman")
func (p *Podman) Exists(user, name string) (bool, error) {
cmd, err := podmanCmd(user, "ps", "--format", "{{.Names}}")
if err != nil {
return false, err
}
if cmd == nil {
return false, nil
}
out, err := exec.Command(bin, "ps", "--format", "{{.Names}}").Output()
out, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("podman ps: %w", err)
}
@@ -81,3 +113,24 @@ func (p *Podman) Exists(name string) (bool, error) {
}
return false, nil
}
// PodExists reports whether a pod with the given name exists (any state).
func (p *Podman) PodExists(user, pod string) (bool, error) {
cmd, err := podmanCmd(user, "pod", "exists", pod)
if err != nil {
return false, err
}
if cmd == nil {
return false, nil
}
if err := cmd.Run(); err != nil {
if _, ok := err.(*exec.ExitError); ok {
return false, nil // exit 1 = pod does not exist
}
return false, fmt.Errorf("podman pod exists: %w", err)
}
return true, nil
}
// PodName returns the pod name podman-compose uses for a project.
func PodName(project string) string { return "pod_" + project }
+45 -3
View File
@@ -1,21 +1,63 @@
package runtime
import (
"path/filepath"
"strings"
)
// Compose labels set by podman-compose on every container it creates.
const (
LabelPodmanComposeProject = "io.podman.compose.project"
LabelComposeWorkingDir = "com.docker.compose.project.working_dir"
LabelComposeConfigFiles = "com.docker.compose.project.config_files"
)
// Container represents a discovered running container.
type Container struct {
Name string
Image string
Command string
Runtime string // "podman" or "docker"
User string // empty = root/system scope
Labels map[string]string
}
// Project returns the podman-compose project the container belongs to, or "".
func (c Container) Project() string {
return c.Labels[LabelPodmanComposeProject]
}
// WorkingDir returns the compose project working directory, or "".
func (c Container) WorkingDir() string {
return c.Labels[LabelComposeWorkingDir]
}
// ComposeFiles returns the compose files used to create the container.
// Relative paths are relative to WorkingDir.
func (c Container) ComposeFiles() []string {
raw := c.Labels[LabelComposeConfigFiles]
if raw == "" {
return nil
}
var files []string
for _, f := range strings.Split(raw, ",") {
if f = strings.TrimSpace(f); f != "" {
files = append(files, filepath.Clean(f))
}
}
return files
}
// Runtime is the interface all container runtimes must implement.
// user selects the scope: "" = root/system, otherwise a rootless user.
type Runtime interface {
Name() string
ListRunning() ([]Container, error)
Exists(name string) (bool, error)
ListRunning(user string) ([]Container, error)
Exists(user, name string) (bool, error)
}
// Available returns all supported runtimes (callers skip those with no binary).
// Available returns all supported runtimes in priority order: podman first,
// docker as fallback (callers skip those with no binary).
func Available() []Runtime {
return []Runtime{&Podman{}, &Docker{}}
}
+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"))
}
}