From f0827fcdd71e9decfeac4fd09b3ea0d6d367261b Mon Sep 17 00:00:00 2001 From: Hein Date: Mon, 12 Oct 2026 19:05:51 +0200 Subject: [PATCH] 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 ") - 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 --- AGENTS.md | 14 +- PLAN.md | 5 +- README.md | 40 ++++- cmd/helpers.go | 25 +++ cmd/install.go | 2 + cmd/installall.go | 2 + cmd/list.go | 2 +- cmd/root.go | 22 ++- cmd/status.go | 2 +- cmd/syncup.go | 289 +++++++++++++++++++++++++++------- cmd/syncup_test.go | 185 ++++++++++++++++++++++ config/config.go | 28 +++- config/config_test.go | 39 +++++ docs/generated-units.md | 53 +++++++ docs/unitdore.1 | 33 +++- runtime/docker.go | 66 +++++++- runtime/podman.go | 77 +++++++-- runtime/runtime.go | 48 +++++- runtime/runtime_test.go | 82 +++++++++- systemd/generator.go | 107 ++++++++++++- systemd/generator_test.go | 128 +++++++++++++++ systemd/manager.go | 29 ++-- sysuser/sysuser.go | 95 +++++++++++ sysuser/sysuser_test.go | 31 ++++ testdata/stack/compose.yaml | 15 ++ testdata/stack/podman_ps.json | 100 ++++++++++++ 26 files changed, 1397 insertions(+), 122 deletions(-) create mode 100644 cmd/helpers.go create mode 100644 cmd/syncup_test.go create mode 100644 sysuser/sysuser.go create mode 100644 sysuser/sysuser_test.go create mode 100644 testdata/stack/compose.yaml create mode 100644 testdata/stack/podman_ps.json diff --git a/AGENTS.md b/AGENTS.md index c58428a..16abf90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,9 @@ make release-version # Bump patch version, commit, tag, and push cmd/ # Cobra commands (edit, install, list, root, start, startall, # status, stop, stopall, syncup, uninstall, update) config/ # Config struct + YAML load/save (units.yaml) -runtime/ # Docker and Podman runtime abstraction +runtime/ # Docker and Podman runtime abstraction, compose labels +sysuser/ # run commands as root/user (runuser), home/linger lookup +testdata/stack # podman-compose test stack + captured `podman ps` fixture systemd/ # .service file generator and systemd manager docs/ # generated-units.md (unit file examples), unitdore.1 (man page) ``` @@ -48,6 +50,7 @@ docs/ # generated-units.md (unit file examples), unitdore.1 (man page) ```yaml prefix: "" # prepended to service file name (e.g. "prod-") suffix: "" # appended to service file name (e.g. "-svc") +users: [] # root only: rootless users scanned by syncup units: - name: nginx @@ -61,8 +64,15 @@ units: restart: false # Restart=on-failure restart_sec: 0 # RestartSec restart_retries: 0 # StartLimitBurst + kind: container # container | stack (podman-compose project) + project: "" # stack: compose project + working_dir: "" # stack: compose dir (from labels) + compose_files: [] # stack: relative to working_dir + compose_bin: "" # stack: podman-compose path ``` +Non-root runs use `~/.config/unitdore/units.yaml` and manage only own user units. + ### Generated service file locations | Scenario | Path | @@ -82,6 +92,8 @@ units: Tests exist in: - `config/config_test.go` - `runtime/runtime_test.go` +- `cmd/syncup_test.go` +- `sysuser/sysuser_test.go` - `systemd/generator_test.go` Always run `make test` before marking a task complete. Add tests for new behaviour in the same package as the code under test. diff --git a/PLAN.md b/PLAN.md index bb07ec9..569ac85 100644 --- a/PLAN.md +++ b/PLAN.md @@ -27,7 +27,8 @@ Think of it as: **config → systemd units → running services.** ## Non-Goals - Not a container runtime (doesn't replace Podman/Docker) -- Not Kubernetes / Compose / Quadlet +- Not Kubernetes / Quadlet +- Compose: only wraps existing podman-compose stacks (one unit per project); docker compose planned - Not a daemon — runs on demand --- @@ -172,6 +173,8 @@ unitdore/ | 5 | Reconcile (auto-disable missing units) in syncup | | 6 | User unit support (rootless containers) | | 7 | Polish: `edit`, error handling, --dry-run flag | +| 8 | podman-compose stacks (compose + pod fallback), root/user scopes | +| 9 | docker compose stacks (`com.docker.compose.*` labels) | --- diff --git a/README.md b/README.md index f5b088f..ae39897 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > *A door you open and close for container units.* -Unitdore bridges your container runtime (Podman, Docker) and systemd. It discovers running containers, stores them in a config file, and generates + manages systemd `.service` units for each one. +Unitdore bridges your container runtime (Podman, Docker) and systemd. It discovers running containers and podman-compose stacks, stores them in a config file, and generates + manages systemd `.service` units for each one. ## Install @@ -54,6 +54,11 @@ sudo unitdore syncup Queries Podman and Docker for running containers, adds new ones to the config, and reconciles any that have disappeared (marking them disabled). +- Podman first; Docker only for names Podman doesn't have +- podman-compose containers → one `kind: stack` unit per project; existing member units disabled (`part of stack `) +- Root: root's containers + rootless Podman containers of each user in `users:` +- Normal user: own Podman containers, config `~/.config/unitdore/units.yaml`, user units only + ### Edit the config ```bash @@ -104,6 +109,7 @@ oldthing podman hein no no — container ```yaml prefix: "" # optional: prepended to all service names, e.g. "prod-" suffix: "" # optional: appended to all service names, e.g. "-svc" +users: [hein] # root only: rootless users scanned by syncup units: - name: nginx @@ -121,8 +127,36 @@ units: order: 2 delay: 5s enabled: true + + - name: shop # = compose project name + kind: stack # container (default) | stack + runtime: podman + user: hein + project: shop + working_dir: /srv/shop # from compose labels (refreshed by syncup) + compose_files: [compose.yaml] + compose_bin: /usr/bin/podman-compose + order: 3 + enabled: true ``` +### Stacks (podman-compose) + +| Condition | Unit | +|---|---| +| `compose_bin`, `working_dir`, `compose_files` exist | `podman-compose -p -f up` / `stop`, `Type=simple` | +| any missing | `podman pod start/stop pod_`, `Type=oneshot` | +| compose missing and no pod | `syncup` disables: `stack not found` | + +### Users + +| Run as | Config | Discovers | Units | +|---|---|---|---| +| root | `/etc/unitdore/units.yaml` | root + `users:` (Podman only for users) | system + user units | +| user | `~/.config/unitdore/units.yaml` | own Podman | own user units | + +User units need linger to start at boot: `sudo loginctl enable-linger ` (`install` warns). + ### Prefix and suffix Set `prefix` and/or `suffix` to namespace your service files: @@ -147,6 +181,7 @@ See **[docs/generated-units.md](docs/generated-units.md)** for full examples cov - User units (rootless) - Docker vs Podman - Custom commands +- Compose stacks (compose + pod fallback) - Prefix/suffix naming - Startup ordering - Unnamed containers (short ID fallback) @@ -175,7 +210,7 @@ WantedBy=multi-user.target ## Flags ``` ---config string path to units config file (default "/etc/unitdore/units.yaml") +--config string path to units config file (default "/etc/unitdore/units.yaml"; non-root: ~/.config/unitdore/units.yaml) ``` ## Requirements @@ -183,4 +218,5 @@ WantedBy=multi-user.target - Go 1.21+ - systemd - Podman and/or Docker (only what you have installed is used) +- podman-compose for compose-mode stacks (pod fallback without it) - Root for system units; the relevant user for rootless units diff --git a/cmd/helpers.go b/cmd/helpers.go new file mode 100644 index 0000000..3cfcbd9 --- /dev/null +++ b/cmd/helpers.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "fmt" + + "github.com/warkanum/unitdore/config" + "github.com/warkanum/unitdore/sysuser" +) + +// runtimeLabel is the RUNTIME column value for list/status. +func runtimeLabel(u config.Unit) string { + if u.IsStack() { + return u.Runtime + " (stack)" + } + return u.Runtime +} + +// lingerHint prints, once per user, how to make user units start at boot. +func lingerHint(seen map[string]bool, u config.Unit) { + if u.User == "" || seen[u.User] || sysuser.LingerEnabled(u.User) { + return + } + seen[u.User] = true + fmt.Printf(" ! linger not enabled for %s — user units won't start at boot: sudo loginctl enable-linger %s\n", u.User, u.User) +} diff --git a/cmd/install.go b/cmd/install.go index 729248e..7c8e528 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -35,6 +35,7 @@ func runInstall(cmd *cobra.Command, args []string) error { installed := 0 skipped := 0 removed := 0 + lingerSeen := map[string]bool{} for _, u := range cfg.Units { if !u.Enabled { @@ -73,6 +74,7 @@ func runInstall(cmd *cobra.Command, args []string) error { } else { path, _ := systemd.UnitPath(u, prefix, suffix) fmt.Printf(" ✓ installed: %s\n", path) + lingerHint(lingerSeen, u) installed++ } } diff --git a/cmd/installall.go b/cmd/installall.go index 7fb1720..202dd8e 100644 --- a/cmd/installall.go +++ b/cmd/installall.go @@ -35,6 +35,7 @@ func runInstallall(cmd *cobra.Command, args []string) error { installed := 0 skipped := 0 removed := 0 + lingerSeen := map[string]bool{} for _, u := range cfg.Units { if !u.Enabled { @@ -72,6 +73,7 @@ func runInstallall(cmd *cobra.Command, args []string) error { } else { path, _ := systemd.UnitPath(u, prefix, suffix) fmt.Printf(" ✓ installed: %s\n", path) + lingerHint(lingerSeen, u) installed++ } } diff --git a/cmd/list.go b/cmd/list.go index fc2c64e..90508a3 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -60,7 +60,7 @@ func runList(cmd *cobra.Command, args []string) error { } } - rows = append(rows, row{u.Name, u.Runtime, enabled, installed, unitPath}) + rows = append(rows, row{u.Name, runtimeLabel(u), enabled, installed, unitPath}) } cols := []string{"NAME", "RUNTIME", "ENABLED", "INSTALLED", "SERVICE FILE"} diff --git a/cmd/root.go b/cmd/root.go index ade579b..9b3b9fe 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,6 +5,8 @@ import ( "os" "github.com/spf13/cobra" + "github.com/warkanum/unitdore/config" + "github.com/warkanum/unitdore/sysuser" ) var version = "0.0.10" @@ -16,9 +18,23 @@ var rootCmd = &cobra.Command{ Short: "A door you open and close for container units", Long: `Unitdore manages container units via systemd. -It discovers running containers, stores them in a config file, -and generates + manages systemd .service units for each one.`, +It discovers running containers and podman-compose stacks, stores them in a +config file, and generates + manages systemd .service units for each one. + +As root: system units, plus user units for users listed in 'users:'. +As a normal user: own config and user units only.`, Version: version, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // Normal users get their own config and manage only their user units + if !cmd.Flags().Changed("config") && !sysuser.IsRoot() { + home, err := sysuser.HomeDir(sysuser.Current()) + if err != nil { + return err + } + configPath = config.UserConfigPath(home) + } + return nil + }, } func Execute() { @@ -29,5 +45,5 @@ func Execute() { } func init() { - rootCmd.PersistentFlags().StringVar(&configPath, "config", "/etc/unitdore/units.yaml", "path to units config file") + rootCmd.PersistentFlags().StringVar(&configPath, "config", config.DefaultConfigPath, "path to units config file (default for non-root: ~/.config/unitdore/units.yaml)") } diff --git a/cmd/status.go b/cmd/status.go index a11d024..f66cf09 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -76,7 +76,7 @@ func runStatus(cmd *cobra.Command, args []string) error { rows = append(rows, statusRow{ Name: u.Name, - Runtime: u.Runtime, + Runtime: runtimeLabel(u), User: user, Enabled: enabled, Installed: installed, diff --git a/cmd/syncup.go b/cmd/syncup.go index 32d60e5..0e52539 100644 --- a/cmd/syncup.go +++ b/cmd/syncup.go @@ -2,11 +2,15 @@ package cmd import ( "fmt" + "io" "os" + "strings" "github.com/spf13/cobra" "github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/runtime" + "github.com/warkanum/unitdore/systemd" + "github.com/warkanum/unitdore/sysuser" ) var syncupCmd = &cobra.Command{ @@ -15,8 +19,17 @@ var syncupCmd = &cobra.Command{ Long: `Syncup queries all available runtimes for running containers and adds any new ones to the config file. Existing entries are never removed. -It also reconciles: if a configured unit's container no longer exists, -it is marked disabled with a reason.`, +Podman is queried first. Docker is only a fallback: a container name found +in podman is never registered as docker. + +Containers created by podman-compose are grouped into one stack unit per +project. Existing per-container units of stack members are disabled. + +Scopes: as root, root's containers plus rootless containers of each user in +'users:' (podman only). As a normal user, the user's own podman containers. + +It also reconciles: if a configured unit's container or stack no longer +exists, it is marked disabled with a reason.`, RunE: runSyncup, } @@ -24,71 +37,235 @@ func init() { rootCmd.AddCommand(syncupCmd) } +const ( + reasonNotFound = "container not found" + reasonStackNotFound = "stack not found" + reasonMemberPrefix = "part of stack " +) + +type syncStats struct { + added, unchanged, disabled, reenabled int +} + func runSyncup(cmd *cobra.Command, args []string) error { cfg, err := config.LoadOrEmpty(configPath) if err != nil { return err } - added := 0 - unchanged := 0 - discovered := map[string]bool{} + containers := discover(syncScopes(cfg)) - // Discover running containers across all runtimes - for _, rt := range runtime.Available() { - containers, err := rt.ListRunning() - if err != nil { - fmt.Fprintf(os.Stderr, "warning: %s discovery failed: %v\n", rt.Name(), err) - continue - } - for _, c := range containers { - discovered[c.Name] = true - unit := config.Unit{ - Name: c.Name, - Runtime: c.Runtime, - Order: len(cfg.Units) + 1, - Enabled: true, - } - if cfg.AddUnit(unit) { - fmt.Printf(" + added: %s (%s)\n", c.Name, c.Runtime) - added++ - } else { - unchanged++ - } - } - } - - // Reconcile: check if existing units' containers still exist - disabled := 0 - reenabled := 0 - for i := range cfg.Units { - u := &cfg.Units[i] - rt := runtime.Get(u.Runtime) - if rt == nil { - continue - } - exists, err := rt.Exists(u.Name) - if err != nil { - fmt.Fprintf(os.Stderr, "warning: checking %s: %v\n", u.Name, err) - continue - } - if !exists && u.Enabled { - u.Enabled = false - u.DisabledReason = "container not found" - fmt.Printf(" ! disabled: %s (container not found)\n", u.Name) - disabled++ - } else if exists && !u.Enabled && u.DisabledReason == "container not found" { - u.Enabled = true - u.DisabledReason = "" - fmt.Printf(" ✓ re-enabled: %s (container found)\n", u.Name) - reenabled++ - } - } + var st syncStats + running := mergeDiscovered(cfg, containers, composeBinFor, os.Stdout, &st) + reconcile(cfg, running, unitExists, os.Stdout, &st) if err := cfg.Save(configPath); err != nil { return err } - fmt.Printf("\nDone. Added: %d Unchanged: %d Disabled: %d Re-enabled: %d\n", added, unchanged, disabled, reenabled) + fmt.Printf("\nDone. Added: %d Unchanged: %d Disabled: %d Re-enabled: %d\n", st.added, st.unchanged, st.disabled, st.reenabled) return nil } + +// syncScopes returns the user scopes to scan ("" = root/system). +func syncScopes(cfg *config.Config) []string { + if sysuser.IsRoot() { + return append([]string{""}, cfg.Users...) + } + return []string{sysuser.Current()} +} + +// discover lists running containers in every scope, podman first. +func discover(scopes []string) []runtime.Container { + var all []runtime.Container + for _, user := range scopes { + for _, rt := range runtime.Available() { + // Docker is a system daemon; user scopes only see rootless podman + if user != "" && rt.Name() == "docker" { + continue + } + containers, err := rt.ListRunning(user) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: %s discovery failed (%s): %v\n", rt.Name(), scopeName(user), err) + continue + } + all = append(all, containers...) + } + } + return all +} + +func composeBinFor(user string) string { + return sysuser.LookPath(user, "podman-compose") +} + +func scopeName(user string) string { + if user == "" { + return "root" + } + return user +} + +func scopeKey(user, name string) string { return user + "/" + name } + +// mergeDiscovered adds discovered containers and stacks to cfg. Returns the +// set of running scopeKey(user, name) for containers and stack projects. +func mergeDiscovered(cfg *config.Config, containers []runtime.Container, composeBin func(user string) string, w io.Writer, st *syncStats) map[string]bool { + running := map[string]bool{} + podmanNames := map[string]bool{} + members := map[string]string{} // scopeKey(user, container) -> project + + for _, c := range containers { + if c.Runtime == "podman" { + podmanNames[scopeKey(c.User, c.Name)] = true + } + } + + // Group podman-compose containers into one stack unit per (user, project) + for _, c := range containers { + project := c.Project() + if c.Runtime != "podman" || project == "" { + continue + } + members[scopeKey(c.User, c.Name)] = project + key := scopeKey(c.User, project) + if running[key] { + continue + } + running[key] = true + + stack := config.Unit{ + Name: project, + Runtime: "podman", + User: c.User, + Kind: config.KindStack, + Project: project, + WorkingDir: c.WorkingDir(), + ComposeFiles: c.ComposeFiles(), + ComposeBin: composeBin(c.User), + Order: len(cfg.Units) + 1, + Enabled: true, + } + existing := cfg.FindUnit(project) + switch { + case existing == nil: + cfg.AddUnit(stack) + fmt.Fprintf(w, " + added stack: %s (%s, %s)\n", project, scopeName(c.User), strings.Join(stack.ComposeFiles, ",")) + st.added++ + case existing.IsStack() && existing.Project == project && existing.User == c.User: + // Labels are the source of truth for where the stack lives + existing.WorkingDir = stack.WorkingDir + existing.ComposeFiles = stack.ComposeFiles + if stack.ComposeBin != "" { + existing.ComposeBin = stack.ComposeBin + } + st.unchanged++ + default: + fmt.Fprintf(os.Stderr, "warning: stack %s (%s) skipped: unit name already used\n", project, scopeName(c.User)) + } + } + + for _, c := range containers { + key := scopeKey(c.User, c.Name) + // Podman is queried first and wins on name clash (e.g. podman-docker shim) + if c.Runtime != "podman" && podmanNames[key] { + continue + } + running[key] = true + if _, ok := members[key]; ok { + continue + } + unit := config.Unit{ + Name: c.Name, + Runtime: c.Runtime, + User: c.User, + Order: len(cfg.Units) + 1, + Enabled: true, + } + if cfg.AddUnit(unit) { + fmt.Fprintf(w, " + added: %s (%s, %s)\n", c.Name, c.Runtime, scopeName(c.User)) + st.added++ + continue + } + if existing := cfg.FindUnit(c.Name); existing.User != c.User { + fmt.Fprintf(os.Stderr, "warning: %s (%s) skipped: unit name already used by %s\n", c.Name, scopeName(c.User), scopeName(existing.User)) + continue + } + st.unchanged++ + } + + for i := range cfg.Units { + u := &cfg.Units[i] + if u.IsStack() { + continue + } + key := scopeKey(u.User, u.Name) + if u.Runtime == "docker" && podmanNames[key] { + u.Runtime = "podman" + fmt.Fprintf(w, " ~ runtime: %s docker -> podman\n", u.Name) + } + if project, ok := members[key]; ok && u.DisabledReason != reasonMemberPrefix+project { + wasEnabled := u.Enabled + u.Enabled = false + u.DisabledReason = reasonMemberPrefix + project + if wasEnabled { + fmt.Fprintf(w, " ! disabled: %s (%s)\n", u.Name, u.DisabledReason) + st.disabled++ + } + } + } + + return running +} + +// unitExists checks whether a unit's container or stack still exists. +func unitExists(u config.Unit) (bool, error) { + if u.IsStack() { + if systemd.ComposeAvailable(u) { + return true, nil + } + return (&runtime.Podman{}).PodExists(u.User, runtime.PodName(u.Project)) + } + rt := runtime.Get(u.Runtime) + if rt == nil { + return true, nil // unknown runtime — leave alone + } + return rt.Exists(u.User, u.Name) +} + +// reconcile disables units whose container/stack is gone and re-enables +// units that were auto-disabled once it is back. +func reconcile(cfg *config.Config, running map[string]bool, exists func(config.Unit) (bool, error), w io.Writer, st *syncStats) { + for i := range cfg.Units { + u := &cfg.Units[i] + if strings.HasPrefix(u.DisabledReason, reasonMemberPrefix) { + continue + } + reason := reasonNotFound + if u.IsStack() { + reason = reasonStackNotFound + } + + found := running[scopeKey(u.User, u.Name)] + if !found { + var err error + found, err = exists(*u) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: checking %s: %v\n", u.Name, err) + continue + } + } + + if !found && u.Enabled { + u.Enabled = false + u.DisabledReason = reason + fmt.Fprintf(w, " ! disabled: %s (%s)\n", u.Name, reason) + st.disabled++ + } else if found && !u.Enabled && u.DisabledReason == reason { + u.Enabled = true + u.DisabledReason = "" + fmt.Fprintf(w, " ✓ re-enabled: %s (found)\n", u.Name) + st.reenabled++ + } + } +} diff --git a/cmd/syncup_test.go b/cmd/syncup_test.go new file mode 100644 index 0000000..bc31f55 --- /dev/null +++ b/cmd/syncup_test.go @@ -0,0 +1,185 @@ +package cmd + +import ( + "io" + "testing" + + "github.com/warkanum/unitdore/config" + "github.com/warkanum/unitdore/runtime" +) + +func stackContainer(user, name, project string) runtime.Container { + return runtime.Container{ + Name: name, + Runtime: "podman", + User: user, + Labels: map[string]string{ + runtime.LabelPodmanComposeProject: project, + runtime.LabelComposeWorkingDir: "/srv/" + project, + runtime.LabelComposeConfigFiles: "compose.yaml", + }, + } +} + +func noBin(string) string { return "" } + +func merge(cfg *config.Config, cs []runtime.Container) (map[string]bool, syncStats) { + var st syncStats + running := mergeDiscovered(cfg, cs, noBin, io.Discard, &st) + return running, st +} + +func TestMerge_GroupsStack(t *testing.T) { + cfg := &config.Config{} + _, st := merge(cfg, []runtime.Container{ + stackContainer("", "shop_web_1", "shop"), + stackContainer("", "shop_db_1", "shop"), + {Name: "redis", Runtime: "podman"}, + }) + + if len(cfg.Units) != 2 || st.added != 2 { + t.Fatalf("expected 2 units (1 stack + 1 container), got %d: %+v", len(cfg.Units), cfg.Units) + } + s := cfg.FindUnit("shop") + if s == nil || !s.IsStack() { + t.Fatalf("stack unit shop missing: %+v", cfg.Units) + } + if s.Project != "shop" || s.WorkingDir != "/srv/shop" || len(s.ComposeFiles) != 1 || s.ComposeFiles[0] != "compose.yaml" { + t.Errorf("stack fields wrong: %+v", s) + } + if cfg.FindUnit("shop_web_1") != nil || cfg.FindUnit("shop_db_1") != nil { + t.Error("stack members must not get their own units") + } + if cfg.FindUnit("redis") == nil { + t.Error("plain container should be added") + } +} + +func TestMerge_DisablesExistingMemberUnits(t *testing.T) { + cfg := &config.Config{Units: []config.Unit{ + {Name: "shop_web_1", Runtime: "podman", Enabled: true}, + {Name: "shop_db_1", Runtime: "podman", Enabled: false, DisabledReason: reasonNotFound}, + }} + running, st := merge(cfg, []runtime.Container{ + stackContainer("", "shop_web_1", "shop"), + stackContainer("", "shop_db_1", "shop"), + }) + + for _, n := range []string{"shop_web_1", "shop_db_1"} { + u := cfg.FindUnit(n) + if u.Enabled || u.DisabledReason != "part of stack shop" { + t.Errorf("%s: enabled=%v reason=%q, want disabled as stack member", n, u.Enabled, u.DisabledReason) + } + } + if st.disabled != 1 { + t.Errorf("disabled = %d, want 1 (only the previously enabled unit)", st.disabled) + } + + // Reconcile must not re-enable member units + reconcile(cfg, running, func(config.Unit) (bool, error) { return true, nil }, io.Discard, &st) + if cfg.FindUnit("shop_db_1").Enabled { + t.Error("reconcile re-enabled a stack member unit") + } +} + +func TestMerge_PodmanWinsOverDocker(t *testing.T) { + cfg := &config.Config{Units: []config.Unit{ + {Name: "old", Runtime: "docker", Enabled: true}, + }} + merge(cfg, []runtime.Container{ + {Name: "web", Runtime: "podman"}, + {Name: "web", Runtime: "docker"}, + {Name: "old", Runtime: "podman"}, + {Name: "only-docker", Runtime: "docker"}, + }) + + if u := cfg.FindUnit("web"); u == nil || u.Runtime != "podman" { + t.Errorf("web should be podman: %+v", u) + } + if u := cfg.FindUnit("old"); u.Runtime != "podman" { + t.Errorf("existing docker unit found in podman should switch to podman, got %s", u.Runtime) + } + if u := cfg.FindUnit("only-docker"); u == nil || u.Runtime != "docker" { + t.Errorf("docker-only container should be added as docker: %+v", u) + } +} + +func TestMerge_UserScope(t *testing.T) { + cfg := &config.Config{} + merge(cfg, []runtime.Container{ + {Name: "app", Runtime: "podman", User: "hein"}, + stackContainer("hein", "blog_web_1", "blog"), + }) + if u := cfg.FindUnit("app"); u == nil || u.User != "hein" { + t.Errorf("user container should keep its user: %+v", u) + } + if u := cfg.FindUnit("blog"); u == nil || u.User != "hein" || !u.IsStack() { + t.Errorf("user stack should keep its user: %+v", u) + } +} + +func TestMerge_NameClashAcrossUsers(t *testing.T) { + cfg := &config.Config{} + _, st := merge(cfg, []runtime.Container{ + {Name: "app", Runtime: "podman"}, + {Name: "app", Runtime: "podman", User: "hein"}, + }) + if len(cfg.Units) != 1 || cfg.Units[0].User != "" { + t.Errorf("first unit should win on name clash: %+v", cfg.Units) + } + if st.unchanged != 0 { + t.Errorf("clash should not count as unchanged, got %d", st.unchanged) + } +} + +func TestMerge_RefreshesExistingStack(t *testing.T) { + cfg := &config.Config{Units: []config.Unit{ + {Name: "shop", Runtime: "podman", Kind: config.KindStack, Project: "shop", WorkingDir: "/old", Enabled: true}, + }} + _, st := merge(cfg, []runtime.Container{stackContainer("", "shop_web_1", "shop")}) + if u := cfg.FindUnit("shop"); u.WorkingDir != "/srv/shop" { + t.Errorf("working_dir not refreshed from labels: %q", u.WorkingDir) + } + if st.added != 0 || st.unchanged != 1 { + t.Errorf("added=%d unchanged=%d, want 0/1", st.added, st.unchanged) + } +} + +func TestReconcile_Stack(t *testing.T) { + cfg := &config.Config{Units: []config.Unit{ + {Name: "gone", Kind: config.KindStack, Project: "gone", Runtime: "podman", Enabled: true}, + {Name: "back", Kind: config.KindStack, Project: "back", Runtime: "podman", Enabled: false, DisabledReason: reasonStackNotFound}, + {Name: "manual", Kind: config.KindStack, Project: "manual", Runtime: "podman", Enabled: false, DisabledReason: "by hand"}, + }} + exists := func(u config.Unit) (bool, error) { return u.Name != "gone", nil } + + var st syncStats + reconcile(cfg, map[string]bool{}, exists, io.Discard, &st) + + if u := cfg.FindUnit("gone"); u.Enabled || u.DisabledReason != reasonStackNotFound { + t.Errorf("gone: enabled=%v reason=%q", u.Enabled, u.DisabledReason) + } + if u := cfg.FindUnit("back"); !u.Enabled { + t.Error("back should be re-enabled") + } + if u := cfg.FindUnit("manual"); u.Enabled { + t.Error("manually disabled unit must stay disabled") + } + if st.disabled != 1 || st.reenabled != 1 { + t.Errorf("disabled=%d reenabled=%d, want 1/1", st.disabled, st.reenabled) + } +} + +func TestReconcile_RunningSkipsExistsCheck(t *testing.T) { + cfg := &config.Config{Units: []config.Unit{ + {Name: "app", Runtime: "podman", User: "hein", Enabled: true}, + }} + called := false + exists := func(config.Unit) (bool, error) { called = true; return false, nil } + + var st syncStats + reconcile(cfg, map[string]bool{scopeKey("hein", "app"): true}, exists, io.Discard, &st) + if called || !cfg.Units[0].Enabled { + t.Errorf("running unit should not be checked or disabled (called=%v)", called) + } +} diff --git a/config/config.go b/config/config.go index 83d4223..ea90ab4 100644 --- a/config/config.go +++ b/config/config.go @@ -10,6 +10,17 @@ import ( const DefaultConfigPath = "/etc/unitdore/units.yaml" +// UserConfigPath returns the config path used when running as a normal user. +func UserConfigPath(home string) string { + return filepath.Join(home, ".config", "unitdore", "units.yaml") +} + +// Unit kinds. +const ( + KindContainer = "container" + KindStack = "stack" +) + // Unit represents a single managed container unit. type Unit struct { Name string `yaml:"name"` @@ -23,13 +34,24 @@ type Unit struct { Restart bool `yaml:"restart,omitempty"` // enable Restart=on-failure RestartSec int `yaml:"restart_sec,omitempty"` // seconds between restarts RestartRetries int `yaml:"restart_retries,omitempty"` // max restart attempts (StartLimitBurst) + + // Stack fields (kind: stack) — one unit for a whole podman-compose project. + Kind string `yaml:"kind,omitempty"` // container (default) | stack + Project string `yaml:"project,omitempty"` // compose project name + WorkingDir string `yaml:"working_dir,omitempty"` // compose project directory + ComposeFiles []string `yaml:"compose_files,omitempty"` // relative to working_dir unless absolute + ComposeBin string `yaml:"compose_bin,omitempty"` // path to podman-compose } +// IsStack reports whether the unit manages a compose stack. +func (u Unit) IsStack() bool { return u.Kind == KindStack } + // Config is the root config structure. type Config struct { - Units []Unit `yaml:"units"` - Prefix string `yaml:"prefix,omitempty"` // prepended to generated service name, e.g. "prod-" - Suffix string `yaml:"suffix,omitempty"` // appended to generated service name, e.g. "-svc" + Units []Unit `yaml:"units"` + Prefix string `yaml:"prefix,omitempty"` // prepended to generated service name, e.g. "prod-" + Suffix string `yaml:"suffix,omitempty"` // appended to generated service name, e.g. "-svc" + Users []string `yaml:"users,omitempty"` // rootless users scanned by syncup when run as root } // Load reads and parses the config file at path. diff --git a/config/config_test.go b/config/config_test.go index 2aa1cd3..82b3c39 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -144,3 +144,42 @@ func TestDisabledReason_Roundtrip(t *testing.T) { t.Errorf("expected disabled_reason to survive roundtrip, got: %q", loaded.Units[0].DisabledReason) } } + +func TestSaveAndLoad_Stack(t *testing.T) { + path := filepath.Join(t.TempDir(), "units.yaml") + cfg := &Config{ + Users: []string{"hein"}, + Units: []Unit{{ + Name: "shop", Runtime: "podman", Kind: KindStack, Project: "shop", + WorkingDir: "/srv/shop", ComposeFiles: []string{"compose.yaml"}, + ComposeBin: "/usr/bin/podman-compose", User: "hein", Enabled: true, + }}, + } + if err := cfg.Save(path); err != nil { + t.Fatalf("Save failed: %v", err) + } + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + u := loaded.Units[0] + if !u.IsStack() || u.Project != "shop" || u.WorkingDir != "/srv/shop" || + len(u.ComposeFiles) != 1 || u.ComposeBin != "/usr/bin/podman-compose" { + t.Errorf("stack fields not round-tripped: %+v", u) + } + if len(loaded.Users) != 1 || loaded.Users[0] != "hein" { + t.Errorf("users not round-tripped: %v", loaded.Users) + } +} + +func TestIsStack_DefaultKind(t *testing.T) { + if (Unit{Name: "x"}).IsStack() { + t.Error("unit without kind must be a container") + } +} + +func TestUserConfigPath(t *testing.T) { + if got := UserConfigPath("/home/hein"); got != "/home/hein/.config/unitdore/units.yaml" { + t.Errorf("UserConfigPath() = %q", got) + } +} diff --git a/docs/generated-units.md b/docs/generated-units.md index b6f4408..0f33548 100644 --- a/docs/generated-units.md +++ b/docs/generated-units.md @@ -277,6 +277,59 @@ units: --- +## Stack Unit (podman-compose, compose mode) + +Used when `compose_bin`, `working_dir` and all `compose_files` exist. + +```yaml +- name: shop + kind: stack + runtime: podman + project: shop + working_dir: /srv/shop + compose_files: [compose.yaml] + compose_bin: /usr/bin/podman-compose + enabled: true +``` + +```ini +# /etc/systemd/system/unitdore-shop.service +# Generated by unitdore — do not edit manually + +[Unit] +Description=Unitdore: shop (podman-compose stack) +After=network.target + +[Service] +Type=simple +WorkingDirectory=/srv/shop +ExecStart=/usr/bin/podman-compose -p shop -f compose.yaml up +ExecStop=-/usr/bin/podman-compose -p shop -f compose.yaml stop + +[Install] +WantedBy=multi-user.target +``` + +--- + +## Stack Unit (pod fallback) + +Used when the compose binary, working dir or a compose file is missing. Requires pod `pod_`. + +```ini +[Unit] +Description=Unitdore: shop (podman pod) +After=network.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/bin/podman pod start pod_shop +ExecStop=-/usr/bin/podman pod stop pod_shop +``` + +--- + ## Quick Reference: Service File Locations | Scenario | Path | diff --git a/docs/unitdore.1 b/docs/unitdore.1 index 474b742..2511bb5 100644 --- a/docs/unitdore.1 +++ b/docs/unitdore.1 @@ -9,18 +9,26 @@ unitdore \- manage container units via systemd .SH DESCRIPTION .B unitdore bridges your container runtime (Podman, Docker) and systemd. -It discovers running containers, stores them in a config file, and generates -and manages systemd \fI.service\fR units for each one. +It discovers running containers and podman\-compose stacks, stores them in a +config file, and generates and manages systemd \fI.service\fR units for each one. +.PP +As root: system units, plus user units for users listed in \fBusers:\fR. +As a normal user: own config (\fI~/.config/unitdore/units.yaml\fR) and user units only. .SH OPTIONS .TP .BR \-\-config " " \fIpath\fR Path to the units config file. Default: \fI/etc/unitdore/units.yaml\fR +(non\-root: \fI~/.config/unitdore/units.yaml\fR) .SH COMMANDS .TP .B syncup Query all available runtimes for running containers and add any new ones to the config. Reconciles existing entries: if a configured unit's container no longer exists it is marked disabled with a reason. +Podman is queried first; Docker only for names Podman does not have. +podman\-compose containers become one \fBkind: stack\fR unit per project; +existing member units are disabled. As root, rootless Podman containers of +each user in \fBusers:\fR are also discovered. .TP .B install Generate \fI.service\fR files for all enabled units and write them to the @@ -74,7 +82,7 @@ Open the units config file in \fB$EDITOR\fR. Falls back to \fBnvim\fR, .nf prefix: "" # prepended to all service names, e.g. "prod-" suffix: "" # appended to all service names, e.g. "-svc" -service_user: "" # User= in [Service]; defaults to current OS user +users: [] # root only: rootless users scanned by syncup units: - name: nginx @@ -85,7 +93,19 @@ units: delay: 0s # delay after previous order group enabled: true disabled_reason: "" # auto-set by syncup reconciliation + + - name: shop # compose project name + kind: stack # container (default) | stack + runtime: podman + project: shop + working_dir: /srv/shop + compose_files: [compose.yaml] + compose_bin: /usr/bin/podman-compose .fi +.PP +Stack units run \fBpodman\-compose up\fR/\fBstop\fR when compose_bin, +working_dir and compose_files exist; otherwise \fBpodman pod start\fR/\fBstop +pod_\fR\fIproject\fR. .SH GENERATED SERVICE FILES System units are written to \fI/etc/systemd/system/\fR and user units to \fI~/.config/systemd/user/\fR. @@ -127,13 +147,18 @@ sudo unitdore update nginx \-\-order 2 .I /etc/unitdore/units.yaml Default configuration file. .TP +.I ~/.config/unitdore/units.yaml +Configuration file when run as a normal user. +.TP .I /etc/systemd/system/unitdore-*.service Generated system unit files. .TP .I ~/.config/systemd/user/unitdore-*.service Generated rootless user unit files. .SH REQUIREMENTS -systemd; Podman and/or Docker (only installed runtimes are used). +systemd; Podman and/or Docker (only installed runtimes are used); +podman\-compose for compose\-mode stacks. +User units need linger to start at boot: \fBloginctl enable\-linger\fR \fIuser\fR. Root is required for system units; the relevant user for rootless units. .SH SEE ALSO .BR systemctl (1), diff --git a/runtime/docker.go b/runtime/docker.go index c05cea8..2cd49b9 100644 --- a/runtime/docker.go +++ b/runtime/docker.go @@ -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) } diff --git a/runtime/podman.go b/runtime/podman.go index 708cc5c..79abef4 100644 --- a/runtime/podman.go +++ b/runtime/podman.go @@ -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 } diff --git a/runtime/runtime.go b/runtime/runtime.go index 884f822..9009b64 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -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{}} } diff --git a/runtime/runtime_test.go b/runtime/runtime_test.go index e39ff19..473be2d 100644 --- a/runtime/runtime_test.go +++ b/runtime/runtime_test.go @@ -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")) + } +} diff --git a/systemd/generator.go b/systemd/generator.go index 6cde0c7..3bfc3b2 100644 --- a/systemd/generator.go +++ b/systemd/generator.go @@ -2,6 +2,8 @@ package systemd import ( "fmt" + "os" + "path/filepath" "strings" "text/template" @@ -12,12 +14,18 @@ const systemUnitTemplate = `# /etc/systemd/system/{{.ServiceName}} # Generated by unitdore — do not edit manually [Unit] -Description=Unitdore: {{.Unit.Name}} ({{.Unit.Runtime}}) +Description=Unitdore: {{.Unit.Name}} ({{.Label}}) After=network.target{{with .RuntimeService}} {{.}} Requires={{.}}{{end}} [Service] -Type=simple +Type={{.ServiceType}} +{{- if eq .ServiceType "oneshot"}} +RemainAfterExit=yes +{{- end}} +{{- with .WorkingDir}} +WorkingDirectory={{.}} +{{- end}} ExecStart={{.ExecStart}} ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}} {{- if .Unit.Restart}} @@ -36,11 +44,17 @@ const userUnitTemplate = `# ~/.config/systemd/user/{{.ServiceName}} # Generated by unitdore — do not edit manually [Unit] -Description=Unitdore: {{.Unit.Name}} ({{.Unit.Runtime}}) +Description=Unitdore: {{.Unit.Name}} ({{.Label}}) After=default.target [Service] -Type=simple +Type={{.ServiceType}} +{{- if eq .ServiceType "oneshot"}} +RemainAfterExit=yes +{{- end}} +{{- with .WorkingDir}} +WorkingDirectory={{.}} +{{- end}} ExecStart={{.ExecStart}} ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}} {{- if .Unit.Restart}} @@ -61,6 +75,9 @@ type templateData struct { ExecStart string ExecStop string RuntimeService string + Label string // shown in Description + ServiceType string // simple | oneshot + WorkingDir string } // ServiceName returns the systemd service name for a unit, with optional prefix/suffix. @@ -70,14 +87,25 @@ func ServiceName(u config.Unit, prefix, suffix string) string { // Generate produces the .service file content for a unit. func Generate(u config.Unit, prefix, suffix string) (string, error) { - execStart, execStop := buildExecCommands(u) - data := templateData{ ServiceName: ServiceName(u, prefix, suffix), Unit: u, - ExecStart: execStart, - ExecStop: execStop, RuntimeService: runtimeService(u.Runtime), + Label: u.Runtime, + ServiceType: "simple", + } + + switch { + case u.IsStack() && u.Command == "" && StackMode(u) == StackModeCompose: + data.ExecStart, data.ExecStop = buildComposeCommands(u) + data.Label = "podman-compose stack" + data.WorkingDir = u.WorkingDir + case u.IsStack() && u.Command == "": + data.ExecStart, data.ExecStop = buildPodCommands(u) + data.Label = "podman pod" + data.ServiceType = "oneshot" + default: + data.ExecStart, data.ExecStop = buildExecCommands(u) } tmplStr := systemUnitTemplate @@ -125,3 +153,66 @@ func buildExecCommands(u config.Unit) (start, stop string) { } return start, stop } + +// Stack modes: compose (podman-compose up/stop) with pod start/stop as fallback. +const ( + StackModeCompose = "compose" + StackModePod = "pod" +) + +// StackMode picks how a stack unit is started: compose when the compose binary, +// working dir and compose files all exist; otherwise pod start/stop. +func StackMode(u config.Unit) string { + if ComposeAvailable(u) { + return StackModeCompose + } + return StackModePod +} + +// ComposeAvailable reports whether the stack can be driven by podman-compose. +func ComposeAvailable(u config.Unit) bool { + if u.ComposeBin == "" || u.WorkingDir == "" { + return false + } + if !exists(u.ComposeBin) || !exists(u.WorkingDir) { + return false + } + for _, f := range u.ComposeFiles { + if !filepath.IsAbs(f) { + f = filepath.Join(u.WorkingDir, f) + } + if !exists(f) { + return false + } + } + return true +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func buildComposeCommands(u config.Unit) (start, stop string) { + args := []string{quoteArg(u.ComposeBin), "-p", quoteArg(u.Project)} + for _, f := range u.ComposeFiles { + args = append(args, "-f", quoteArg(f)) + } + base := strings.Join(args, " ") + return base + " up", base + " stop" +} + +func buildPodCommands(u config.Unit) (start, stop string) { + pod := quoteArg("pod_" + u.Project) + return "/usr/bin/podman pod start " + pod, "/usr/bin/podman pod stop " + pod +} + +// quoteArg double-quotes an argument for systemd command lines if it +// contains whitespace or quotes. +func quoteArg(s string) string { + if !strings.ContainsAny(s, " \t\"'\\") { + return s + } + r := strings.NewReplacer(`\`, `\\`, `"`, `\"`) + return `"` + r.Replace(s) + `"` +} diff --git a/systemd/generator_test.go b/systemd/generator_test.go index 3f8ce94..7e54866 100644 --- a/systemd/generator_test.go +++ b/systemd/generator_test.go @@ -1,6 +1,8 @@ package systemd import ( + "os" + "path/filepath" "strings" "testing" @@ -263,3 +265,129 @@ func TestBuildExecCommands(t *testing.T) { }) } } + +// newComposeStack creates a stack unit whose compose binary, working dir and +// compose file exist on disk (compose mode). +func newComposeStack(t *testing.T) config.Unit { + t.Helper() + dir := t.TempDir() + bin := filepath.Join(dir, "podman-compose") + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte("services: {}\n"), 0644); err != nil { + t.Fatal(err) + } + return config.Unit{ + Name: "shop", + Runtime: "podman", + Kind: config.KindStack, + Project: "shop", + WorkingDir: dir, + ComposeFiles: []string{"compose.yaml"}, + ComposeBin: bin, + Enabled: true, + } +} + +func TestStackMode(t *testing.T) { + u := newComposeStack(t) + if StackMode(u) != StackModeCompose { + t.Errorf("StackMode() = %q, want compose", StackMode(u)) + } + + missingFile := u + missingFile.ComposeFiles = []string{"gone.yaml"} + if StackMode(missingFile) != StackModePod { + t.Error("missing compose file should fall back to pod mode") + } + + missingBin := u + missingBin.ComposeBin = "" + if StackMode(missingBin) != StackModePod { + t.Error("missing compose binary should fall back to pod mode") + } + + missingDir := u + missingDir.WorkingDir = "/nonexistent/unitdore-test" + if StackMode(missingDir) != StackModePod { + t.Error("missing working dir should fall back to pod mode") + } +} + +func TestGenerate_StackCompose(t *testing.T) { + u := newComposeStack(t) + content, err := Generate(u, "", "") + if err != nil { + t.Fatalf("Generate() error: %v", err) + } + base := u.ComposeBin + " -p shop -f compose.yaml" + for _, want := range []string{ + "Description=Unitdore: shop (podman-compose stack)", + "Type=simple", + "WorkingDirectory=" + u.WorkingDir, + "ExecStart=" + base + " up", + "ExecStop=-" + base + " stop", + "WantedBy=multi-user.target", + } { + if !strings.Contains(content, want) { + t.Errorf("Generate() missing %q in output:\n%s", want, content) + } + } + if strings.Contains(content, "RemainAfterExit") || strings.Contains(content, "Requires=") { + t.Errorf("compose stack unit has unexpected directives:\n%s", content) + } +} + +func TestGenerate_StackPodFallback(t *testing.T) { + u := newComposeStack(t) + u.ComposeBin = "" + u.User = "hein" + content, err := Generate(u, "", "") + if err != nil { + t.Fatalf("Generate() error: %v", err) + } + for _, want := range []string{ + "Description=Unitdore: shop (podman pod)", + "Type=oneshot", + "RemainAfterExit=yes", + "ExecStart=/usr/bin/podman pod start pod_shop", + "ExecStop=-/usr/bin/podman pod stop pod_shop", + "WantedBy=default.target", + } { + if !strings.Contains(content, want) { + t.Errorf("Generate() missing %q in output:\n%s", want, content) + } + } + if strings.Contains(content, "WorkingDirectory=") { + t.Errorf("pod fallback should not set WorkingDirectory:\n%s", content) + } +} + +func TestGenerate_StackCustomCommand(t *testing.T) { + u := newComposeStack(t) + u.Command = "/usr/local/bin/stack.sh" + content, err := Generate(u, "", "") + if err != nil { + t.Fatalf("Generate() error: %v", err) + } + if !strings.Contains(content, "ExecStart=/usr/local/bin/stack.sh") { + t.Errorf("custom command should override stack commands:\n%s", content) + } +} + +func TestBuildComposeCommands_Quoting(t *testing.T) { + u := config.Unit{ + Project: "shop", + ComposeBin: "/usr/bin/podman-compose", + ComposeFiles: []string{"compose.yaml", "my override.yaml"}, + } + start, stop := buildComposeCommands(u) + wantBase := `/usr/bin/podman-compose -p shop -f compose.yaml -f "my override.yaml"` + if start != wantBase+" up" { + t.Errorf("start = %q", start) + } + if stop != wantBase+" stop" { + t.Errorf("stop = %q", stop) + } +} diff --git a/systemd/manager.go b/systemd/manager.go index 002ed8e..a658e98 100644 --- a/systemd/manager.go +++ b/systemd/manager.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/warkanum/unitdore/config" + "github.com/warkanum/unitdore/sysuser" ) const systemUnitDir = "/etc/systemd/system" @@ -19,7 +20,7 @@ func UnitPath(u config.Unit, prefix, suffix string) (string, error) { return filepath.Join(systemUnitDir, svcName), nil } // Rootless: write to the user's systemd config dir - home, err := userHomeDir(u.User) + home, err := sysuser.HomeDir(u.User) if err != nil { return "", err } @@ -130,27 +131,19 @@ func systemctl(user string, args ...string) error { } func runAsUser(user string, bin string, args ...string) ([]byte, error) { - full := append([]string{"-u", user, "--", bin}, args...) - return exec.Command("runuser", full...).Output() + cmd, err := sysuser.Command(user, bin, args...) + if err != nil { + return nil, err + } + return cmd.Output() } func runAsUserCmd(user string, bin string, args ...string) error { - full := append([]string{"-u", user, "--", bin}, args...) - cmd := exec.Command("runuser", full...) + cmd, err := sysuser.Command(user, bin, args...) + if err != nil { + return err + } cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } - -func userHomeDir(user string) (string, error) { - out, err := exec.Command("getent", "passwd", user).Output() - if err != nil { - return "", fmt.Errorf("looking up user %s: %w", user, err) - } - // passwd format: name:pass:uid:gid:gecos:home:shell - parts := strings.Split(strings.TrimSpace(string(out)), ":") - if len(parts) < 6 { - return "", fmt.Errorf("unexpected passwd entry for %s", user) - } - return parts[5], nil -} diff --git a/sysuser/sysuser.go b/sysuser/sysuser.go new file mode 100644 index 0000000..9210892 --- /dev/null +++ b/sysuser/sysuser.go @@ -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 +} diff --git a/sysuser/sysuser_test.go b/sysuser/sysuser_test.go new file mode 100644 index 0000000..ca5bff9 --- /dev/null +++ b/sysuser/sysuser_test.go @@ -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") + } +} diff --git a/testdata/stack/compose.yaml b/testdata/stack/compose.yaml new file mode 100644 index 0000000..81bba0b --- /dev/null +++ b/testdata/stack/compose.yaml @@ -0,0 +1,15 @@ +# Test stack for unitdore compose-stack detection. +# Run: podman-compose -p unitdore-test up -d +name: unitdore-test + +services: + web: + image: docker.io/library/alpine:latest + init: true + command: ["sleep", "infinity"] + depends_on: + - db + db: + image: docker.io/library/alpine:latest + init: true + command: ["sleep", "infinity"] diff --git a/testdata/stack/podman_ps.json b/testdata/stack/podman_ps.json new file mode 100644 index 0000000..8486e8a --- /dev/null +++ b/testdata/stack/podman_ps.json @@ -0,0 +1,100 @@ +[ + { + "AutoRemove": false, + "Command": [ + "sleep", + "infinity" + ], + "CreatedAt": "6 seconds ago", + "CIDFile": "", + "Exited": false, + "ExitedAt": -62135596800, + "ExitCode": 0, + "ExposedPorts": null, + "Id": "6d5a1e4d8ab39d64b26842a0e50b5943cfa62d71c70d63ce5c2ad92f598811b9", + "Image": "docker.io/library/alpine:latest", + "ImageID": "a40c03cbb81c59bfb0e0887ab0b1859727075da7b9cc576a1cec2c771f38c5fb", + "IsInfra": false, + "Labels": { + "PODMAN_SYSTEMD_UNIT": "podman-compose@unitdore-test.service", + "com.docker.compose.container-number": "1", + "com.docker.compose.project": "unitdore-test", + "com.docker.compose.project.config_files": "compose.yaml", + "com.docker.compose.project.working_dir": "/home/warkanum/dev/unitdore/testdata/stack", + "com.docker.compose.service": "db", + "io.podman.compose.config-hash": "1a54723a4ead4531f2ddce5aa7e85019b4ebd1612ca8973b85cd9026f4f87949", + "io.podman.compose.project": "unitdore-test", + "io.podman.compose.service": "db", + "io.podman.compose.version": "1.6.0" + }, + "Mounts": [], + "Names": [ + "unitdore-test_db_1" + ], + "Namespaces": { + + }, + "Networks": [ + "unitdore-test_default" + ], + "Pid": 75799, + "Pod": "235775e179ed8413b84b823ff63a812a053d929b8399b2d48cc19f9a4eeef410", + "PodName": "", + "Ports": null, + "Restarts": 0, + "Size": null, + "StartedAt": 1791823934, + "State": "running", + "Status": "Up 6 seconds", + "Created": 1791823933 + }, + { + "AutoRemove": false, + "Command": [ + "sleep", + "infinity" + ], + "CreatedAt": "6 seconds ago", + "CIDFile": "", + "Exited": false, + "ExitedAt": -62135596800, + "ExitCode": 0, + "ExposedPorts": null, + "Id": "b6b364f2a885baa583b7112cd7d37832e7f11f9091698ef76ca4c44259e501cc", + "Image": "docker.io/library/alpine:latest", + "ImageID": "a40c03cbb81c59bfb0e0887ab0b1859727075da7b9cc576a1cec2c771f38c5fb", + "IsInfra": false, + "Labels": { + "PODMAN_SYSTEMD_UNIT": "podman-compose@unitdore-test.service", + "com.docker.compose.container-number": "1", + "com.docker.compose.project": "unitdore-test", + "com.docker.compose.project.config_files": "compose.yaml", + "com.docker.compose.project.working_dir": "/home/warkanum/dev/unitdore/testdata/stack", + "com.docker.compose.service": "web", + "io.podman.compose.config-hash": "f38934df837f52ead7f2848b58acef10380a7929125a7c4f61199ad5b557302f", + "io.podman.compose.project": "unitdore-test", + "io.podman.compose.service": "web", + "io.podman.compose.version": "1.6.0" + }, + "Mounts": [], + "Names": [ + "unitdore-test_web_1" + ], + "Namespaces": { + + }, + "Networks": [ + "unitdore-test_default" + ], + "Pid": 75919, + "Pod": "235775e179ed8413b84b823ff63a812a053d929b8399b2d48cc19f9a4eeef410", + "PodName": "", + "Ports": null, + "Restarts": 0, + "Size": null, + "StartedAt": 1791823934, + "State": "running", + "Status": "Up 6 seconds", + "Created": 1791823933 + } +]