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:
+59
-7
@@ -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
@@ -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
@@ -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
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user