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
+95
View File
@@ -0,0 +1,95 @@
// Package sysuser runs commands as the current process or as another user,
// and answers questions about local user accounts.
package sysuser
import (
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
)
// IsRoot reports whether the process runs with euid 0.
func IsRoot() bool { return os.Geteuid() == 0 }
// Current returns the username of the effective user.
func Current() string {
u, err := user.LookupId(fmt.Sprint(os.Geteuid()))
if err != nil {
return ""
}
return u.Username
}
// IsSelf reports whether name is the effective user (or empty).
func IsSelf(name string) bool {
return name == "" || name == Current()
}
// HomeDir returns the home directory of the given user.
func HomeDir(name string) (string, error) {
u, err := user.Lookup(name)
if err != nil {
return "", fmt.Errorf("looking up user %s: %w", name, err)
}
return u.HomeDir, nil
}
// UID returns the numeric uid of the given user as a string.
func UID(name string) (string, error) {
u, err := user.Lookup(name)
if err != nil {
return "", fmt.Errorf("looking up user %s: %w", name, err)
}
return u.Uid, nil
}
// Command builds a command that runs bin as the given user.
// An empty user, or the current user, runs it directly. Otherwise runuser is
// used (requires root) with XDG_RUNTIME_DIR pointed at the user's runtime dir,
// which rootless podman and systemctl --user need.
func Command(name string, bin string, args ...string) (*exec.Cmd, error) {
if IsSelf(name) {
return exec.Command(bin, args...), nil
}
if !IsRoot() {
return nil, fmt.Errorf("running as user %s requires root", name)
}
uid, err := UID(name)
if err != nil {
return nil, err
}
full := append([]string{"-u", name, "--", "env",
"XDG_RUNTIME_DIR=/run/user/" + uid,
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/" + uid + "/bus",
bin}, args...)
return exec.Command("runuser", full...), nil
}
// LookPath finds bin in PATH, then in the user's ~/.local/bin.
// Returns "" if not found.
func LookPath(name string, bin string) string {
if p, err := exec.LookPath(bin); err == nil {
return p
}
if name == "" {
name = Current()
}
home, err := HomeDir(name)
if err != nil {
return ""
}
p := filepath.Join(home, ".local", "bin", bin)
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
return p
}
return ""
}
// LingerEnabled reports whether systemd lingering is enabled for the user,
// so user units start at boot without a login session.
func LingerEnabled(name string) bool {
_, err := os.Stat(filepath.Join("/var/lib/systemd/linger", name))
return err == nil
}
+31
View File
@@ -0,0 +1,31 @@
package sysuser
import "testing"
func TestIsSelf(t *testing.T) {
if !IsSelf("") {
t.Error("empty user must be self")
}
if cur := Current(); cur != "" && !IsSelf(cur) {
t.Errorf("current user %q must be self", cur)
}
}
func TestCommand_Self(t *testing.T) {
cmd, err := Command(Current(), "true")
if err != nil {
t.Fatalf("Command() error: %v", err)
}
if cmd.Args[0] != "true" {
t.Errorf("self command should run directly, got %v", cmd.Args)
}
}
func TestCommand_OtherUserRequiresRoot(t *testing.T) {
if IsRoot() {
t.Skip("running as root")
}
if _, err := Command("root", "true"); err == nil {
t.Error("running as another user without root should error")
}
}