Author SHA1 Message Date
warkanum 173b4b2523 chore(release): update package version to 0.0.11
Release / test (push) Successful in 30s
Release / release (push) Successful in 47s
Release / pkg-aur (push) Successful in 37s
Release / pkg-rpm (push) Successful in 45s
Release / pkg-deb (push) Failing after 21s
2026-10-12 19:08:06 +02:00
warkanum cb4413b840 Merge branch 'main' of git.warky.dev:wdevs/unitdore 2026-10-12 19:07:38 +02:00
warkanum f0827fcdd7 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
2026-10-12 19:05:51 +02:00
warkanum cc4829046d ci(release): attach source archives to release and use them for AUR package
Gitea's /archive/ endpoint is disabled, which broke the AUR source download.
Build source tar.gz/zip with git archive, upload as release assets, and point
the PKGBUILD and checksum step at the release asset.
2026-10-12 18:27:53 +02:00
warkanum 13817f8d34 Merge pull request 'issue-5: regression tests for start/stop/startall/stopall CLI semantics' (#6) from issue-5-targeted-start-stop into main
Reviewed-on: #6
2026-08-20 08:39:13 +00:00
29 changed files with 1412 additions and 130 deletions
+8 -1
View File
@@ -53,6 +53,13 @@ jobs:
echo "Built $NAME" echo "Built $NAME"
done done
- name: Build source archives
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
PREFIX="unitdore-${TAG#v}"
git archive --format=tar.gz --prefix="${PREFIX}/" -o "unitdore-${TAG}-source.tar.gz" "$TAG"
git archive --format=zip --prefix="${PREFIX}/" -o "unitdore-${TAG}-source.zip" "$TAG"
- name: Create release and upload assets - name: Create release and upload assets
run: | run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}" TAG="${{ github.event.inputs.tag || github.ref_name }}"
@@ -166,7 +173,7 @@ jobs:
echo "Publishing AUR package version ${PKGVER}-${PKGREL}" echo "Publishing AUR package version ${PKGVER}-${PKGREL}"
# Compute SHA256 of the source archive from the same URL the PKGBUILD will download. # Compute SHA256 of the source archive from the same URL the PKGBUILD will download.
SHA=$(curl -fsSL "https://git.warky.dev/wdevs/unitdore/archive/v${PKGVER}.zip" | sha256sum | cut -d' ' -f1) SHA=$(curl -fsSL "https://git.warky.dev/wdevs/unitdore/releases/download/v${PKGVER}/unitdore-v${PKGVER}-source.zip" | sha256sum | cut -d' ' -f1)
# Update PKGBUILD — keep remote source URL, bump version/checksum, and increment pkgrel for same-version rebuilds. # Update PKGBUILD — keep remote source URL, bump version/checksum, and increment pkgrel for same-version rebuilds.
sed -e "s/^pkgver=.*/pkgver=${PKGVER}/" \ sed -e "s/^pkgver=.*/pkgver=${PKGVER}/" \
+13 -1
View File
@@ -30,7 +30,9 @@ make release-version # Bump patch version, commit, tag, and push
cmd/ # Cobra commands (edit, install, list, root, start, startall, cmd/ # Cobra commands (edit, install, list, root, start, startall,
# status, stop, stopall, syncup, uninstall, update) # status, stop, stopall, syncup, uninstall, update)
config/ # Config struct + YAML load/save (units.yaml) 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 systemd/ # .service file generator and systemd manager
docs/ # generated-units.md (unit file examples), unitdore.1 (man page) 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 ```yaml
prefix: "" # prepended to service file name (e.g. "prod-") prefix: "" # prepended to service file name (e.g. "prod-")
suffix: "" # appended to service file name (e.g. "-svc") suffix: "" # appended to service file name (e.g. "-svc")
users: [] # root only: rootless users scanned by syncup
units: units:
- name: nginx - name: nginx
@@ -61,8 +64,15 @@ units:
restart: false # Restart=on-failure restart: false # Restart=on-failure
restart_sec: 0 # RestartSec restart_sec: 0 # RestartSec
restart_retries: 0 # StartLimitBurst 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 ### Generated service file locations
| Scenario | Path | | Scenario | Path |
@@ -82,6 +92,8 @@ units:
Tests exist in: Tests exist in:
- `config/config_test.go` - `config/config_test.go`
- `runtime/runtime_test.go` - `runtime/runtime_test.go`
- `cmd/syncup_test.go`
- `sysuser/sysuser_test.go`
- `systemd/generator_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. Always run `make test` before marking a task complete. Add tests for new behaviour in the same package as the code under test.
+4 -1
View File
@@ -27,7 +27,8 @@ Think of it as: **config → systemd units → running services.**
## Non-Goals ## Non-Goals
- Not a container runtime (doesn't replace Podman/Docker) - 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 - Not a daemon — runs on demand
--- ---
@@ -172,6 +173,8 @@ unitdore/
| 5 | Reconcile (auto-disable missing units) in syncup | | 5 | Reconcile (auto-disable missing units) in syncup |
| 6 | User unit support (rootless containers) | | 6 | User unit support (rootless containers) |
| 7 | Polish: `edit`, error handling, --dry-run flag | | 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) |
--- ---
+38 -2
View File
@@ -2,7 +2,7 @@
> *A door you open and close for container units.* > *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 ## 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). 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 <project>`)
- 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 ### Edit the config
```bash ```bash
@@ -104,6 +109,7 @@ oldthing podman hein no no — container
```yaml ```yaml
prefix: "" # optional: prepended to all service names, e.g. "prod-" prefix: "" # optional: prepended to all service names, e.g. "prod-"
suffix: "" # optional: appended to all service names, e.g. "-svc" suffix: "" # optional: appended to all service names, e.g. "-svc"
users: [hein] # root only: rootless users scanned by syncup
units: units:
- name: nginx - name: nginx
@@ -121,8 +127,36 @@ units:
order: 2 order: 2
delay: 5s delay: 5s
enabled: true 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 <proj> -f <files> up` / `stop`, `Type=simple` |
| any missing | `podman pod start/stop pod_<proj>`, `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 <user>` (`install` warns).
### Prefix and suffix ### Prefix and suffix
Set `prefix` and/or `suffix` to namespace your service files: 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) - User units (rootless)
- Docker vs Podman - Docker vs Podman
- Custom commands - Custom commands
- Compose stacks (compose + pod fallback)
- Prefix/suffix naming - Prefix/suffix naming
- Startup ordering - Startup ordering
- Unnamed containers (short ID fallback) - Unnamed containers (short ID fallback)
@@ -175,7 +210,7 @@ WantedBy=multi-user.target
## Flags ## 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 ## Requirements
@@ -183,4 +218,5 @@ WantedBy=multi-user.target
- Go 1.21+ - Go 1.21+
- systemd - systemd
- Podman and/or Docker (only what you have installed is used) - 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 - Root for system units; the relevant user for rootless units
+25
View File
@@ -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)
}
+2
View File
@@ -35,6 +35,7 @@ func runInstall(cmd *cobra.Command, args []string) error {
installed := 0 installed := 0
skipped := 0 skipped := 0
removed := 0 removed := 0
lingerSeen := map[string]bool{}
for _, u := range cfg.Units { for _, u := range cfg.Units {
if !u.Enabled { if !u.Enabled {
@@ -73,6 +74,7 @@ func runInstall(cmd *cobra.Command, args []string) error {
} else { } else {
path, _ := systemd.UnitPath(u, prefix, suffix) path, _ := systemd.UnitPath(u, prefix, suffix)
fmt.Printf(" ✓ installed: %s\n", path) fmt.Printf(" ✓ installed: %s\n", path)
lingerHint(lingerSeen, u)
installed++ installed++
} }
} }
+2
View File
@@ -35,6 +35,7 @@ func runInstallall(cmd *cobra.Command, args []string) error {
installed := 0 installed := 0
skipped := 0 skipped := 0
removed := 0 removed := 0
lingerSeen := map[string]bool{}
for _, u := range cfg.Units { for _, u := range cfg.Units {
if !u.Enabled { if !u.Enabled {
@@ -72,6 +73,7 @@ func runInstallall(cmd *cobra.Command, args []string) error {
} else { } else {
path, _ := systemd.UnitPath(u, prefix, suffix) path, _ := systemd.UnitPath(u, prefix, suffix)
fmt.Printf(" ✓ installed: %s\n", path) fmt.Printf(" ✓ installed: %s\n", path)
lingerHint(lingerSeen, u)
installed++ installed++
} }
} }
+1 -1
View File
@@ -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"} cols := []string{"NAME", "RUNTIME", "ENABLED", "INSTALLED", "SERVICE FILE"}
+20 -4
View File
@@ -5,9 +5,11 @@ import (
"os" "os"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/sysuser"
) )
var version = "0.0.10" var version = "0.0.11"
var configPath string var configPath string
@@ -16,9 +18,23 @@ var rootCmd = &cobra.Command{
Short: "A door you open and close for container units", Short: "A door you open and close for container units",
Long: `Unitdore manages container units via systemd. Long: `Unitdore manages container units via systemd.
It discovers running containers, stores them in a config file, It discovers running containers and podman-compose stacks, stores them in a
and generates + manages systemd .service units for each one.`, 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, 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() { func Execute() {
@@ -29,5 +45,5 @@ func Execute() {
} }
func init() { 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)")
} }
+1 -1
View File
@@ -76,7 +76,7 @@ func runStatus(cmd *cobra.Command, args []string) error {
rows = append(rows, statusRow{ rows = append(rows, statusRow{
Name: u.Name, Name: u.Name,
Runtime: u.Runtime, Runtime: runtimeLabel(u),
User: user, User: user,
Enabled: enabled, Enabled: enabled,
Installed: installed, Installed: installed,
+233 -56
View File
@@ -2,11 +2,15 @@ package cmd
import ( import (
"fmt" "fmt"
"io"
"os" "os"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/runtime" "github.com/warkanum/unitdore/runtime"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/sysuser"
) )
var syncupCmd = &cobra.Command{ var syncupCmd = &cobra.Command{
@@ -15,8 +19,17 @@ var syncupCmd = &cobra.Command{
Long: `Syncup queries all available runtimes for running containers and adds Long: `Syncup queries all available runtimes for running containers and adds
any new ones to the config file. Existing entries are never removed. any new ones to the config file. Existing entries are never removed.
It also reconciles: if a configured unit's container no longer exists, Podman is queried first. Docker is only a fallback: a container name found
it is marked disabled with a reason.`, 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, RunE: runSyncup,
} }
@@ -24,71 +37,235 @@ func init() {
rootCmd.AddCommand(syncupCmd) 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 { func runSyncup(cmd *cobra.Command, args []string) error {
cfg, err := config.LoadOrEmpty(configPath) cfg, err := config.LoadOrEmpty(configPath)
if err != nil { if err != nil {
return err return err
} }
added := 0 containers := discover(syncScopes(cfg))
unchanged := 0
discovered := map[string]bool{}
// Discover running containers across all runtimes var st syncStats
for _, rt := range runtime.Available() { running := mergeDiscovered(cfg, containers, composeBinFor, os.Stdout, &st)
containers, err := rt.ListRunning() reconcile(cfg, running, unitExists, os.Stdout, &st)
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++
}
}
if err := cfg.Save(configPath); err != nil { if err := cfg.Save(configPath); err != nil {
return err 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 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++
}
}
}
+185
View File
@@ -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)
}
}
+22
View File
@@ -10,6 +10,17 @@ import (
const DefaultConfigPath = "/etc/unitdore/units.yaml" 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. // Unit represents a single managed container unit.
type Unit struct { type Unit struct {
Name string `yaml:"name"` Name string `yaml:"name"`
@@ -23,13 +34,24 @@ type Unit struct {
Restart bool `yaml:"restart,omitempty"` // enable Restart=on-failure Restart bool `yaml:"restart,omitempty"` // enable Restart=on-failure
RestartSec int `yaml:"restart_sec,omitempty"` // seconds between restarts RestartSec int `yaml:"restart_sec,omitempty"` // seconds between restarts
RestartRetries int `yaml:"restart_retries,omitempty"` // max restart attempts (StartLimitBurst) 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. // Config is the root config structure.
type Config struct { type Config struct {
Units []Unit `yaml:"units"` Units []Unit `yaml:"units"`
Prefix string `yaml:"prefix,omitempty"` // prepended to generated service name, e.g. "prod-" 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" 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. // Load reads and parses the config file at path.
+39
View File
@@ -144,3 +144,42 @@ func TestDisabledReason_Roundtrip(t *testing.T) {
t.Errorf("expected disabled_reason to survive roundtrip, got: %q", loaded.Units[0].DisabledReason) 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)
}
}
+53
View File
@@ -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_<project>`.
```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 ## Quick Reference: Service File Locations
| Scenario | Path | | Scenario | Path |
+29 -4
View File
@@ -9,18 +9,26 @@ unitdore \- manage container units via systemd
.SH DESCRIPTION .SH DESCRIPTION
.B unitdore .B unitdore
bridges your container runtime (Podman, Docker) and systemd. bridges your container runtime (Podman, Docker) and systemd.
It discovers running containers, stores them in a config file, and generates It discovers running containers and podman\-compose stacks, stores them in a
and manages systemd \fI.service\fR units for each one. 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 .SH OPTIONS
.TP .TP
.BR \-\-config " " \fIpath\fR .BR \-\-config " " \fIpath\fR
Path to the units config file. Default: \fI/etc/unitdore/units.yaml\fR Path to the units config file. Default: \fI/etc/unitdore/units.yaml\fR
(non\-root: \fI~/.config/unitdore/units.yaml\fR)
.SH COMMANDS .SH COMMANDS
.TP .TP
.B syncup .B syncup
Query all available runtimes for running containers and add any new ones to 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 the config. Reconciles existing entries: if a configured unit's container no
longer exists it is marked disabled with a reason. 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 .TP
.B install .B install
Generate \fI.service\fR files for all enabled units and write them to the 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 .nf
prefix: "" # prepended to all service names, e.g. "prod-" prefix: "" # prepended to all service names, e.g. "prod-"
suffix: "" # appended to all service names, e.g. "-svc" 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: units:
- name: nginx - name: nginx
@@ -85,7 +93,19 @@ units:
delay: 0s # delay after previous order group delay: 0s # delay after previous order group
enabled: true enabled: true
disabled_reason: "" # auto-set by syncup reconciliation 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 .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 .SH GENERATED SERVICE FILES
System units are written to \fI/etc/systemd/system/\fR and user units to System units are written to \fI/etc/systemd/system/\fR and user units to
\fI~/.config/systemd/user/\fR. \fI~/.config/systemd/user/\fR.
@@ -127,13 +147,18 @@ sudo unitdore update nginx \-\-order 2
.I /etc/unitdore/units.yaml .I /etc/unitdore/units.yaml
Default configuration file. Default configuration file.
.TP .TP
.I ~/.config/unitdore/units.yaml
Configuration file when run as a normal user.
.TP
.I /etc/systemd/system/unitdore-*.service .I /etc/systemd/system/unitdore-*.service
Generated system unit files. Generated system unit files.
.TP .TP
.I ~/.config/systemd/user/unitdore-*.service .I ~/.config/systemd/user/unitdore-*.service
Generated rootless user unit files. Generated rootless user unit files.
.SH REQUIREMENTS .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. Root is required for system units; the relevant user for rootless units.
.SH SEE ALSO .SH SEE ALSO
.BR systemctl (1), .BR systemctl (1),
+5 -5
View File
@@ -1,6 +1,6 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev> # Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=unitdore pkgname=unitdore
pkgver=0.0.10 pkgver=0.0.11
pkgrel=1 pkgrel=1
pkgdesc="A door you open and close for container units — manage containers via systemd" pkgdesc="A door you open and close for container units — manage containers via systemd"
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
@@ -12,11 +12,11 @@ optdepends=(
'docker: Docker container runtime support' 'docker: Docker container runtime support'
) )
makedepends=('go') makedepends=('go')
source=("$pkgname-$pkgver.zip::$url/archive/v$pkgver.zip") source=("$pkgname-$pkgver.zip::$url/releases/download/v$pkgver/$pkgname-v$pkgver-source.zip")
sha256sums=('SKIP') sha256sums=('SKIP')
build() { build() {
cd "$pkgname" cd "$pkgname-$pkgver"
export CGO_ENABLED=0 export CGO_ENABLED=0
go build \ go build \
-trimpath \ -trimpath \
@@ -25,12 +25,12 @@ build() {
} }
check() { check() {
cd "$pkgname" cd "$pkgname-$pkgver"
go test ./... go test ./...
} }
package() { package() {
cd "$pkgname" cd "$pkgname-$pkgver"
# Binary # Binary
install -Dm755 "$pkgname" "$pkgdir/usr/bin/$pkgname" install -Dm755 "$pkgname" "$pkgdir/usr/bin/$pkgname"
+1 -1
View File
@@ -1,5 +1,5 @@
Name: unitdore Name: unitdore
Version: 0.0.10 Version: 0.0.11
Release: 1%{?dist} Release: 1%{?dist}
Summary: Manage container units via systemd Summary: Manage container units via systemd
+59 -7
View File
@@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"os/exec" "os/exec"
"strings" "strings"
"github.com/warkanum/unitdore/sysuser"
) )
type Docker struct{} type Docker struct{}
@@ -17,21 +19,44 @@ type dockerContainer struct {
Image string `json:"Image"` Image string `json:"Image"`
Command string `json:"Command"` Command string `json:"Command"`
State string `json:"State"` 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") bin, err := exec.LookPath("docker")
if err != nil { 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 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 { if err != nil {
// Docker installed but daemon not running — treat as no containers // Docker installed but daemon not running — treat as no containers
return nil, nil 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 var containers []Container
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" { if line == "" {
@@ -57,18 +82,45 @@ func (d *Docker) ListRunning() ([]Container, error) {
Image: c.Image, Image: c.Image,
Command: c.Command, Command: c.Command,
Runtime: "docker", Runtime: "docker",
Labels: parseDockerLabels(c.Labels),
}) })
} }
return containers, nil return containers
} }
func (d *Docker) Exists(name string) (bool, error) { // parseDockerLabels parses docker's "k=v,k=v" label string. A segment without
bin, err := exec.LookPath("docker") // "=" 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 { if err != nil {
return false, err
}
if cmd == nil {
return false, nil return false, nil
} }
out, err := exec.Command(bin, "ps", "--format", "{{.Names}}").Output() out, err := cmd.Output()
if err != nil { if err != nil {
return false, fmt.Errorf("docker ps: %w", err) return false, fmt.Errorf("docker ps: %w", err)
} }
+60 -7
View File
@@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"os/exec" "os/exec"
"strings" "strings"
"github.com/warkanum/unitdore/sysuser"
) )
type Podman struct{} type Podman struct{}
@@ -17,25 +19,51 @@ type podmanContainer struct {
Image string `json:"Image"` Image string `json:"Image"`
Command []string `json:"Command"` Command []string `json:"Command"`
State string `json:"State"` 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") bin, err := exec.LookPath("podman")
if err != nil { 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 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 { if err != nil {
// Podman installed but not usable — treat as no containers // Podman installed but not usable — treat as no containers
return nil, nil return nil, nil
} }
var raw []podmanContainer containers, err := parsePodmanPS(out)
if err := json.Unmarshal(out, &raw); err != nil { if err != nil {
// Podman installed but output is not valid JSON (e.g. OCI runtime misconfigured) // Podman installed but output is not valid JSON (e.g. OCI runtime misconfigured)
return nil, nil 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 var containers []Container
for _, c := range raw { for _, c := range raw {
@@ -58,18 +86,22 @@ func (p *Podman) ListRunning() ([]Container, error) {
Image: c.Image, Image: c.Image,
Command: strings.Join(c.Command, " "), Command: strings.Join(c.Command, " "),
Runtime: "podman", Runtime: "podman",
Labels: c.Labels,
}) })
} }
return containers, nil return containers, nil
} }
func (p *Podman) Exists(name string) (bool, error) { func (p *Podman) Exists(user, name string) (bool, error) {
bin, err := exec.LookPath("podman") cmd, err := podmanCmd(user, "ps", "--format", "{{.Names}}")
if err != nil { if err != nil {
return false, err
}
if cmd == nil {
return false, nil return false, nil
} }
out, err := exec.Command(bin, "ps", "--format", "{{.Names}}").Output() out, err := cmd.Output()
if err != nil { if err != nil {
return false, fmt.Errorf("podman ps: %w", err) return false, fmt.Errorf("podman ps: %w", err)
} }
@@ -81,3 +113,24 @@ func (p *Podman) Exists(name string) (bool, error) {
} }
return false, nil 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 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. // Container represents a discovered running container.
type Container struct { type Container struct {
Name string Name string
Image string Image string
Command string Command string
Runtime string // "podman" or "docker" 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. // Runtime is the interface all container runtimes must implement.
// user selects the scope: "" = root/system, otherwise a rootless user.
type Runtime interface { type Runtime interface {
Name() string Name() string
ListRunning() ([]Container, error) ListRunning(user string) ([]Container, error)
Exists(name string) (bool, 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 { func Available() []Runtime {
return []Runtime{&Podman{}, &Docker{}} return []Runtime{&Podman{}, &Docker{}}
} }
+80 -2
View File
@@ -1,6 +1,8 @@
package runtime package runtime
import ( import (
"os"
"reflect"
"testing" "testing"
) )
@@ -53,7 +55,7 @@ func TestAvailable_ReturnsRuntimes(t *testing.T) {
func TestPodman_ListRunning_NoBinary(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 // This test is meaningful if podman isn't installed; if it is, we just check no error occurs
p := &Podman{} p := &Podman{}
containers, err := p.ListRunning() containers, err := p.ListRunning("")
if err != nil { if err != nil {
t.Errorf("ListRunning() should not error when podman is absent: %v", err) 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) { func TestDocker_ListRunning_NoBinary(t *testing.T) {
d := &Docker{} d := &Docker{}
containers, err := d.ListRunning() containers, err := d.ListRunning("")
if err != nil { if err != nil {
t.Errorf("ListRunning() should not error when docker is absent: %v", err) t.Errorf("ListRunning() should not error when docker is absent: %v", err)
} }
_ = containers _ = 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"))
}
}
+99 -8
View File
@@ -2,6 +2,8 @@ package systemd
import ( import (
"fmt" "fmt"
"os"
"path/filepath"
"strings" "strings"
"text/template" "text/template"
@@ -12,12 +14,18 @@ const systemUnitTemplate = `# /etc/systemd/system/{{.ServiceName}}
# Generated by unitdore — do not edit manually # Generated by unitdore — do not edit manually
[Unit] [Unit]
Description=Unitdore: {{.Unit.Name}} ({{.Unit.Runtime}}) Description=Unitdore: {{.Unit.Name}} ({{.Label}})
After=network.target{{with .RuntimeService}} {{.}} After=network.target{{with .RuntimeService}} {{.}}
Requires={{.}}{{end}} Requires={{.}}{{end}}
[Service] [Service]
Type=simple Type={{.ServiceType}}
{{- if eq .ServiceType "oneshot"}}
RemainAfterExit=yes
{{- end}}
{{- with .WorkingDir}}
WorkingDirectory={{.}}
{{- end}}
ExecStart={{.ExecStart}} ExecStart={{.ExecStart}}
ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}} ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}}
{{- if .Unit.Restart}} {{- if .Unit.Restart}}
@@ -36,11 +44,17 @@ const userUnitTemplate = `# ~/.config/systemd/user/{{.ServiceName}}
# Generated by unitdore — do not edit manually # Generated by unitdore — do not edit manually
[Unit] [Unit]
Description=Unitdore: {{.Unit.Name}} ({{.Unit.Runtime}}) Description=Unitdore: {{.Unit.Name}} ({{.Label}})
After=default.target After=default.target
[Service] [Service]
Type=simple Type={{.ServiceType}}
{{- if eq .ServiceType "oneshot"}}
RemainAfterExit=yes
{{- end}}
{{- with .WorkingDir}}
WorkingDirectory={{.}}
{{- end}}
ExecStart={{.ExecStart}} ExecStart={{.ExecStart}}
ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}} ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}}
{{- if .Unit.Restart}} {{- if .Unit.Restart}}
@@ -61,6 +75,9 @@ type templateData struct {
ExecStart string ExecStart string
ExecStop string ExecStop string
RuntimeService 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. // 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. // Generate produces the .service file content for a unit.
func Generate(u config.Unit, prefix, suffix string) (string, error) { func Generate(u config.Unit, prefix, suffix string) (string, error) {
execStart, execStop := buildExecCommands(u)
data := templateData{ data := templateData{
ServiceName: ServiceName(u, prefix, suffix), ServiceName: ServiceName(u, prefix, suffix),
Unit: u, Unit: u,
ExecStart: execStart,
ExecStop: execStop,
RuntimeService: runtimeService(u.Runtime), 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 tmplStr := systemUnitTemplate
@@ -125,3 +153,66 @@ func buildExecCommands(u config.Unit) (start, stop string) {
} }
return start, stop 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) + `"`
}
+128
View File
@@ -1,6 +1,8 @@
package systemd package systemd
import ( import (
"os"
"path/filepath"
"strings" "strings"
"testing" "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)
}
}
+11 -18
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/sysuser"
) )
const systemUnitDir = "/etc/systemd/system" 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 return filepath.Join(systemUnitDir, svcName), nil
} }
// Rootless: write to the user's systemd config dir // Rootless: write to the user's systemd config dir
home, err := userHomeDir(u.User) home, err := sysuser.HomeDir(u.User)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -130,27 +131,19 @@ func systemctl(user string, args ...string) error {
} }
func runAsUser(user string, bin string, args ...string) ([]byte, error) { func runAsUser(user string, bin string, args ...string) ([]byte, error) {
full := append([]string{"-u", user, "--", bin}, args...) cmd, err := sysuser.Command(user, bin, args...)
return exec.Command("runuser", full...).Output() if err != nil {
return nil, err
}
return cmd.Output()
} }
func runAsUserCmd(user string, bin string, args ...string) error { func runAsUserCmd(user string, bin string, args ...string) error {
full := append([]string{"-u", user, "--", bin}, args...) cmd, err := sysuser.Command(user, bin, args...)
cmd := exec.Command("runuser", full...) if err != nil {
return err
}
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
return cmd.Run() 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
}
+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")
}
}
+15
View File
@@ -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"]
+100
View File
@@ -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
}
]