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