Author SHA1 Message Date
warkanum d1d0889eb3 build: bump Gentoo and Void versions in release-version
Move version bumping into scripts/bump-version.sh, rename the ebuild per release, reset the Void checksum, and add make pkg-checksums to fill it after the release.
2026-10-12 20:57:37 +02:00
warkanum bc85901287 feat(pkg): add Gentoo ebuild and Void Linux template
Anchor the .gitignore binary entry so pkg/*/unitdore is tracked. Document Gentoo and Void installs in the README.
2026-10-12 20:57:37 +02:00
warkanum df9b226c7e feat: OpenRC and runit init backends behind an Init interface
Extract init-neutral unit resolution into service/, add initsys.Init with systemd, openrc and runit backends, selected by 'init:' config or autodetect. OpenRC and runit are root only; rootless units run as system services as that user. Add docs and generated examples for all three.
2026-10-12 20:19:58 +02:00
warkanum 95143dd998 feat: script and exec runtimes with configurable shell
Add runtime: script (shell file, default /bin/bash, optional stop_script) and runtime: exec (executable with args, optional stop_executable). Both support service_type simple|oneshot and working_dir.
2026-10-12 19:45:54 +02:00
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
54 changed files with 3962 additions and 280 deletions

No files matched your search

+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}/" \
+1 -1
View File
@@ -1,3 +1,3 @@
unitdore /unitdore
*.exe *.exe
.codex .codex
+36 -4
View File
@@ -1,6 +1,6 @@
## Project Overview ## Project Overview
**Unitdore** is a Go CLI tool that bridges container runtimes (Podman, Docker) and systemd. It discovers running containers, stores them in a YAML config, and generates + manages systemd `.service` unit files for each one. **Unitdore** is a Go CLI tool that bridges container runtimes (Podman, Docker) and the init system (systemd, OpenRC, runit). It discovers running containers, stores them in a YAML config, and generates + manages service files for each one.
# Agent Rules # Agent Rules
@@ -30,8 +30,14 @@ 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
systemd/ # .service file generator and systemd manager sysuser/ # run commands as root/user (runuser), home/linger lookup
testdata/stack # podman-compose test stack + captured `podman ps` fixture
service/ # init-neutral unit resolution: commands, quoting, user env
initsys/ # Init interface, backend selection (config init: / autodetect)
systemd/ # .service generator + systemctl manager (Backend)
openrc/ # /etc/init.d script generator + rc-service manager (Backend)
runit/ # /etc/sv run/finish generator + sv manager (Backend)
docs/ # generated-units.md (unit file examples), unitdore.1 (man page) docs/ # generated-units.md (unit file examples), unitdore.1 (man page)
``` ```
@@ -39,19 +45,25 @@ docs/ # generated-units.md (unit file examples), unitdore.1 (man page)
- **`config`** — `Unit` and `Config` structs; `Load`, `Save`, `FindUnit`, `AddUnit` - **`config`** — `Unit` and `Config` structs; `Load`, `Save`, `FindUnit`, `AddUnit`
- **`runtime`** — `ContainerRuntime` interface; `docker.go`, `podman.go` implementations - **`runtime`** — `ContainerRuntime` interface; `docker.go`, `podman.go` implementations
- **`service`** — `Resolve(unit)` → start/stop commands, type, workdir; shared by all backends
- **`initsys`** — `Init` interface; `New(cfg)` picks backend; cmd/ only talks to `Init`
- **`systemd`** — `generator.go` builds `.service` file content; `manager.go` calls `systemctl` - **`systemd`** — `generator.go` builds `.service` file content; `manager.go` calls `systemctl`
- **`openrc`**, **`runit`** — same role; root only; tests stub command execution via package vars
### Config file ### Config file
**Location:** `/etc/unitdore/units.yaml` **Location:** `/etc/unitdore/units.yaml`
```yaml ```yaml
init: "" # systemd | openrc | runit (empty = autodetect)
service_dir: "" # runit: supervised dir (empty = autodetect)
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
runtime: podman # podman | docker runtime: podman # podman | docker | script | exec
user: "" # empty = system unit; set for rootless user unit user: "" # empty = system unit; set for rootless user unit
command: "" # override ExecStart entirely command: "" # override ExecStart entirely
order: 1 # startup order, lower = earlier; same = parallel order: 1 # startup order, lower = earlier; same = parallel
@@ -61,8 +73,22 @@ 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
script: "" # script: file run via shell (ExecStart)
stop_script: "" # script: optional ExecStop
shell: "" # script: interpreter, default /bin/bash
executable: "" # exec: binary run as ExecStart
args: [] # exec: arguments
stop_executable: "" # exec: optional ExecStop
service_type: "" # script/exec: simple (default) | oneshot
``` ```
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 +108,12 @@ 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`
- `service/spec_test.go`
- `initsys/initsys_test.go`
- `openrc/openrc_test.go`
- `runit/runit_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.
+8 -6
View File
@@ -4,7 +4,7 @@ CONFIG_DIR := /etc/unitdore
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -ldflags "-X github.com/warkanum/unitdore/cmd.version=$(VERSION)" LDFLAGS := -ldflags "-X github.com/warkanum/unitdore/cmd.version=$(VERSION)"
.PHONY: all build install uninstall test lint clean release-version .PHONY: all build install uninstall test lint clean release-version pkg-checksums
all: build all: build
@@ -42,7 +42,7 @@ lint:
clean: clean:
rm -f $(BINARY) rm -f $(BINARY)
## release-version: bump patch version, update source versions, commit, tag, and push ## release-version: bump patch version, update source and package versions, commit, tag, and push
release-version: release-version:
@CURRENT=$$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0"); \ @CURRENT=$$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0"); \
MAJOR=$$(echo $$CURRENT | sed 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\1/'); \ MAJOR=$$(echo $$CURRENT | sed 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\1/'); \
@@ -51,15 +51,17 @@ release-version:
NEXT="v$$MAJOR.$$MINOR.$$((PATCH + 1))"; \ NEXT="v$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
PKGVER="$$MAJOR.$$MINOR.$$((PATCH + 1))"; \ PKGVER="$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
echo "Current: $$CURRENT → Next: $$NEXT"; \ echo "Current: $$CURRENT → Next: $$NEXT"; \
sed -i "s/^var version = .*/var version = \"$$PKGVER\"/" cmd/root.go; \ scripts/bump-version.sh "$$PKGVER"; \
sed -i "s/^pkgver=.*/pkgver=$$PKGVER/" pkg/arch/PKGBUILD; \ git add -A cmd/root.go pkg; \
sed -i "s/^Version:.*/Version: $$PKGVER/" pkg/centos/unitdore.spec; \
git add cmd/root.go pkg/arch/PKGBUILD pkg/centos/unitdore.spec; \
git commit -m "chore(release): update package version to $$PKGVER"; \ git commit -m "chore(release): update package version to $$PKGVER"; \
git tag -a "$$NEXT" -m "Release $$NEXT"; \ git tag -a "$$NEXT" -m "Release $$NEXT"; \
git push origin HEAD "$$NEXT"; \ git push origin HEAD "$$NEXT"; \
echo "Pushed $$NEXT — release workflow triggered" echo "Pushed $$NEXT — release workflow triggered"
## pkg-checksums: fill the Void checksum from the published release (run after release)
pkg-checksums:
scripts/update-checksums.sh
## help: show this help ## help: show this help
help: help:
@grep -E '^## ' Makefile | sed 's/## / /' @grep -E '^## ' Makefile | sed 's/## / /'
+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) |
--- ---
+116 -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 your init system (systemd, OpenRC, runit). It discovers running containers and podman-compose stacks, stores them in a config file, and generates + manages a service for each one.
## Install ## Install
@@ -30,6 +30,26 @@ sudo rpm -i unitdore-*.rpm
sudo dnf install unitdore-*.rpm sudo dnf install unitdore-*.rpm
``` ```
**Gentoo (OpenRC / systemd)**
Ebuild in `pkg/gentoo/app-containers/unitdore/`. Copy it into a local overlay, then:
```bash
ebuild unitdore-<ver>.ebuild manifest # once per version
sudo emerge app-containers/unitdore
```
**Void Linux (runit)**
Template in `pkg/void/srcpkgs/unitdore/`. Copy it into a `void-packages` checkout, then:
```bash
./xbps-src pkg unitdore
sudo xbps-install --repository=hostdir/binpkgs unitdore
```
Set `init: runit` / `init: openrc` in the config if autodetect picks the wrong one.
**From source** **From source**
```bash ```bash
@@ -54,6 +74,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
@@ -102,8 +127,11 @@ oldthing podman hein no no — container
**Location:** `/etc/unitdore/units.yaml` **Location:** `/etc/unitdore/units.yaml`
```yaml ```yaml
init: "" # optional: systemd | openrc | runit (empty = autodetect)
service_dir: "" # optional: runit supervised dir (empty = /var/service or /etc/service)
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 +149,92 @@ 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
- name: backup # shell script unit
runtime: script
script: /opt/scripts/backup.sh # required
stop_script: /opt/scripts/stop.sh # optional → ExecStop
shell: /bin/bash # optional, default /bin/bash
service_type: simple # simple (default) | oneshot
working_dir: /opt/scripts # optional
order: 4
enabled: true
- name: agent # executable unit
runtime: exec
executable: /opt/agent/bin/agent # required
args: [--config, /etc/agent.toml] # optional
stop_executable: /opt/agent/bin/stop # optional → ExecStop
order: 5
enabled: true
``` ```
### Init systems
Set `init:` in the config, or leave empty to autodetect (`/run/systemd/system` → systemd, `/run/openrc` → openrc, `/etc/runit` → runit, else systemd).
| | systemd | openrc | runit |
|---|---|---|---|
| Service file | `/etc/systemd/system/unitdore-<n>.service` | `/etc/init.d/unitdore-<n>` | `/etc/sv/unitdore-<n>/{run,finish}` + symlink in `service_dir` |
| Start / stop | `systemctl` | `rc-service` | `sv` |
| Enable at boot | `systemctl enable` | `rc-update add … default` | remove `down` file |
| Installed state | disabled until enabled | not in runlevel | symlinked with `down` file (no autostart) |
| Rootless `user:` | `systemctl --user` unit (needs linger) | system service, `command_user` | system service, `chpst -u` |
| Non-root use | yes (own user units) | **root only** | **root only** |
| `restart*` | `Restart=on-failure`, `StartLimitBurst` | `supervise-daemon` `respawn_*` (always respawns) | `finish`: on-failure, `RestartSec`; **no retry limit** |
| `docker` dependency | `Requires=docker.service` | `need docker` | none |
| `oneshot` | `RemainAfterExit` | `start()`/`stop()` functions | run once, then `tail -f /dev/null` |
- Rootless units on openrc/runit set `HOME` and `XDG_RUNTIME_DIR=/run/user/<uid>` and create that dir if missing.
- `command` / `args` use systemd quoting; they are re-quoted for sh on openrc/runit.
- No log redirection is generated on openrc; use the runtime's own logs.
- `docs/generated-units.md` shows systemd output only.
### Script / executable units
Added manually (`unitdore edit`); syncup never creates or disables them.
| Key | Runtime | Meaning |
|---|---|---|
| `script` | script | Required. `ExecStart=<shell> <script>` |
| `stop_script` | script | Optional. `ExecStop=-<shell> <stop_script>` |
| `shell` | script | Interpreter, default `/bin/bash` |
| `executable` | exec | Required. `ExecStart=<executable> <args>` |
| `args` | exec | Optional list, quoted as needed |
| `stop_executable` | exec | Optional. `ExecStop=-<stop_executable>` |
| `service_type` | both | `simple` (default) or `oneshot` (RemainAfterExit=yes) |
| `working_dir` | both | Optional `WorkingDirectory` |
| `command` | both | If set, overrides ExecStart |
| `user` | both | Set for a user unit |
### 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 +259,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 +288,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 +296,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
+26
View File
@@ -0,0 +1,26 @@
package cmd
import (
"fmt"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/initsys"
"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(ini initsys.Init, seen map[string]bool, u config.Unit) {
if !ini.SupportsLinger() || 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)
}
+15 -9
View File
@@ -5,7 +5,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd" "github.com/warkanum/unitdore/initsys"
) )
var dryRun bool var dryRun bool
@@ -32,21 +32,26 @@ func runInstall(cmd *cobra.Command, args []string) error {
} }
prefix, suffix := cfg.Prefix, cfg.Suffix prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
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 {
// Remove unit file if it exists for disabled units // Remove unit file if it exists for disabled units
if systemd.IsInstalled(u, prefix, suffix) { if ini.IsInstalled(u, prefix, suffix) {
if dryRun { if dryRun {
fmt.Printf(" ~ would remove: %s\n", systemd.ServiceName(u, prefix, suffix)) fmt.Printf(" ~ would remove: %s\n", ini.ServiceName(u, prefix, suffix))
} else { } else {
if err := systemd.Uninstall(u, prefix, suffix); err != nil { if err := ini.Uninstall(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err) fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err)
} else { } else {
fmt.Printf(" - removed: %s (disabled)\n", systemd.ServiceName(u, prefix, suffix)) fmt.Printf(" - removed: %s (disabled)\n", ini.ServiceName(u, prefix, suffix))
removed++ removed++
} }
} }
@@ -57,22 +62,23 @@ func runInstall(cmd *cobra.Command, args []string) error {
} }
if dryRun { if dryRun {
content, err := systemd.Generate(u, prefix, suffix) content, err := ini.Generate(u, prefix, suffix)
if err != nil { if err != nil {
fmt.Printf(" ✗ %s: %v\n", u.Name, err) fmt.Printf(" ✗ %s: %v\n", u.Name, err)
continue continue
} }
path, _ := systemd.UnitPath(u, prefix, suffix) path, _ := ini.UnitPath(u, prefix, suffix)
fmt.Printf("\n--- %s ---\n%s\n", path, content) fmt.Printf("\n--- %s ---\n%s\n", path, content)
installed++ installed++
continue continue
} }
if err := systemd.Install(u, prefix, suffix); err != nil { if err := ini.Install(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err) fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
} else { } else {
path, _ := systemd.UnitPath(u, prefix, suffix) path, _ := ini.UnitPath(u, prefix, suffix)
fmt.Printf(" ✓ installed: %s\n", path) fmt.Printf(" ✓ installed: %s\n", path)
lingerHint(ini, lingerSeen, u)
installed++ installed++
} }
} }
+15 -9
View File
@@ -5,7 +5,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd" "github.com/warkanum/unitdore/initsys"
) )
var installallDryRun bool var installallDryRun bool
@@ -32,20 +32,25 @@ func runInstallall(cmd *cobra.Command, args []string) error {
} }
prefix, suffix := cfg.Prefix, cfg.Suffix prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
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 {
if systemd.IsInstalled(u, prefix, suffix) { if ini.IsInstalled(u, prefix, suffix) {
if installallDryRun { if installallDryRun {
fmt.Printf(" ~ would remove: %s\n", systemd.ServiceName(u, prefix, suffix)) fmt.Printf(" ~ would remove: %s\n", ini.ServiceName(u, prefix, suffix))
} else { } else {
if err := systemd.Uninstall(u, prefix, suffix); err != nil { if err := ini.Uninstall(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err) fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err)
} else { } else {
fmt.Printf(" - removed: %s (disabled)\n", systemd.ServiceName(u, prefix, suffix)) fmt.Printf(" - removed: %s (disabled)\n", ini.ServiceName(u, prefix, suffix))
removed++ removed++
} }
} }
@@ -56,22 +61,23 @@ func runInstallall(cmd *cobra.Command, args []string) error {
} }
if installallDryRun { if installallDryRun {
content, err := systemd.Generate(u, prefix, suffix) content, err := ini.Generate(u, prefix, suffix)
if err != nil { if err != nil {
fmt.Printf(" ✗ %s: %v\n", u.Name, err) fmt.Printf(" ✗ %s: %v\n", u.Name, err)
continue continue
} }
path, _ := systemd.UnitPath(u, prefix, suffix) path, _ := ini.UnitPath(u, prefix, suffix)
fmt.Printf("\n--- %s ---\n%s\n", path, content) fmt.Printf("\n--- %s ---\n%s\n", path, content)
installed++ installed++
continue continue
} }
if err := systemd.Install(u, prefix, suffix); err != nil { if err := ini.Install(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err) fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
} else { } else {
path, _ := systemd.UnitPath(u, prefix, suffix) path, _ := ini.UnitPath(u, prefix, suffix)
fmt.Printf(" ✓ installed: %s\n", path) fmt.Printf(" ✓ installed: %s\n", path)
lingerHint(ini, lingerSeen, u)
installed++ installed++
} }
} }
+8 -4
View File
@@ -7,7 +7,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd" "github.com/warkanum/unitdore/initsys"
) )
var listCmd = &cobra.Command{ var listCmd = &cobra.Command{
@@ -41,6 +41,10 @@ func runList(cmd *cobra.Command, args []string) error {
}) })
prefix, suffix := cfg.Prefix, cfg.Suffix prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
type row struct{ name, runtime, enabled, installed, path string } type row struct{ name, runtime, enabled, installed, path string }
@@ -53,14 +57,14 @@ func runList(cmd *cobra.Command, args []string) error {
installed := "no" installed := "no"
unitPath := "—" unitPath := "—"
if p, err := systemd.UnitPath(u, prefix, suffix); err == nil { if p, err := ini.UnitPath(u, prefix, suffix); err == nil {
unitPath = p unitPath = p
if systemd.IsInstalled(u, prefix, suffix) { if ini.IsInstalled(u, prefix, suffix) {
installed = "yes" installed = "yes"
} }
} }
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"}
+22 -5
View File
@@ -5,20 +5,37 @@ 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
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "unitdore", Use: "unitdore",
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 the system init (systemd, OpenRC or runit).
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 init service units for each one (systemd by default;
set 'init:' in the config for openrc or runit).
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 +46,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)")
} }
+8 -4
View File
@@ -5,7 +5,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd" "github.com/warkanum/unitdore/initsys"
) )
var startCmd = &cobra.Command{ var startCmd = &cobra.Command{
@@ -32,13 +32,17 @@ func runStart(cmd *cobra.Command, args []string) error {
} }
prefix, suffix := cfg.Prefix, cfg.Suffix prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
if !systemd.IsInstalled(*u, prefix, suffix) { if !ini.IsInstalled(*u, prefix, suffix) {
return fmt.Errorf("%s is not installed — run 'unitdore install' first", name) return fmt.Errorf("%s is not installed — run 'unitdore install' first", name)
} }
fmt.Printf(" ▶ starting %s...\n", systemd.ServiceName(*u, prefix, suffix)) fmt.Printf(" ▶ starting %s...\n", ini.ServiceName(*u, prefix, suffix))
if err := systemd.Start(*u, prefix, suffix); err != nil { if err := ini.Start(*u, prefix, suffix); err != nil {
return err return err
} }
fmt.Printf(" ✓ started: %s\n", name) fmt.Printf(" ✓ started: %s\n", name)
+8 -4
View File
@@ -6,7 +6,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd" "github.com/warkanum/unitdore/initsys"
) )
var startallCmd = &cobra.Command{ var startallCmd = &cobra.Command{
@@ -28,6 +28,10 @@ func runStartall(cmd *cobra.Command, args []string) error {
} }
prefix, suffix := cfg.Prefix, cfg.Suffix prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
units := make([]config.Unit, len(cfg.Units)) units := make([]config.Unit, len(cfg.Units))
copy(units, cfg.Units) copy(units, cfg.Units)
@@ -45,12 +49,12 @@ func runStartall(cmd *cobra.Command, args []string) error {
if !u.Enabled { if !u.Enabled {
continue continue
} }
if !systemd.IsInstalled(u, prefix, suffix) { if !ini.IsInstalled(u, prefix, suffix) {
fmt.Printf(" ! %s: not installed — run 'unitdore install' first\n", u.Name) fmt.Printf(" ! %s: not installed — run 'unitdore install' first\n", u.Name)
continue continue
} }
fmt.Printf(" ▶ starting: %s...\n", systemd.ServiceName(u, prefix, suffix)) fmt.Printf(" ▶ starting: %s...\n", ini.ServiceName(u, prefix, suffix))
if err := systemd.Start(u, prefix, suffix); err != nil { if err := ini.Start(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err) fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
failed++ failed++
} else { } else {
+9 -5
View File
@@ -7,7 +7,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd" "github.com/warkanum/unitdore/initsys"
) )
var statusCmd = &cobra.Command{ var statusCmd = &cobra.Command{
@@ -37,6 +37,10 @@ func runStatus(cmd *cobra.Command, args []string) error {
} }
prefix, suffix := cfg.Prefix, cfg.Suffix prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
if len(cfg.Units) == 0 { if len(cfg.Units) == 0 {
fmt.Println("No units configured. Run 'unitdore syncup' to discover containers.") fmt.Println("No units configured. Run 'unitdore syncup' to discover containers.")
@@ -65,18 +69,18 @@ func runStatus(cmd *cobra.Command, args []string) error {
} }
installed := "yes" installed := "yes"
if !systemd.IsInstalled(u, prefix, suffix) { if !ini.IsInstalled(u, prefix, suffix) {
installed = "no" installed = "no"
} }
state := "—" state := "—"
if systemd.IsInstalled(u, prefix, suffix) { if ini.IsInstalled(u, prefix, suffix) {
state = systemd.Status(u, prefix, suffix) state = ini.Status(u, prefix, suffix)
} }
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,
+8 -4
View File
@@ -5,7 +5,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd" "github.com/warkanum/unitdore/initsys"
) )
var stopCmd = &cobra.Command{ var stopCmd = &cobra.Command{
@@ -32,13 +32,17 @@ func runStop(cmd *cobra.Command, args []string) error {
} }
prefix, suffix := cfg.Prefix, cfg.Suffix prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
if !systemd.IsInstalled(*u, prefix, suffix) { if !ini.IsInstalled(*u, prefix, suffix) {
return fmt.Errorf("%s is not installed", name) return fmt.Errorf("%s is not installed", name)
} }
fmt.Printf(" ■ stopping %s...\n", systemd.ServiceName(*u, prefix, suffix)) fmt.Printf(" ■ stopping %s...\n", ini.ServiceName(*u, prefix, suffix))
if err := systemd.Stop(*u, prefix, suffix); err != nil { if err := ini.Stop(*u, prefix, suffix); err != nil {
return err return err
} }
fmt.Printf(" ✓ stopped: %s\n", name) fmt.Printf(" ✓ stopped: %s\n", name)
+8 -4
View File
@@ -6,7 +6,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd" "github.com/warkanum/unitdore/initsys"
) )
var stopallCmd = &cobra.Command{ var stopallCmd = &cobra.Command{
@@ -27,6 +27,10 @@ func runStopall(cmd *cobra.Command, args []string) error {
} }
prefix, suffix := cfg.Prefix, cfg.Suffix prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
// Reverse order for shutdown // Reverse order for shutdown
units := make([]config.Unit, len(cfg.Units)) units := make([]config.Unit, len(cfg.Units))
@@ -42,11 +46,11 @@ func runStopall(cmd *cobra.Command, args []string) error {
failed := 0 failed := 0
for _, u := range units { for _, u := range units {
if !systemd.IsInstalled(u, prefix, suffix) { if !ini.IsInstalled(u, prefix, suffix) {
continue continue
} }
fmt.Printf(" ■ stopping: %s...\n", systemd.ServiceName(u, prefix, suffix)) fmt.Printf(" ■ stopping: %s...\n", ini.ServiceName(u, prefix, suffix))
if err := systemd.Stop(u, prefix, suffix); err != nil { if err := ini.Stop(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err) fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
failed++ failed++
} else { } else {
+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/service"
"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 service.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)
}
}
+10 -6
View File
@@ -5,7 +5,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd" "github.com/warkanum/unitdore/initsys"
) )
var uninstallCmd = &cobra.Command{ var uninstallCmd = &cobra.Command{
@@ -32,21 +32,25 @@ func runUninstall(cmd *cobra.Command, args []string) error {
} }
prefix, suffix := cfg.Prefix, cfg.Suffix prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
if !systemd.IsInstalled(*u, prefix, suffix) { if !ini.IsInstalled(*u, prefix, suffix) {
return fmt.Errorf("%s is not installed", name) return fmt.Errorf("%s is not installed", name)
} }
svcName := systemd.ServiceName(*u, prefix, suffix) svcName := ini.ServiceName(*u, prefix, suffix)
fmt.Printf(" ■ disabling %s...\n", svcName) fmt.Printf(" ■ disabling %s...\n", svcName)
if err := systemd.Disable(*u, prefix, suffix); err != nil { if err := ini.Disable(*u, prefix, suffix); err != nil {
return fmt.Errorf("disabling unit: %w", err) return fmt.Errorf("disabling unit: %w", err)
} }
path, _ := systemd.UnitPath(*u, prefix, suffix) path, _ := ini.UnitPath(*u, prefix, suffix)
fmt.Printf(" ✗ removing %s...\n", path) fmt.Printf(" ✗ removing %s...\n", path)
if err := systemd.Uninstall(*u, prefix, suffix); err != nil { if err := ini.Uninstall(*u, prefix, suffix); err != nil {
return fmt.Errorf("removing service file: %w", err) return fmt.Errorf("removing service file: %w", err)
} }
+10 -6
View File
@@ -6,7 +6,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd" "github.com/warkanum/unitdore/initsys"
) )
var uninstallallCmd = &cobra.Command{ var uninstallallCmd = &cobra.Command{
@@ -27,6 +27,10 @@ func runUninstallall(cmd *cobra.Command, args []string) error {
} }
prefix, suffix := cfg.Prefix, cfg.Suffix prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
units := make([]config.Unit, len(cfg.Units)) units := make([]config.Unit, len(cfg.Units))
copy(units, cfg.Units) copy(units, cfg.Units)
@@ -41,19 +45,19 @@ func runUninstallall(cmd *cobra.Command, args []string) error {
failed := 0 failed := 0
for _, u := range units { for _, u := range units {
if !systemd.IsInstalled(u, prefix, suffix) { if !ini.IsInstalled(u, prefix, suffix) {
continue continue
} }
svcName := systemd.ServiceName(u, prefix, suffix) svcName := ini.ServiceName(u, prefix, suffix)
fmt.Printf(" ■ disabling %s...\n", svcName) fmt.Printf(" ■ disabling %s...\n", svcName)
if err := systemd.Disable(u, prefix, suffix); err != nil { if err := ini.Disable(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed to disable %s: %v\n", u.Name, err) fmt.Printf(" ✗ failed to disable %s: %v\n", u.Name, err)
failed++ failed++
continue continue
} }
path, _ := systemd.UnitPath(u, prefix, suffix) path, _ := ini.UnitPath(u, prefix, suffix)
fmt.Printf(" ✗ removing %s...\n", path) fmt.Printf(" ✗ removing %s...\n", path)
if err := systemd.Uninstall(u, prefix, suffix); err != nil { if err := ini.Uninstall(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err) fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err)
failed++ failed++
continue continue
+8 -4
View File
@@ -6,7 +6,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd" "github.com/warkanum/unitdore/initsys"
) )
var ( var (
@@ -64,12 +64,16 @@ func runUpdate(cmd *cobra.Command, args []string) error {
} }
prefix, suffix := cfg.Prefix, cfg.Suffix prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
if systemd.IsInstalled(*u, prefix, suffix) { if ini.IsInstalled(*u, prefix, suffix) {
if err := systemd.Install(*u, prefix, suffix); err != nil { if err := ini.Install(*u, prefix, suffix); err != nil {
return fmt.Errorf("recreating service file: %w", err) return fmt.Errorf("recreating service file: %w", err)
} }
path, _ := systemd.UnitPath(*u, prefix, suffix) path, _ := ini.UnitPath(*u, prefix, suffix)
fmt.Printf(" ✓ service file recreated: %s\n", path) fmt.Printf(" ✓ service file recreated: %s\n", path)
} }
+63 -3
View File
@@ -10,6 +10,25 @@ 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"
)
// Runtimes for units that are not containers.
const (
RuntimeScript = "script" // run a shell script
RuntimeExec = "exec" // run an executable directly
DefaultShell = "/bin/bash"
)
// 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 +42,54 @@ 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
// Script fields (runtime: script) — run a shell file as a service.
Script string `yaml:"script,omitempty"` // path to script run as ExecStart
StopScript string `yaml:"stop_script,omitempty"` // optional script run as ExecStop
Shell string `yaml:"shell,omitempty"` // interpreter, default /bin/bash
// Executable fields (runtime: exec) — run a binary directly.
Executable string `yaml:"executable,omitempty"` // path to binary run as ExecStart
Args []string `yaml:"args,omitempty"` // arguments for executable
StopExecutable string `yaml:"stop_executable,omitempty"` // optional binary run as ExecStop
// Script and exec units: simple (default) | oneshot. WorkingDir is also used.
ServiceType string `yaml:"service_type,omitempty"`
}
// IsStack reports whether the unit manages a compose stack.
func (u Unit) IsStack() bool { return u.Kind == KindStack }
// IsScript reports whether the unit runs a shell script.
func (u Unit) IsScript() bool { return u.Runtime == RuntimeScript }
// IsExec reports whether the unit runs an executable directly.
func (u Unit) IsExec() bool { return u.Runtime == RuntimeExec }
// ShellPath returns the configured interpreter, or the default.
func (u Unit) ShellPath() string {
if u.Shell == "" {
return DefaultShell
}
return u.Shell
} }
// 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
Init string `yaml:"init,omitempty"` // systemd | openrc | runit (empty = autodetect)
ServiceDir string `yaml:"service_dir,omitempty"` // runit: supervised service dir (empty = autodetect)
} }
// 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)
}
}
+376 -1
View File
@@ -1,4 +1,4 @@
# Generated Systemd Unit Files # Generated Unit Files (systemd, OpenRC, runit)
Unitdore generates `.service` files from your `units.yaml` config. Unitdore generates `.service` files from your `units.yaml` config.
This document shows every combination with annotated examples. This document shows every combination with annotated examples.
@@ -277,6 +277,379 @@ 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
```
---
## Config Behind the Init System Examples
```yaml
init: systemd # systemd | openrc | runit
units:
- {name: nginx, runtime: podman, order: 1, enabled: true}
- {name: redis, runtime: docker, order: 2, enabled: true, restart: true, restart_sec: 5, restart_retries: 3}
- {name: myapp, runtime: podman, user: alice, order: 3, enabled: true}
- {name: backup, runtime: script, script: /opt/scripts/backup.sh, stop_script: /opt/scripts/stop.sh, shell: /bin/sh, working_dir: /opt/scripts, order: 4, enabled: true}
- {name: agent, runtime: exec, executable: /opt/agent/bin/agent, args: [--config, /etc/agent.toml], service_type: oneshot, order: 5, enabled: true}
- {name: shop, runtime: podman, kind: stack, project: shop, order: 6, enabled: true}
```
---
## Script and Executable Units (systemd)
`script` runs `<shell> <script>` (default shell `/bin/bash`); `exec` runs the binary directly.
```ini
# /etc/systemd/system/unitdore-backup.service
# Generated by unitdore — do not edit manually
[Unit]
Description=Unitdore: backup (sh script)
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/scripts
ExecStart=/bin/sh /opt/scripts/backup.sh
ExecStop=-/bin/sh /opt/scripts/stop.sh
[Install]
WantedBy=multi-user.target
```
```ini
# /etc/systemd/system/unitdore-agent.service
# Generated by unitdore — do not edit manually
[Unit]
Description=Unitdore: agent (executable)
After=network.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/opt/agent/bin/agent --config /etc/agent.toml
ExecStop=
[Install]
WantedBy=multi-user.target
```
---
## OpenRC
Root only. Files in `/etc/init.d/`. Rootless `user:` units run as a system service via `command_user`.
### nginx
```sh
#!/sbin/openrc-run
# /etc/init.d/unitdore-nginx
# Generated by unitdore — do not edit manually
description="Unitdore: nginx (podman)"
command_background=yes
pidfile="/run/${RC_SVCNAME}.pid"
command="/usr/bin/podman"
command_args="start -a nginx"
depend() {
need net
}
stop_pre() {
/usr/bin/podman stop nginx || true
}
```
### redis
```sh
#!/sbin/openrc-run
# /etc/init.d/unitdore-redis
# Generated by unitdore — do not edit manually
description="Unitdore: redis (docker)"
supervisor=supervise-daemon
respawn_delay=5
respawn_max=3
command="/usr/bin/docker"
command_args="start -a redis"
depend() {
need net
need docker
}
stop_pre() {
/usr/bin/docker stop redis || true
}
```
### myapp
```sh
#!/sbin/openrc-run
# /etc/init.d/unitdore-myapp
# Generated by unitdore — do not edit manually
description="Unitdore: myapp (podman)"
export HOME=/home/alice
export XDG_RUNTIME_DIR=/run/user/1000
command_background=yes
pidfile="/run/${RC_SVCNAME}.pid"
command="/usr/bin/podman"
command_args="start -a myapp"
command_user="alice"
depend() {
need net
}
start_pre() {
checkpath -d -m 0700 -o alice /run/user/1000
}
stop_pre() {
su -m -s /bin/sh -c '/usr/bin/podman stop myapp' alice || true
}
```
### backup
```sh
#!/sbin/openrc-run
# /etc/init.d/unitdore-backup
# Generated by unitdore — do not edit manually
description="Unitdore: backup (sh script)"
command_background=yes
pidfile="/run/${RC_SVCNAME}.pid"
command="/bin/sh"
command_args="/opt/scripts/backup.sh"
directory="/opt/scripts"
depend() {
need net
}
stop_pre() {
/bin/sh /opt/scripts/stop.sh || true
}
```
### agent
```sh
#!/sbin/openrc-run
# /etc/init.d/unitdore-agent
# Generated by unitdore — do not edit manually
description="Unitdore: agent (executable)"
start() {
ebegin "Starting $RC_SVCNAME"
/opt/agent/bin/agent --config /etc/agent.toml
eend $?
}
depend() {
need net
}
```
### shop
```sh
#!/sbin/openrc-run
# /etc/init.d/unitdore-shop
# Generated by unitdore — do not edit manually
description="Unitdore: shop (podman pod)"
start() {
ebegin "Starting $RC_SVCNAME"
/usr/bin/podman pod start pod_shop
eend $?
}
stop() {
ebegin "Stopping $RC_SVCNAME"
/usr/bin/podman pod stop pod_shop
eend $?
}
depend() {
need net
}
```
---
## runit
Root only. Each unit is `/etc/sv/unitdore-<name>/{run,finish}` symlinked into the service dir. Install adds a `down` file (no autostart until started/enabled). Both files are shown per unit.
### nginx
```sh
# ===== /etc/sv/unitdore-nginx/run =====
#!/bin/sh
# unitdore-nginx — generated by unitdore, do not edit manually
exec 2>&1
exec /usr/bin/podman start -a nginx
# ===== /etc/sv/unitdore-nginx/finish =====
#!/bin/sh
# unitdore-nginx — generated by unitdore, do not edit manually
/usr/bin/podman stop nginx || true
exec sv down .
```
### redis
```sh
# ===== /etc/sv/unitdore-redis/run =====
#!/bin/sh
# unitdore-redis — generated by unitdore, do not edit manually
exec 2>&1
exec /usr/bin/docker start -a redis
# ===== /etc/sv/unitdore-redis/finish =====
#!/bin/sh
# unitdore-redis — generated by unitdore, do not edit manually
/usr/bin/docker stop redis || true
[ "$1" = 0 ] && exec sv down .
sleep 5
```
### myapp
```sh
# ===== /etc/sv/unitdore-myapp/run =====
#!/bin/sh
# unitdore-myapp — generated by unitdore, do not edit manually
exec 2>&1
[ -d /run/user/1000 ] || install -d -m 0700 -o alice /run/user/1000
export HOME=/home/alice
export XDG_RUNTIME_DIR=/run/user/1000
exec chpst -u alice /usr/bin/podman start -a myapp
# ===== /etc/sv/unitdore-myapp/finish =====
#!/bin/sh
# unitdore-myapp — generated by unitdore, do not edit manually
chpst -u alice /usr/bin/podman stop myapp || true
exec sv down .
```
### backup
```sh
# ===== /etc/sv/unitdore-backup/run =====
#!/bin/sh
# unitdore-backup — generated by unitdore, do not edit manually
exec 2>&1
cd /opt/scripts || exit 1
exec /bin/sh /opt/scripts/backup.sh
# ===== /etc/sv/unitdore-backup/finish =====
#!/bin/sh
# unitdore-backup — generated by unitdore, do not edit manually
cd /opt/scripts 2>/dev/null
/bin/sh /opt/scripts/stop.sh || true
exec sv down .
```
### agent
```sh
# ===== /etc/sv/unitdore-agent/run =====
#!/bin/sh
# unitdore-agent — generated by unitdore, do not edit manually
exec 2>&1
/opt/agent/bin/agent --config /etc/agent.toml || exit 1
exec tail -f /dev/null
# ===== /etc/sv/unitdore-agent/finish =====
#!/bin/sh
# unitdore-agent — generated by unitdore, do not edit manually
exec sv down .
```
### shop
```sh
# ===== /etc/sv/unitdore-shop/run =====
#!/bin/sh
# unitdore-shop — generated by unitdore, do not edit manually
exec 2>&1
/usr/bin/podman pod start pod_shop || exit 1
exec tail -f /dev/null
# ===== /etc/sv/unitdore-shop/finish =====
#!/bin/sh
# unitdore-shop — generated by unitdore, do not edit manually
/usr/bin/podman pod stop pod_shop || true
exec sv down .
```
---
## Quick Reference: Service File Locations ## Quick Reference: Service File Locations
| Scenario | Path | | Scenario | Path |
@@ -286,3 +659,5 @@ units:
| With prefix `prod-` | `/etc/systemd/system/unitdore-prod-<name>.service` | | With prefix `prod-` | `/etc/systemd/system/unitdore-prod-<name>.service` |
| With suffix `-svc` | `/etc/systemd/system/unitdore-<name>-svc.service` | | With suffix `-svc` | `/etc/systemd/system/unitdore-<name>-svc.service` |
| With both | `/etc/systemd/system/unitdore-prod-<name>-svc.service` | | With both | `/etc/systemd/system/unitdore-prod-<name>-svc.service` |
| OpenRC | `/etc/init.d/unitdore-<name>` |
| runit | `/etc/sv/unitdore-<name>/` (+ symlink in `/var/service` or `/etc/service`) |
+37 -5
View File
@@ -1,6 +1,6 @@
.TH UNITDORE 1 "2026-04-08" "0.1.0" "User Commands" .TH UNITDORE 1 "2026-04-08" "0.1.0" "User Commands"
.SH NAME .SH NAME
unitdore \- manage container units via systemd unitdore \- manage container units via systemd, OpenRC or runit
.SH SYNOPSIS .SH SYNOPSIS
.B unitdore .B unitdore
[\fB\-\-config\fR \fIpath\fR] [\fB\-\-config\fR \fIpath\fR]
@@ -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,26 @@ 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 INIT SYSTEMS
The \fBinit:\fR config key selects \fBsystemd\fR, \fBopenrc\fR or \fBrunit\fR.
Empty means autodetect, falling back to systemd. OpenRC and runit are root only;
rootless units run as system services as that user. runit service links go in
\fBservice_dir:\fR (default \fI/var/service\fR or \fI/etc/service\fR).
OpenRC scripts are written to \fI/etc/init.d/unitdore\-*\fR and runit services to
\fI/etc/sv/unitdore\-*\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 +154,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, OpenRC or runit; 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),
+77
View File
@@ -0,0 +1,77 @@
// Package initsys selects the init system backend that installs and controls
// unitdore services.
package initsys
import (
"fmt"
"os"
"path/filepath"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/openrc"
"github.com/warkanum/unitdore/runit"
"github.com/warkanum/unitdore/systemd"
)
// Backend names.
const (
Systemd = "systemd"
OpenRC = "openrc"
Runit = "runit"
)
// Init is implemented by every init system backend. u.User selects the scope;
// backends that cannot do per-user managers run user units as system services.
type Init interface {
Name() string
ServiceName(u config.Unit, prefix, suffix string) string
UnitPath(u config.Unit, prefix, suffix string) (string, error)
Generate(u config.Unit, prefix, suffix string) (string, error)
Install(u config.Unit, prefix, suffix string) error
Uninstall(u config.Unit, prefix, suffix string) error
Enable(u config.Unit, prefix, suffix string) error
Disable(u config.Unit, prefix, suffix string) error
Start(u config.Unit, prefix, suffix string) error
Stop(u config.Unit, prefix, suffix string) error
// Status returns active | inactive | failed | activating | deactivating | unknown.
Status(u config.Unit, prefix, suffix string) string
IsInstalled(u config.Unit, prefix, suffix string) bool
// SupportsLinger reports whether user units need systemd-style linger.
SupportsLinger() bool
}
// New returns the backend named in cfg.Init, or the detected one.
func New(cfg *config.Config) (Init, error) {
name := cfg.Init
if name == "" {
name = Detect("/")
}
switch name {
case Systemd:
return systemd.Backend{}, nil
case OpenRC:
return openrc.Backend{}, nil
case Runit:
return runit.Backend{ServiceDir: cfg.ServiceDir}, nil
default:
return nil, fmt.Errorf("unknown init %q (want systemd, openrc or runit)", name)
}
}
// Detect guesses the running init system below root. Falls back to systemd.
func Detect(root string) string {
has := func(p string) bool {
_, err := os.Stat(filepath.Join(root, p))
return err == nil
}
switch {
case has("run/systemd/system"):
return Systemd
case has("run/openrc"), has("sbin/openrc-run"):
return OpenRC
case has("run/runit"), has("etc/runit"):
return Runit
default:
return Systemd
}
}
+52
View File
@@ -0,0 +1,52 @@
package initsys
import (
"os"
"path/filepath"
"testing"
"github.com/warkanum/unitdore/config"
)
func TestDetect(t *testing.T) {
tests := []struct {
name string
paths []string
want string
}{
{"systemd", []string{"run/systemd/system"}, Systemd},
{"openrc", []string{"run/openrc"}, OpenRC},
{"openrc binary", []string{"sbin/openrc-run"}, OpenRC},
{"runit", []string{"etc/runit"}, Runit},
{"systemd wins", []string{"run/systemd/system", "etc/runit"}, Systemd},
{"fallback", nil, Systemd},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
root := t.TempDir()
for _, p := range tt.paths {
if err := os.MkdirAll(filepath.Dir(filepath.Join(root, p)), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, p), nil, 0644); err != nil {
t.Fatal(err)
}
}
if got := Detect(root); got != tt.want {
t.Errorf("Detect() = %q, want %q", got, tt.want)
}
})
}
}
func TestNew(t *testing.T) {
for _, name := range []string{Systemd, OpenRC, Runit} {
ini, err := New(&config.Config{Init: name})
if err != nil || ini.Name() != name {
t.Errorf("New(%q) = %v, %v", name, ini, err)
}
}
if _, err := New(&config.Config{Init: "sysv"}); err == nil {
t.Error("New() should reject an unknown init")
}
}
+279
View File
@@ -0,0 +1,279 @@
// Package openrc generates and manages OpenRC init scripts.
//
// Services run at system level (root only). Units with a user run as that
// user through command_user, with HOME and XDG_RUNTIME_DIR set.
package openrc
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/service"
)
const runlevel = "default"
// Overridable for tests.
var (
initDir = "/etc/init.d"
// runCmd runs a command with stdio passed through.
runCmd = func(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// outCmd runs a command and returns its combined output.
outCmd = func(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).CombinedOutput()
}
)
// Backend is the OpenRC implementation of the init interface.
type Backend struct{}
func (Backend) Name() string { return "openrc" }
// SupportsLinger is false: OpenRC services need no linger.
func (Backend) SupportsLinger() bool { return false }
func (Backend) ServiceName(u config.Unit, prefix, suffix string) string {
return service.BaseName(u, prefix, suffix)
}
func (b Backend) UnitPath(u config.Unit, prefix, suffix string) (string, error) {
return filepath.Join(initDir, b.ServiceName(u, prefix, suffix)), nil
}
func (b Backend) IsInstalled(u config.Unit, prefix, suffix string) bool {
path, _ := b.UnitPath(u, prefix, suffix)
_, err := os.Stat(path)
return err == nil
}
func (b Backend) Install(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("openrc"); err != nil {
return err
}
content, err := b.Generate(u, prefix, suffix)
if err != nil {
return err
}
path, _ := b.UnitPath(u, prefix, suffix)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating init dir: %w", err)
}
if err := os.WriteFile(path, []byte(content), 0755); err != nil {
return fmt.Errorf("writing init script: %w", err)
}
return nil
}
func (b Backend) Uninstall(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("openrc"); err != nil {
return err
}
name := b.ServiceName(u, prefix, suffix)
_ = runCmd("rc-update", "del", name, runlevel) // not in runlevel is fine
path, _ := b.UnitPath(u, prefix, suffix)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("removing init script: %w", err)
}
return nil
}
// Enable adds the service to the default runlevel and starts it.
func (b Backend) Enable(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("openrc"); err != nil {
return err
}
name := b.ServiceName(u, prefix, suffix)
if err := runCmd("rc-update", "add", name, runlevel); err != nil {
return err
}
return runCmd("rc-service", name, "start")
}
// Disable stops the service and removes it from the default runlevel.
func (b Backend) Disable(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("openrc"); err != nil {
return err
}
name := b.ServiceName(u, prefix, suffix)
if err := runCmd("rc-service", name, "stop"); err != nil {
return err
}
_ = runCmd("rc-update", "del", name, runlevel) // not in runlevel is fine
return nil
}
func (b Backend) Start(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("openrc"); err != nil {
return err
}
return runCmd("rc-service", b.ServiceName(u, prefix, suffix), "start")
}
func (b Backend) Stop(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("openrc"); err != nil {
return err
}
return runCmd("rc-service", b.ServiceName(u, prefix, suffix), "stop")
}
func (b Backend) Status(u config.Unit, prefix, suffix string) string {
// rc-service status exits non-zero for stopped services but still prints
out, _ := outCmd("rc-service", b.ServiceName(u, prefix, suffix), "status")
return parseStatus(string(out))
}
// parseStatus maps `rc-service <name> status` output to a systemd-like state.
func parseStatus(out string) string {
out = strings.ToLower(out)
switch {
case strings.Contains(out, "crashed"):
return "failed"
case strings.Contains(out, "starting"):
return "activating"
case strings.Contains(out, "stopping"):
return "deactivating"
case strings.Contains(out, "started"):
return "active"
case strings.Contains(out, "stopped"):
return "inactive"
default:
return "unknown"
}
}
// Generate produces the init script for a unit.
func (b Backend) Generate(u config.Unit, prefix, suffix string) (string, error) {
if err := service.Validate(u); err != nil {
return "", err
}
user, err := service.LookupUser(u.User)
if err != nil {
return "", err
}
spec := service.Resolve(u)
var w strings.Builder
w.WriteString("#!/sbin/openrc-run\n")
w.WriteString("# " + filepath.Join(initDir, b.ServiceName(u, prefix, suffix)) + "\n")
w.WriteString("# Generated by unitdore — do not edit manually\n\n")
fmt.Fprintf(&w, "description=%s\n", dq(fmt.Sprintf("Unitdore: %s (%s)", u.Name, spec.Label)))
if user != nil {
fmt.Fprintf(&w, "export HOME=%s\n", service.ShellQuote(user.Home))
fmt.Fprintf(&w, "export XDG_RUNTIME_DIR=%s\n", service.ShellQuote(user.RuntimeDir))
}
start, err := service.ShellCommand(spec.Start)
if err != nil {
return "", fmt.Errorf("unit %s: %w", u.Name, err)
}
stop := ""
if spec.Stop != "" {
if stop, err = service.ShellCommand(spec.Stop); err != nil {
return "", fmt.Errorf("unit %s: %w", u.Name, err)
}
}
if spec.Type == service.TypeOneshot {
writeOneshot(&w, spec, user, start, stop)
} else if err := writeSupervised(&w, u, spec, user); err != nil {
return "", err
}
w.WriteString("\ndepend() {\n\tneed net\n")
if u.Runtime == "docker" {
w.WriteString("\tneed docker\n")
}
w.WriteString("}\n")
if spec.Type != service.TypeOneshot {
if user != nil {
fmt.Fprintf(&w, "\nstart_pre() {\n\tcheckpath -d -m 0700 -o %s %s\n}\n",
service.ShellQuote(user.Name), service.ShellQuote(user.RuntimeDir))
}
if stop != "" {
fmt.Fprintf(&w, "\nstop_pre() {\n\t%s || true\n}\n", asUser(user, stop))
}
}
return w.String(), nil
}
// writeSupervised writes the variables for a long-running foreground command.
func writeSupervised(w *strings.Builder, u config.Unit, spec service.Spec, user *service.UserEnv) error {
argv, err := service.SplitCommand(spec.Start)
if err != nil {
return fmt.Errorf("unit %s: %w", u.Name, err)
}
if len(argv) == 0 {
return fmt.Errorf("unit %s: empty command", u.Name)
}
w.WriteString("\n")
if u.Restart {
w.WriteString("supervisor=supervise-daemon\n")
fmt.Fprintf(w, "respawn_delay=%d\n", u.RestartSec)
if u.RestartRetries > 0 {
fmt.Fprintf(w, "respawn_max=%d\n", u.RestartRetries)
}
} else {
w.WriteString("command_background=yes\n")
w.WriteString("pidfile=\"/run/${RC_SVCNAME}.pid\"\n")
}
fmt.Fprintf(w, "command=%s\n", dq(argv[0]))
if len(argv) > 1 {
quoted := make([]string, len(argv)-1)
for i, a := range argv[1:] {
quoted[i] = service.ShellQuote(a)
}
fmt.Fprintf(w, "command_args=%s\n", dq(strings.Join(quoted, " ")))
}
if spec.WorkingDir != "" {
fmt.Fprintf(w, "directory=%s\n", dq(spec.WorkingDir))
}
if user != nil {
fmt.Fprintf(w, "command_user=%s\n", dq(user.Name))
}
return nil
}
// writeOneshot writes start/stop functions; OpenRC tracks the started state.
func writeOneshot(w *strings.Builder, spec service.Spec, user *service.UserEnv, start, stop string) {
w.WriteString("\nstart() {\n\tebegin \"Starting $RC_SVCNAME\"\n")
if spec.WorkingDir != "" {
fmt.Fprintf(w, "\tcd %s || return 1\n", service.ShellQuote(spec.WorkingDir))
}
fmt.Fprintf(w, "\t%s\n\teend $?\n}\n", asUser(user, start))
if stop != "" {
w.WriteString("\nstop() {\n\tebegin \"Stopping $RC_SVCNAME\"\n")
if spec.WorkingDir != "" {
fmt.Fprintf(w, "\tcd %s || return 1\n", service.ShellQuote(spec.WorkingDir))
}
fmt.Fprintf(w, "\t%s\n\teend $?\n}\n", asUser(user, stop))
}
}
// asUser wraps a shell command line so it runs as the unit's user, keeping the
// exported HOME / XDG_RUNTIME_DIR.
func asUser(user *service.UserEnv, cmd string) string {
if user == nil {
return cmd
}
return fmt.Sprintf("su -m -s /bin/sh -c %s %s", service.ShellQuote(cmd), service.ShellQuote(user.Name))
}
// dq double-quotes s for an OpenRC (sh) variable assignment.
func dq(s string) string {
r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "$", `\$`, "`", "\\`")
return `"` + r.Replace(s) + `"`
}
+221
View File
@@ -0,0 +1,221 @@
package openrc
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/service"
)
// setup points the backend at a temp init dir with recorded commands.
func setup(t *testing.T) *[]string {
t.Helper()
oldDir, oldRun, oldOut, oldRoot := initDir, runCmd, outCmd, service.IsRoot
t.Cleanup(func() { initDir, runCmd, outCmd, service.IsRoot = oldDir, oldRun, oldOut, oldRoot })
initDir = t.TempDir()
service.IsRoot = func() bool { return true }
var calls []string
runCmd = func(name string, args ...string) error {
calls = append(calls, name+" "+strings.Join(args, " "))
return nil
}
return &calls
}
func mustContain(t *testing.T, content string, wants ...string) {
t.Helper()
for _, w := range wants {
if !strings.Contains(content, w) {
t.Errorf("missing %q in:\n%s", w, content)
}
}
}
func TestGenerate_Container(t *testing.T) {
setup(t)
u := config.Unit{Name: "nginx", Runtime: "podman", Enabled: true}
got, err := Backend{}.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got,
"#!/sbin/openrc-run",
`description="Unitdore: nginx (podman)"`,
"command_background=yes",
`pidfile="/run/${RC_SVCNAME}.pid"`,
`command="/usr/bin/podman"`,
`command_args="start -a nginx"`,
"need net",
"stop_pre() {\n\t/usr/bin/podman stop nginx || true\n}",
)
if strings.Contains(got, "supervise-daemon") || strings.Contains(got, "need docker") {
t.Errorf("unexpected supervisor or docker dep:\n%s", got)
}
}
func TestGenerate_DockerRestart(t *testing.T) {
setup(t)
u := config.Unit{Name: "redis", Runtime: "docker", Restart: true, RestartSec: 5, RestartRetries: 3}
got, err := Backend{}.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got, "supervisor=supervise-daemon", "respawn_delay=5", "respawn_max=3", "need docker")
if strings.Contains(got, "command_background") {
t.Errorf("supervised unit must not background:\n%s", got)
}
}
func TestGenerate_ScriptCustomShellAndDir(t *testing.T) {
setup(t)
u := config.Unit{
Name: "job", Runtime: config.RuntimeScript, Shell: "/bin/sh",
Script: "/opt/my jobs/run.sh", WorkingDir: "/opt",
}
got, err := Backend{}.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got,
"(sh script)",
`command="/bin/sh"`,
`command_args="'/opt/my jobs/run.sh'"`,
`directory="/opt"`,
)
}
func TestGenerate_OneshotStack(t *testing.T) {
setup(t)
u := config.Unit{Name: "shop", Runtime: "podman", Kind: config.KindStack, Project: "shop"}
got, err := Backend{}.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got,
"start() {",
"/usr/bin/podman pod start pod_shop",
"stop() {",
"/usr/bin/podman pod stop pod_shop",
)
if strings.Contains(got, "command=") {
t.Errorf("oneshot must not set command:\n%s", got)
}
}
func TestGenerate_RootlessUser(t *testing.T) {
setup(t)
u := config.Unit{Name: "app", Runtime: "podman", User: "root"}
got, err := Backend{}.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got,
"export HOME=/root",
"export XDG_RUNTIME_DIR=/run/user/0",
`command_user="root"`,
"checkpath -d -m 0700 -o root /run/user/0",
"su -m -s /bin/sh -c '/usr/bin/podman stop app' root",
)
}
func TestGenerate_Invalid(t *testing.T) {
setup(t)
if _, err := (Backend{}).Generate(config.Unit{Name: "x", Runtime: config.RuntimeExec}, "", ""); err == nil {
t.Error("expected validation error")
}
if _, err := (Backend{}).Generate(config.Unit{Name: "x", Runtime: "podman", Command: `/bin/x "open`}, "", ""); err == nil {
t.Error("expected quote error")
}
}
func TestInstallUninstall(t *testing.T) {
calls := setup(t)
b := Backend{}
u := config.Unit{Name: "nginx", Runtime: "podman"}
if b.IsInstalled(u, "", "") {
t.Fatal("should not be installed yet")
}
if err := b.Install(u, "p-", ""); err != nil {
t.Fatal(err)
}
path := filepath.Join(initDir, "unitdore-p-nginx")
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm()&0111 == 0 {
t.Errorf("init script must be executable, mode %v", fi.Mode())
}
if !b.IsInstalled(u, "p-", "") {
t.Error("should be installed")
}
if err := b.Uninstall(u, "p-", ""); err != nil {
t.Fatal(err)
}
if b.IsInstalled(u, "p-", "") {
t.Error("should be uninstalled")
}
if got := strings.Join(*calls, "|"); got != "rc-update del unitdore-p-nginx default" {
t.Errorf("calls = %q", got)
}
}
func TestControlCommands(t *testing.T) {
calls := setup(t)
b := Backend{}
u := config.Unit{Name: "nginx", Runtime: "podman"}
_ = b.Start(u, "", "")
_ = b.Stop(u, "", "")
_ = b.Enable(u, "", "")
_ = b.Disable(u, "", "")
want := []string{
"rc-service unitdore-nginx start",
"rc-service unitdore-nginx stop",
"rc-update add unitdore-nginx default",
"rc-service unitdore-nginx start",
"rc-service unitdore-nginx stop",
"rc-update del unitdore-nginx default",
}
if got := strings.Join(*calls, "|"); got != strings.Join(want, "|") {
t.Errorf("calls =\n%q\nwant\n%q", *calls, want)
}
}
func TestRequiresRoot(t *testing.T) {
setup(t)
service.IsRoot = func() bool { return false }
u := config.Unit{Name: "nginx", Runtime: "podman"}
b := Backend{}
for name, err := range map[string]error{
"install": b.Install(u, "", ""), "uninstall": b.Uninstall(u, "", ""),
"start": b.Start(u, "", ""), "stop": b.Stop(u, "", ""),
"enable": b.Enable(u, "", ""), "disable": b.Disable(u, "", ""),
} {
if err == nil {
t.Errorf("%s should require root", name)
}
}
}
func TestParseStatus(t *testing.T) {
tests := map[string]string{
" * status: started": "active",
" * status: stopped": "inactive",
" * status: crashed": "failed",
" * status: starting": "activating",
" * status: stopping": "deactivating",
"": "unknown",
"rc-service: not found": "unknown",
}
for in, want := range tests {
if got := parseStatus(in); got != want {
t.Errorf("parseStatus(%q) = %q, want %q", in, got, want)
}
}
}
+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
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE pkgmetadata SYSTEM "https://www.gentoo.org/dtd/metadata.dtd">
<pkgmetadata>
<maintainer type="person">
<email>hein@warky.dev</email>
<name>Hein (Warky Devs)</name>
</maintainer>
</pkgmetadata>
@@ -0,0 +1,58 @@
# Copyright 2024 Hein (Warky Devs)
# Distributed under the terms of the MIT License
EAPI=8
inherit go-module
DESCRIPTION="A door you open and close for container units (systemd, OpenRC, runit)"
HOMEPAGE="https://git.warky.dev/wdevs/unitdore"
EGO_SUM=(
"github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod"
"github.com/inconshreveable/mousetrap v1.1.0"
"github.com/inconshreveable/mousetrap v1.1.0/go.mod"
"github.com/russross/blackfriday/v2 v2.1.0/go.mod"
"github.com/spf13/cobra v1.10.2"
"github.com/spf13/cobra v1.10.2/go.mod"
"github.com/spf13/pflag v1.0.9"
"github.com/spf13/pflag v1.0.9/go.mod"
"go.yaml.in/yaml/v3 v3.0.4/go.mod"
"gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod"
"gopkg.in/yaml.v3 v3.0.1"
"gopkg.in/yaml.v3 v3.0.1/go.mod"
)
go-module_set_globals
SRC_URI="
${HOMEPAGE}/releases/download/v${PV}/${PN}-v${PV}-source.tar.gz -> ${P}.tar.gz
${EGO_SUM_SRC_URI}
"
LICENSE="MIT"
SLOT="0"
KEYWORDS="~amd64 ~arm64"
RDEPEND="
|| (
app-containers/podman
app-containers/docker
)
"
BDEPEND=">=dev-lang/go-1.23"
src_compile() {
ego build -trimpath \
-ldflags "-X github.com/warkanum/unitdore/cmd.version=${PV}" \
-o ${PN} .
}
src_test() {
ego test ./...
}
src_install() {
dobin ${PN}
doman docs/${PN}.1
keepdir /etc/${PN}
}
+19
View File
@@ -0,0 +1,19 @@
# Template file for 'unitdore'
pkgname=unitdore
version=0.0.11
revision=1
build_style=go
go_import_path="github.com/warkanum/unitdore"
go_ldflags="-X github.com/warkanum/unitdore/cmd.version=${version}"
short_desc="Manage container units via systemd, OpenRC or runit"
maintainer="Hein (Warky Devs) <hein@warky.dev>"
license="MIT"
homepage="https://git.warky.dev/wdevs/unitdore"
distfiles="${homepage}/releases/download/v${version}/unitdore-v${version}-source.tar.gz"
checksum=0ce16db55d2dbd5437c2820d19c76b0714c1b57568bf6db509184f66b7c4e7a3
post_install() {
vman docs/unitdore.1
vlicense LICENSE
vmkdir etc/unitdore
}
+364
View File
@@ -0,0 +1,364 @@
// Package runit generates and manages runit service directories.
//
// Each unit is a directory /etc/sv/<name> (run, finish) linked into the
// supervised service dir. Services run at system level (root only). Units with
// a user run as that user through chpst, with HOME and XDG_RUNTIME_DIR set.
//
// Install links the service with a `down` file: it is supervised but does not
// start until started or enabled. Enable removes `down`, Disable restores it.
package runit
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/service"
)
// Overridable for tests.
var (
svDir = "/etc/sv"
// serviceDirCandidates are probed in order when no service_dir is set.
serviceDirCandidates = []string{"/var/service", "/etc/service"}
// superviseWait is how long Start waits for runsvdir to pick up a new link.
superviseWait = 10 * time.Second
runCmd = func(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
outCmd = func(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).CombinedOutput()
}
)
// Backend is the runit implementation of the init interface.
type Backend struct {
ServiceDir string // supervised dir; empty = autodetect
}
func (Backend) Name() string { return "runit" }
// SupportsLinger is false: runit services need no linger.
func (Backend) SupportsLinger() bool { return false }
func (Backend) ServiceName(u config.Unit, prefix, suffix string) string {
return service.BaseName(u, prefix, suffix)
}
// UnitPath is the service definition directory under /etc/sv.
func (b Backend) UnitPath(u config.Unit, prefix, suffix string) (string, error) {
return filepath.Join(svDir, b.ServiceName(u, prefix, suffix)), nil
}
func (b Backend) IsInstalled(u config.Unit, prefix, suffix string) bool {
dir, _ := b.UnitPath(u, prefix, suffix)
_, err := os.Stat(filepath.Join(dir, "run"))
return err == nil
}
// serviceDir returns the supervised dir that gets the symlinks.
func (b Backend) serviceDir() string {
if b.ServiceDir != "" {
return b.ServiceDir
}
for _, d := range serviceDirCandidates {
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
return d
}
}
return serviceDirCandidates[0]
}
func (b Backend) link(u config.Unit, prefix, suffix string) string {
return filepath.Join(b.serviceDir(), b.ServiceName(u, prefix, suffix))
}
type file struct {
name string
content string
}
// Generate produces the run and finish scripts, separated by marker lines.
func (b Backend) Generate(u config.Unit, prefix, suffix string) (string, error) {
files, err := b.files(u, prefix, suffix)
if err != nil {
return "", err
}
dir, _ := b.UnitPath(u, prefix, suffix)
var w strings.Builder
for _, f := range files {
fmt.Fprintf(&w, "# ===== %s =====\n%s\n", filepath.Join(dir, f.name), f.content)
}
return w.String(), nil
}
func (b Backend) files(u config.Unit, prefix, suffix string) ([]file, error) {
if err := service.Validate(u); err != nil {
return nil, err
}
user, err := service.LookupUser(u.User)
if err != nil {
return nil, err
}
spec := service.Resolve(u)
start, err := service.ShellCommand(spec.Start)
if err != nil {
return nil, fmt.Errorf("unit %s: %w", u.Name, err)
}
stop := ""
if spec.Stop != "" {
if stop, err = service.ShellCommand(spec.Stop); err != nil {
return nil, fmt.Errorf("unit %s: %w", u.Name, err)
}
}
header := "#!/bin/sh\n# " + b.ServiceName(u, prefix, suffix) + " — generated by unitdore, do not edit manually\n"
run := header + "exec 2>&1\n" + preamble(user, spec.WorkingDir)
if spec.Type == service.TypeOneshot {
// Run once, then stay up (like RemainAfterExit) until stopped
run += asUser(user, start) + " || exit 1\nexec tail -f /dev/null\n"
} else {
run += "exec " + asUser(user, start) + "\n"
}
files := []file{{"run", run}}
if fin := finishScript(header, u, user, spec.WorkingDir, stop); fin != "" {
files = append(files, file{"finish", fin})
}
return files, nil
}
// preamble prepares the environment: working dir and rootless user runtime.
func preamble(user *service.UserEnv, workDir string) string {
var s string
if workDir != "" {
s += "cd " + service.ShellQuote(workDir) + " || exit 1\n"
}
if user != nil {
s += fmt.Sprintf("[ -d %[1]s ] || install -d -m 0700 -o %[2]s %[1]s\n",
service.ShellQuote(user.RuntimeDir), service.ShellQuote(user.Name))
s += "export HOME=" + service.ShellQuote(user.Home) + "\n"
s += "export XDG_RUNTIME_DIR=" + service.ShellQuote(user.RuntimeDir) + "\n"
}
return s
}
func asUser(user *service.UserEnv, cmd string) string {
if user == nil {
return cmd
}
return "chpst -u " + service.ShellQuote(user.Name) + " " + cmd
}
// finishScript runs after the service exits: best-effort stop command, then
// the restart policy. runit restarts by default, so no-restart stops it.
func finishScript(header string, u config.Unit, user *service.UserEnv, workDir, stop string) string {
var body string
if workDir != "" {
body += "cd " + service.ShellQuote(workDir) + " 2>/dev/null\n"
}
if stop != "" {
body += asUser(user, stop) + " || true\n"
}
switch {
case !u.Restart:
body += "exec sv down .\n"
default:
// Restart=on-failure: a clean exit stays down
body += "[ \"$1\" = 0 ] && exec sv down .\n"
if u.RestartSec > 0 {
body += fmt.Sprintf("sleep %d\n", u.RestartSec)
}
}
return header + body
}
func (b Backend) Install(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("runit"); err != nil {
return err
}
files, err := b.files(u, prefix, suffix)
if err != nil {
return err
}
dir, _ := b.UnitPath(u, prefix, suffix)
link := b.link(u, prefix, suffix)
// A re-install keeps an already enabled service enabled
wasEnabled := isLinked(link, dir) && !exists(filepath.Join(dir, "down"))
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("creating service dir: %w", err)
}
for _, f := range files {
if err := os.WriteFile(filepath.Join(dir, f.name), []byte(f.content), 0755); err != nil {
return fmt.Errorf("writing %s: %w", f.name, err)
}
}
if !hasFile(files, "finish") {
_ = os.Remove(filepath.Join(dir, "finish"))
}
if !wasEnabled {
if err := setDown(dir, true); err != nil {
return err
}
}
return ensureLink(dir, link)
}
func (b Backend) Uninstall(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("runit"); err != nil {
return err
}
dir, _ := b.UnitPath(u, prefix, suffix)
link := b.link(u, prefix, suffix)
_ = runCmd("sv", "stop", link) // not running is fine
if err := os.Remove(link); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("removing service link: %w", err)
}
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("removing service dir: %w", err)
}
return nil
}
// Enable makes the service start with runsvdir and starts it now.
func (b Backend) Enable(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("runit"); err != nil {
return err
}
dir, _ := b.UnitPath(u, prefix, suffix)
link := b.link(u, prefix, suffix)
if err := setDown(dir, false); err != nil {
return err
}
if err := ensureLink(dir, link); err != nil {
return err
}
return startLink(link)
}
// Disable stops the service and keeps it from starting with runsvdir.
func (b Backend) Disable(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("runit"); err != nil {
return err
}
dir, _ := b.UnitPath(u, prefix, suffix)
if err := setDown(dir, true); err != nil {
return err
}
_ = runCmd("sv", "stop", b.link(u, prefix, suffix)) // not running is fine
return nil
}
func (b Backend) Start(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("runit"); err != nil {
return err
}
dir, _ := b.UnitPath(u, prefix, suffix)
link := b.link(u, prefix, suffix)
if err := ensureLink(dir, link); err != nil {
return err
}
return startLink(link)
}
func (b Backend) Stop(u config.Unit, prefix, suffix string) error {
if err := service.RequireRoot("runit"); err != nil {
return err
}
return runCmd("sv", "stop", b.link(u, prefix, suffix))
}
func (b Backend) Status(u config.Unit, prefix, suffix string) string {
out, _ := outCmd("sv", "status", b.link(u, prefix, suffix))
return parseStatus(string(out))
}
// parseStatus maps `sv status` output to a systemd-like state.
func parseStatus(out string) string {
out = strings.TrimSpace(out)
switch {
case strings.HasPrefix(out, "run:"):
return "active"
case strings.HasPrefix(out, "down:"):
return "inactive"
case strings.HasPrefix(out, "finish:"):
return "deactivating"
case strings.Contains(out, "runsv not running"):
return "inactive"
default:
return "unknown"
}
}
// startLink waits until runsvdir supervises the link, then starts it.
func startLink(link string) error {
deadline := time.Now().Add(superviseWait)
for !exists(filepath.Join(link, "supervise", "ok")) {
if time.Now().After(deadline) {
return fmt.Errorf("%s is not supervised yet (is runsvdir running?)", link)
}
time.Sleep(200 * time.Millisecond)
}
return runCmd("sv", "start", link)
}
func ensureLink(dir, link string) error {
if isLinked(link, dir) {
return nil
}
if err := os.MkdirAll(filepath.Dir(link), 0755); err != nil {
return fmt.Errorf("creating service dir: %w", err)
}
if err := os.Symlink(dir, link); err != nil {
return fmt.Errorf("linking service: %w", err)
}
return nil
}
func isLinked(link, dir string) bool {
target, err := os.Readlink(link)
return err == nil && target == dir
}
func setDown(dir string, down bool) error {
path := filepath.Join(dir, "down")
if !down {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("removing down file: %w", err)
}
return nil
}
if err := os.WriteFile(path, nil, 0644); err != nil {
return fmt.Errorf("writing down file: %w", err)
}
return nil
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func hasFile(files []file, name string) bool {
for _, f := range files {
if f.name == name {
return true
}
}
return false
}
+295
View File
@@ -0,0 +1,295 @@
package runit
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/service"
)
// setup points the backend at temp dirs with recorded commands.
func setup(t *testing.T) (b Backend, calls *[]string) {
t.Helper()
oldSv, oldRun, oldOut, oldRoot, oldWait := svDir, runCmd, outCmd, service.IsRoot, superviseWait
t.Cleanup(func() { svDir, runCmd, outCmd, service.IsRoot, superviseWait = oldSv, oldRun, oldOut, oldRoot, oldWait })
svDir = t.TempDir()
superviseWait = 300 * time.Millisecond
service.IsRoot = func() bool { return true }
var recorded []string
runCmd = func(name string, args ...string) error {
recorded = append(recorded, name+" "+strings.Join(args, " "))
return nil
}
return Backend{ServiceDir: t.TempDir()}, &recorded
}
func mustContain(t *testing.T, content string, wants ...string) {
t.Helper()
for _, w := range wants {
if !strings.Contains(content, w) {
t.Errorf("missing %q in:\n%s", w, content)
}
}
}
func read(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func TestGenerate_Container(t *testing.T) {
b, _ := setup(t)
u := config.Unit{Name: "nginx", Runtime: "podman"}
got, err := b.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got,
"===== "+filepath.Join(svDir, "unitdore-nginx", "run")+" =====",
"exec 2>&1",
"exec /usr/bin/podman start -a nginx",
"===== "+filepath.Join(svDir, "unitdore-nginx", "finish")+" =====",
"/usr/bin/podman stop nginx || true",
"exec sv down .",
)
}
func TestGenerate_RestartPolicy(t *testing.T) {
b, _ := setup(t)
u := config.Unit{Name: "app", Runtime: "podman", Restart: true, RestartSec: 7}
got, err := b.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got, `[ "$1" = 0 ] && exec sv down .`, "sleep 7")
if strings.Contains(got, "\nexec sv down .\n") {
t.Errorf("restarting unit must not always go down:\n%s", got)
}
}
func TestGenerate_ScriptExecAndWorkingDir(t *testing.T) {
b, _ := setup(t)
u := config.Unit{
Name: "agent", Runtime: config.RuntimeExec, Executable: "/opt/agent",
Args: []string{"--config", "/etc/my agent.toml"}, WorkingDir: "/opt",
}
got, err := b.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got, "cd /opt || exit 1", `exec /opt/agent --config '/etc/my agent.toml'`)
}
func TestGenerate_Oneshot(t *testing.T) {
b, _ := setup(t)
u := config.Unit{Name: "shop", Runtime: "podman", Kind: config.KindStack, Project: "shop"}
got, err := b.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got,
"/usr/bin/podman pod start pod_shop || exit 1\nexec tail -f /dev/null",
"/usr/bin/podman pod stop pod_shop || true",
)
}
func TestGenerate_RootlessUser(t *testing.T) {
b, _ := setup(t)
u := config.Unit{Name: "app", Runtime: "podman", User: "root"}
got, err := b.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got,
"[ -d /run/user/0 ] || install -d -m 0700 -o root /run/user/0",
"export HOME=/root",
"export XDG_RUNTIME_DIR=/run/user/0",
"exec chpst -u root /usr/bin/podman start -a app",
"chpst -u root /usr/bin/podman stop app || true",
)
}
func TestGenerate_Invalid(t *testing.T) {
b, _ := setup(t)
if _, err := b.Generate(config.Unit{Name: "x", Runtime: config.RuntimeScript}, "", ""); err == nil {
t.Error("expected validation error")
}
}
func TestInstall_WritesDirLinkAndDown(t *testing.T) {
b, _ := setup(t)
u := config.Unit{Name: "nginx", Runtime: "podman"}
dir := filepath.Join(svDir, "unitdore-nginx")
link := filepath.Join(b.ServiceDir, "unitdore-nginx")
if b.IsInstalled(u, "", "") {
t.Fatal("should not be installed yet")
}
if err := b.Install(u, "", ""); err != nil {
t.Fatal(err)
}
for _, name := range []string{"run", "finish"} {
fi, err := os.Stat(filepath.Join(dir, name))
if err != nil || fi.Mode().Perm()&0111 == 0 {
t.Errorf("%s missing or not executable: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(dir, "down")); err != nil {
t.Error("fresh install must write a down file")
}
if target, err := os.Readlink(link); err != nil || target != dir {
t.Errorf("link = %q, %v", target, err)
}
if !b.IsInstalled(u, "", "") {
t.Error("should be installed")
}
}
func TestInstall_ReinstallKeepsEnabled(t *testing.T) {
b, _ := setup(t)
u := config.Unit{Name: "nginx", Runtime: "podman"}
dir := filepath.Join(svDir, "unitdore-nginx")
if err := b.Install(u, "", ""); err != nil {
t.Fatal(err)
}
if err := b.Enable(u, "", ""); err == nil {
t.Log("enable ok") // may time out waiting for supervise; down file still removed
}
if _, err := os.Stat(filepath.Join(dir, "down")); !os.IsNotExist(err) {
t.Fatal("enable must remove the down file")
}
u.Restart = true
if err := b.Install(u, "", ""); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "down")); !os.IsNotExist(err) {
t.Error("re-install must keep an enabled service enabled")
}
if !strings.Contains(read(t, filepath.Join(dir, "finish")), `"$1" = 0`) {
t.Error("re-install must rewrite scripts")
}
}
func TestDisable_WritesDownAndStops(t *testing.T) {
b, calls := setup(t)
u := config.Unit{Name: "nginx", Runtime: "podman"}
dir := filepath.Join(svDir, "unitdore-nginx")
if err := b.Install(u, "", ""); err != nil {
t.Fatal(err)
}
*calls = nil
if err := b.Disable(u, "", ""); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "down")); err != nil {
t.Error("disable must write a down file")
}
if got := strings.Join(*calls, "|"); got != "sv stop "+filepath.Join(b.ServiceDir, "unitdore-nginx") {
t.Errorf("calls = %q", got)
}
}
func TestStartStop_UseSv(t *testing.T) {
b, calls := setup(t)
u := config.Unit{Name: "nginx", Runtime: "podman"}
link := filepath.Join(b.ServiceDir, "unitdore-nginx")
if err := b.Install(u, "", ""); err != nil {
t.Fatal(err)
}
// Not supervised yet: Start must give up with a clear error
if err := b.Start(u, "", ""); err == nil || !strings.Contains(err.Error(), "not supervised") {
t.Errorf("Start() err = %v", err)
}
if err := os.MkdirAll(filepath.Join(link, "supervise"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(link, "supervise", "ok"), nil, 0644); err != nil {
t.Fatal(err)
}
*calls = nil
if err := b.Start(u, "", ""); err != nil {
t.Fatal(err)
}
if err := b.Stop(u, "", ""); err != nil {
t.Fatal(err)
}
if got := strings.Join(*calls, "|"); got != "sv start "+link+"|sv stop "+link {
t.Errorf("calls = %q", got)
}
}
func TestUninstall_RemovesDirAndLink(t *testing.T) {
b, calls := setup(t)
u := config.Unit{Name: "nginx", Runtime: "podman"}
if err := b.Install(u, "", ""); err != nil {
t.Fatal(err)
}
*calls = nil
if err := b.Uninstall(u, "", ""); err != nil {
t.Fatal(err)
}
if b.IsInstalled(u, "", "") {
t.Error("should be uninstalled")
}
if _, err := os.Lstat(filepath.Join(b.ServiceDir, "unitdore-nginx")); !os.IsNotExist(err) {
t.Error("link should be gone")
}
if len(*calls) != 1 || !strings.HasPrefix((*calls)[0], "sv stop ") {
t.Errorf("calls = %q", *calls)
}
}
func TestServiceDirAutodetect(t *testing.T) {
old := serviceDirCandidates
t.Cleanup(func() { serviceDirCandidates = old })
existing := t.TempDir()
serviceDirCandidates = []string{filepath.Join(existing, "nope"), existing}
if got := (Backend{}).serviceDir(); got != existing {
t.Errorf("serviceDir() = %q, want %q", got, existing)
}
if got := (Backend{ServiceDir: "/custom"}).serviceDir(); got != "/custom" {
t.Errorf("explicit serviceDir() = %q", got)
}
}
func TestRequiresRoot(t *testing.T) {
b, _ := setup(t)
service.IsRoot = func() bool { return false }
u := config.Unit{Name: "nginx", Runtime: "podman"}
for name, err := range map[string]error{
"install": b.Install(u, "", ""), "uninstall": b.Uninstall(u, "", ""),
"start": b.Start(u, "", ""), "stop": b.Stop(u, "", ""),
"enable": b.Enable(u, "", ""), "disable": b.Disable(u, "", ""),
} {
if err == nil {
t.Errorf("%s should require root", name)
}
}
}
func TestParseStatus(t *testing.T) {
tests := map[string]string{
"run: /var/service/x: (pid 123) 5s": "active",
"down: /var/service/x: 3s, normally up": "inactive",
"finish: /var/service/x: (pid 9) 1s": "deactivating",
"fail: /var/service/x: runsv not running": "inactive",
"warning: weird": "unknown",
}
for in, want := range tests {
if got := parseStatus(in); got != want {
t.Errorf("parseStatus(%q) = %q, want %q", in, got, want)
}
}
}
+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)
} }
+65 -12
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{}
@@ -12,30 +14,56 @@ type Podman struct{}
func (p *Podman) Name() string { return "podman" } func (p *Podman) Name() string { return "podman" }
type podmanContainer struct { type podmanContainer struct {
ID string `json:"Id"` ID string `json:"Id"`
Names []string `json:"Names"` Names []string `json:"Names"`
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"))
}
}
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# Usage: scripts/bump-version.sh X.Y.Z
# Updates the version in source and every package definition.
# Run from the repo root. Void checksum is reset: fill it with
# scripts/update-checksums.sh once the release tarball exists.
set -eu
VER="${1:?usage: $0 X.Y.Z}"
EBUILD_DIR=pkg/gentoo/app-containers/unitdore
sed -i "s/^var version = .*/var version = \"$VER\"/" cmd/root.go
sed -i "s/^pkgver=.*/pkgver=$VER/" pkg/arch/PKGBUILD
sed -i "s/^Version:.*/Version: $VER/" pkg/centos/unitdore.spec
# Gentoo: the version lives in the ebuild file name
OLD_EBUILD=$(ls "$EBUILD_DIR"/unitdore-*.ebuild | head -n1)
NEW_EBUILD="$EBUILD_DIR/unitdore-$VER.ebuild"
[ "$OLD_EBUILD" = "$NEW_EBUILD" ] || git mv "$OLD_EBUILD" "$NEW_EBUILD"
# Void: version bump invalidates the checksum
sed -i -e "s/^version=.*/version=$VER/" \
-e "s/^revision=.*/revision=1/" \
-e "s/^checksum=.*/checksum=UPDATE_AFTER_RELEASE/" pkg/void/srcpkgs/unitdore/template
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# Usage: scripts/update-checksums.sh
# Fills the Void template checksum from the published release source tarball.
# Run from the repo root after the release workflow has finished.
set -eu
T=pkg/void/srcpkgs/unitdore/template
VER=$(sed -n 's/^version=//p' "$T")
URL="https://git.warky.dev/wdevs/unitdore/releases/download/v$VER/unitdore-v$VER-source.tar.gz"
SUM=$(curl -fsSL "$URL" | sha256sum | cut -d' ' -f1)
sed -i "s/^checksum=.*/checksum=$SUM/" "$T"
echo "void checksum for v$VER: $SUM"
+294
View File
@@ -0,0 +1,294 @@
// Package service resolves a config.Unit into init-system-neutral run
// instructions (commands, working dir, type) shared by every init backend.
package service
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/sysuser"
)
// Service types.
const (
TypeSimple = "simple"
TypeOneshot = "oneshot"
)
// Stack modes: compose (podman-compose up/stop) with pod start/stop as fallback.
const (
StackModeCompose = "compose"
StackModePod = "pod"
)
// Spec is what an init backend needs to run a unit. Start and Stop are
// command lines in systemd quoting; use SplitCommand to get argv.
type Spec struct {
Start string
Stop string // empty = none
Label string
WorkingDir string
Type string // TypeSimple | TypeOneshot
}
// BaseName returns the init-neutral service name: unitdore-<prefix><name><suffix>.
func BaseName(u config.Unit, prefix, suffix string) string {
return fmt.Sprintf("unitdore-%s%s%s", prefix, u.Name, suffix)
}
// Validate reports unit configs that cannot be turned into a service.
func Validate(u config.Unit) error {
if u.Command != "" {
return nil
}
if u.IsScript() && u.Script == "" {
return fmt.Errorf("unit %s: runtime script requires 'script' (or 'command')", u.Name)
}
if u.IsExec() && u.Executable == "" {
return fmt.Errorf("unit %s: runtime exec requires 'executable' (or 'command')", u.Name)
}
return nil
}
// Resolve builds the run instructions for a unit. Call Validate first.
func Resolve(u config.Unit) Spec {
spec := Spec{Label: u.Runtime, Type: TypeSimple}
switch {
case u.IsScript() && u.Command == "":
spec.Start, spec.Stop = buildScriptCommands(u)
spec.Label = filepath.Base(u.ShellPath()) + " script"
spec.WorkingDir = u.WorkingDir
spec.Type = serviceType(u)
case u.IsExec() && u.Command == "":
spec.Start, spec.Stop = buildExecutableCommands(u)
spec.Label = "executable"
spec.WorkingDir = u.WorkingDir
spec.Type = serviceType(u)
case u.IsStack() && u.Command == "" && StackMode(u) == StackModeCompose:
spec.Start, spec.Stop = buildComposeCommands(u)
spec.Label = "podman-compose stack"
spec.WorkingDir = u.WorkingDir
case u.IsStack() && u.Command == "":
spec.Start, spec.Stop = buildPodCommands(u)
spec.Label = "podman pod"
spec.Type = TypeOneshot
default:
spec.Start, spec.Stop = buildExecCommands(u)
}
return spec
}
func serviceType(u config.Unit) string {
if u.ServiceType == TypeOneshot {
return TypeOneshot
}
return TypeSimple
}
func buildScriptCommands(u config.Unit) (start, stop string) {
shell := QuoteArg(u.ShellPath())
start = shell + " " + QuoteArg(u.Script)
if u.StopScript != "" {
stop = shell + " " + QuoteArg(u.StopScript)
}
return start, stop
}
func buildExecutableCommands(u config.Unit) (start, stop string) {
parts := []string{QuoteArg(u.Executable)}
for _, a := range u.Args {
parts = append(parts, QuoteArg(a))
}
if u.StopExecutable != "" {
stop = QuoteArg(u.StopExecutable)
}
return strings.Join(parts, " "), stop
}
func buildExecCommands(u config.Unit) (start, stop string) {
// If a custom command is provided, use it directly
if u.Command != "" {
return u.Command, ""
}
switch u.Runtime {
case "podman":
start = fmt.Sprintf("/usr/bin/podman start -a %s", u.Name)
stop = fmt.Sprintf("/usr/bin/podman stop %s", u.Name)
case "docker":
start = fmt.Sprintf("/usr/bin/docker start -a %s", u.Name)
stop = fmt.Sprintf("/usr/bin/docker stop %s", u.Name)
default:
start = fmt.Sprintf("/usr/bin/%s start %s", u.Runtime, u.Name)
stop = fmt.Sprintf("/usr/bin/%s stop %s", u.Runtime, u.Name)
}
return start, stop
}
// 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) + `"`
}
// SplitCommand splits a systemd-style command line into argv. It understands
// double quotes, single quotes and backslash escapes.
func SplitCommand(s string) ([]string, error) {
var (
args []string
cur strings.Builder
inArg bool
quote rune
escape bool
)
for _, r := range s {
switch {
case escape:
cur.WriteRune(r)
escape = false
case r == '\\' && quote != '\'':
escape = true
inArg = true
case quote != 0:
if r == quote {
quote = 0
} else {
cur.WriteRune(r)
}
case r == '"' || r == '\'':
quote = r
inArg = true
case r == ' ' || r == '\t' || r == '\n':
if inArg {
args = append(args, cur.String())
cur.Reset()
inArg = false
}
default:
cur.WriteRune(r)
inArg = true
}
}
if quote != 0 {
return nil, fmt.Errorf("unterminated quote in command: %s", s)
}
if escape {
return nil, fmt.Errorf("trailing backslash in command: %s", s)
}
if inArg {
args = append(args, cur.String())
}
return args, nil
}
// ShellQuote quotes s for a POSIX shell, only when needed.
func ShellQuote(s string) string {
if s != "" && strings.Trim(s, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-") == "" {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// ShellCommand converts a systemd-style command line into a POSIX shell line.
func ShellCommand(s string) (string, error) {
argv, err := SplitCommand(s)
if err != nil {
return "", err
}
for i, a := range argv {
argv[i] = ShellQuote(a)
}
return strings.Join(argv, " "), nil
}
// UserEnv holds what a system-level service needs to run a command as a
// rootless user (podman needs HOME and XDG_RUNTIME_DIR).
type UserEnv struct {
Name string
UID string
Home string
RuntimeDir string
}
// LookupUser resolves the account behind a unit's user. Empty user returns nil.
func LookupUser(user string) (*UserEnv, error) {
if user == "" {
return nil, nil
}
uid, err := sysuser.UID(user)
if err != nil {
return nil, err
}
home, err := sysuser.HomeDir(user)
if err != nil {
return nil, err
}
return &UserEnv{Name: user, UID: uid, Home: home, RuntimeDir: "/run/user/" + uid}, nil
}
// IsRoot is sysuser.IsRoot; a variable so tests can override it.
var IsRoot = sysuser.IsRoot
// RequireRoot errors unless running as root. Non-systemd backends write to
// system directories and manage system-level services only.
func RequireRoot(backend string) error {
if !IsRoot() {
return fmt.Errorf("%s backend requires root", backend)
}
return nil
}
+215
View File
@@ -0,0 +1,215 @@
package service
import (
"os"
"path/filepath"
"testing"
"github.com/warkanum/unitdore/config"
)
func TestBuildExecCommands(t *testing.T) {
tests := []struct {
name string
unit config.Unit
wantStart string
wantStop string
}{
{
name: "podman",
unit: config.Unit{Name: "app", Runtime: "podman"},
wantStart: "/usr/bin/podman start -a app",
wantStop: "/usr/bin/podman stop app",
},
{
name: "docker",
unit: config.Unit{Name: "app", Runtime: "docker"},
wantStart: "/usr/bin/docker start -a app",
wantStop: "/usr/bin/docker stop app",
},
{
name: "custom command",
unit: config.Unit{Name: "app", Runtime: "podman", Command: "/bin/custom"},
wantStart: "/bin/custom",
wantStop: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
start, stop := buildExecCommands(tt.unit)
if start != tt.wantStart {
t.Errorf("start = %q, want %q", start, tt.wantStart)
}
if stop != tt.wantStop {
t.Errorf("stop = %q, want %q", stop, tt.wantStop)
}
})
}
}
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 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)
}
}
// 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 TestSplitCommand(t *testing.T) {
tests := []struct {
in string
want []string
wantErr bool
}{
{in: "/usr/bin/podman start -a app", want: []string{"/usr/bin/podman", "start", "-a", "app"}},
{in: `/bin/x "my file.txt" 'a b'`, want: []string{"/bin/x", "my file.txt", "a b"}},
{in: `/bin/x "say \"hi\""`, want: []string{"/bin/x", `say "hi"`}},
{in: `/bin/x ""`, want: []string{"/bin/x", ""}},
{in: " ", want: nil},
{in: `/bin/x "open`, wantErr: true},
{in: `/bin/x \`, wantErr: true},
}
for _, tt := range tests {
got, err := SplitCommand(tt.in)
if (err != nil) != tt.wantErr {
t.Errorf("SplitCommand(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr)
continue
}
if len(got) != len(tt.want) {
t.Errorf("SplitCommand(%q) = %q, want %q", tt.in, got, tt.want)
continue
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("SplitCommand(%q)[%d] = %q, want %q", tt.in, i, got[i], tt.want[i])
}
}
}
}
func TestQuoteArg_SplitCommand_RoundTrip(t *testing.T) {
for _, arg := range []string{"plain", "with space", `a"b`, `back\slash`, "it's"} {
got, err := SplitCommand("x " + QuoteArg(arg))
if err != nil || len(got) != 2 || got[1] != arg {
t.Errorf("round trip %q = %q, %v", arg, got, err)
}
}
}
func TestShellQuote(t *testing.T) {
tests := map[string]string{
"plain-1.0/x": "plain-1.0/x",
"": "''",
"a b": "'a b'",
"it's": `'it'\''s'`,
"$HOME": "'$HOME'",
}
for in, want := range tests {
if got := ShellQuote(in); got != want {
t.Errorf("ShellQuote(%q) = %q, want %q", in, got, want)
}
}
}
func TestShellCommand(t *testing.T) {
got, err := ShellCommand(`/opt/agent --config "/etc/my agent.toml"`)
if err != nil {
t.Fatal(err)
}
if want := `/opt/agent --config '/etc/my agent.toml'`; got != want {
t.Errorf("ShellCommand() = %q, want %q", got, want)
}
}
func TestValidate(t *testing.T) {
bad := []config.Unit{
{Name: "a", Runtime: config.RuntimeScript},
{Name: "b", Runtime: config.RuntimeExec},
}
for _, u := range bad {
if Validate(u) == nil {
t.Errorf("Validate(%s) should fail", u.Name)
}
}
ok := []config.Unit{
{Name: "a", Runtime: config.RuntimeScript, Command: "/x"},
{Name: "b", Runtime: "podman"},
}
for _, u := range ok {
if err := Validate(u); err != nil {
t.Errorf("Validate(%s) = %v", u.Name, err)
}
}
}
func TestBaseName(t *testing.T) {
if got := BaseName(config.Unit{Name: "x"}, "p-", "-s"); got != "unitdore-p-x-s" {
t.Errorf("BaseName() = %q", got)
}
}
func TestResolve_PodFallbackIsOneshot(t *testing.T) {
u := config.Unit{Name: "shop", Runtime: "podman", Kind: config.KindStack, Project: "shop"}
spec := Resolve(u)
if spec.Type != TypeOneshot || spec.Start != "/usr/bin/podman pod start pod_shop" {
t.Errorf("Resolve() = %+v", spec)
}
}
+37
View File
@@ -0,0 +1,37 @@
package systemd
import "github.com/warkanum/unitdore/config"
// Backend is the systemd implementation of the init interface.
type Backend struct{}
func (Backend) Name() string { return "systemd" }
func (Backend) ServiceName(u config.Unit, prefix, suffix string) string {
return ServiceName(u, prefix, suffix)
}
func (Backend) UnitPath(u config.Unit, prefix, suffix string) (string, error) {
return UnitPath(u, prefix, suffix)
}
func (Backend) Generate(u config.Unit, prefix, suffix string) (string, error) {
return Generate(u, prefix, suffix)
}
func (Backend) Install(u config.Unit, prefix, suffix string) error { return Install(u, prefix, suffix) }
func (Backend) Uninstall(u config.Unit, prefix, suffix string) error {
return Uninstall(u, prefix, suffix)
}
func (Backend) Enable(u config.Unit, prefix, suffix string) error { return Enable(u, prefix, suffix) }
func (Backend) Disable(u config.Unit, prefix, suffix string) error { return Disable(u, prefix, suffix) }
func (Backend) Start(u config.Unit, prefix, suffix string) error { return Start(u, prefix, suffix) }
func (Backend) Stop(u config.Unit, prefix, suffix string) error { return Stop(u, prefix, suffix) }
func (Backend) Status(u config.Unit, prefix, suffix string) string { return Status(u, prefix, suffix) }
func (Backend) IsInstalled(u config.Unit, prefix, suffix string) bool {
return IsInstalled(u, prefix, suffix)
}
// SupportsLinger reports that user units need loginctl linger to start at boot.
func (Backend) SupportsLinger() bool { return true }
+30 -28
View File
@@ -6,18 +6,25 @@ import (
"text/template" "text/template"
"github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/service"
) )
const systemUnitTemplate = `# /etc/systemd/system/{{.ServiceName}} 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 +43,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,23 +74,32 @@ 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.
func ServiceName(u config.Unit, prefix, suffix string) string { func ServiceName(u config.Unit, prefix, suffix string) string {
return fmt.Sprintf("unitdore-%s%s%s.service", prefix, u.Name, suffix) return service.BaseName(u, prefix, suffix) + ".service"
} }
// 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) if err := service.Validate(u); err != nil {
return "", err
}
spec := service.Resolve(u)
data := templateData{ data := templateData{
ServiceName: ServiceName(u, prefix, suffix), ServiceName: ServiceName(u, prefix, suffix),
Unit: u, Unit: u,
ExecStart: execStart, ExecStart: spec.Start,
ExecStop: execStop, ExecStop: spec.Stop,
RuntimeService: runtimeService(u.Runtime), RuntimeService: runtimeService(u.Runtime),
Label: spec.Label,
ServiceType: spec.Type,
WorkingDir: spec.WorkingDir,
} }
tmplStr := systemUnitTemplate tmplStr := systemUnitTemplate
@@ -105,23 +127,3 @@ func runtimeService(runtime string) string {
} }
return "" return ""
} }
func buildExecCommands(u config.Unit) (start, stop string) {
// If a custom command is provided, use it directly
if u.Command != "" {
return u.Command, ""
}
switch u.Runtime {
case "podman":
start = fmt.Sprintf("/usr/bin/podman start -a %s", u.Name)
stop = fmt.Sprintf("/usr/bin/podman stop %s", u.Name)
case "docker":
start = fmt.Sprintf("/usr/bin/docker start -a %s", u.Name)
stop = fmt.Sprintf("/usr/bin/docker stop %s", u.Name)
default:
start = fmt.Sprintf("/usr/bin/%s start %s", u.Runtime, u.Name)
stop = fmt.Sprintf("/usr/bin/%s stop %s", u.Runtime, u.Name)
}
return start, stop
}
+209 -35
View File
@@ -1,6 +1,8 @@
package systemd package systemd
import ( import (
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
@@ -224,42 +226,214 @@ func TestGenerate_WithPrefixSuffix(t *testing.T) {
} }
} }
func TestBuildExecCommands(t *testing.T) { // newComposeStack creates a stack unit whose compose binary, working dir and
tests := []struct { // compose file exist on disk (compose mode).
name string func newComposeStack(t *testing.T) config.Unit {
unit config.Unit t.Helper()
wantStart string dir := t.TempDir()
wantStop string bin := filepath.Join(dir, "podman-compose")
}{ if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0755); err != nil {
{ t.Fatal(err)
name: "podman",
unit: config.Unit{Name: "app", Runtime: "podman"},
wantStart: "/usr/bin/podman start -a app",
wantStop: "/usr/bin/podman stop app",
},
{
name: "docker",
unit: config.Unit{Name: "app", Runtime: "docker"},
wantStart: "/usr/bin/docker start -a app",
wantStop: "/usr/bin/docker stop app",
},
{
name: "custom command",
unit: config.Unit{Name: "app", Runtime: "podman", Command: "/bin/custom"},
wantStart: "/bin/custom",
wantStop: "",
},
} }
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,
}
}
for _, tt := range tests { func TestGenerate_StackCompose(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { u := newComposeStack(t)
start, stop := buildExecCommands(tt.unit) content, err := Generate(u, "", "")
if start != tt.wantStart { if err != nil {
t.Errorf("start = %q, want %q", start, tt.wantStart) t.Fatalf("Generate() error: %v", err)
} }
if stop != tt.wantStop { base := u.ComposeBin + " -p shop -f compose.yaml"
t.Errorf("stop = %q, want %q", stop, tt.wantStop) 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 TestGenerate_Script(t *testing.T) {
u := config.Unit{
Name: "backup",
Runtime: config.RuntimeScript,
Script: "/opt/scripts/backup.sh",
StopScript: "/opt/scripts/backup-stop.sh",
WorkingDir: "/opt/scripts",
Enabled: true,
}
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
checks := []string{
"Description=Unitdore: backup (bash script)",
"Type=simple",
"WorkingDirectory=/opt/scripts",
"ExecStart=/bin/bash /opt/scripts/backup.sh",
"ExecStop=-/bin/bash /opt/scripts/backup-stop.sh",
}
for _, check := range checks {
if !strings.Contains(content, check) {
t.Errorf("Generate() script missing %q in output:\n%s", check, content)
}
}
if strings.Contains(content, "Requires=") {
t.Errorf("Generate() script unit should not contain Requires=:\n%s", content)
}
}
func TestGenerate_ScriptOneshotUserQuoted(t *testing.T) {
u := config.Unit{
Name: "setup",
Runtime: config.RuntimeScript,
User: "alice",
Script: "/home/alice/my scripts/setup.sh",
ServiceType: "oneshot",
Enabled: true,
}
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
checks := []string{
"Type=oneshot",
"RemainAfterExit=yes",
`ExecStart=/bin/bash "/home/alice/my scripts/setup.sh"`,
"WantedBy=default.target",
}
for _, check := range checks {
if !strings.Contains(content, check) {
t.Errorf("Generate() missing %q in output:\n%s", check, content)
}
}
if strings.Contains(content, "ExecStop=-") {
t.Errorf("Generate() should have no stop command without stop_script:\n%s", content)
}
}
func TestGenerate_ScriptMissingPath(t *testing.T) {
u := config.Unit{Name: "bad", Runtime: config.RuntimeScript, Enabled: true}
if _, err := Generate(u, "", ""); err == nil {
t.Error("Generate() should error when script path is empty")
}
}
func TestGenerate_ScriptCustomShell(t *testing.T) {
u := config.Unit{
Name: "fishy",
Runtime: config.RuntimeScript,
Shell: "/usr/bin/zsh",
Script: "/opt/run.zsh",
Enabled: true,
}
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
for _, check := range []string{"Description=Unitdore: fishy (zsh script)", "ExecStart=/usr/bin/zsh /opt/run.zsh"} {
if !strings.Contains(content, check) {
t.Errorf("Generate() missing %q in output:\n%s", check, content)
}
}
}
func TestGenerate_Executable(t *testing.T) {
u := config.Unit{
Name: "agent",
Runtime: config.RuntimeExec,
Executable: "/opt/agent/bin/agent",
Args: []string{"--config", "/etc/my agent.toml"},
StopExecutable: "/opt/agent/bin/agent-stop",
WorkingDir: "/opt/agent",
ServiceType: "oneshot",
Enabled: true,
}
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
checks := []string{
"Description=Unitdore: agent (executable)",
"Type=oneshot",
"WorkingDirectory=/opt/agent",
`ExecStart=/opt/agent/bin/agent --config "/etc/my agent.toml"`,
"ExecStop=-/opt/agent/bin/agent-stop",
}
for _, check := range checks {
if !strings.Contains(content, check) {
t.Errorf("Generate() missing %q in output:\n%s", check, content)
}
}
}
func TestGenerate_ExecutableMissingPath(t *testing.T) {
u := config.Unit{Name: "bad", Runtime: config.RuntimeExec, Enabled: true}
if _, err := Generate(u, "", ""); err == nil {
t.Error("Generate() should error when executable is empty")
} }
} }
+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
}
-25
View File
@@ -2,36 +2,11 @@ package systemd
import ( import (
"sort" "sort"
"strings"
"testing" "testing"
"github.com/warkanum/unitdore/config" "github.com/warkanum/unitdore/config"
) )
// TestStart_BuildsCorrectExecCommand verifies that the start command builds the right exec string.
func TestStart_BuildsCorrectExecCommand(t *testing.T) {
u := config.Unit{Name: "nginx", Runtime: "podman"}
start, stop := buildExecCommands(u)
if !strings.Contains(start, "/usr/bin/podman start -a nginx") {
t.Errorf("expected podman start command in %q", start)
}
if !strings.Contains(stop, "/usr/bin/podman stop nginx") {
t.Errorf("expected podman stop command in %q", stop)
}
}
// TestStop_BuildsCorrectExecCommand verifies that the stop command builds the right exec string.
func TestStop_BuildsCorrectExecCommand(t *testing.T) {
u := config.Unit{Name: "nginx", Runtime: "podman"}
start, stop := buildExecCommands(u)
if !strings.Contains(start, "/usr/bin/podman start -a nginx") {
t.Errorf("expected podman start command in %q", start)
}
if !strings.Contains(stop, "/usr/bin/podman stop nginx") {
t.Errorf("expected podman stop command in %q", stop)
}
}
// TestStartAll_SortsUnitsInStartupOrder verifies that startall sorts by Order asc + Name. // TestStartAll_SortsUnitsInStartupOrder verifies that startall sorts by Order asc + Name.
func TestStartAll_SortsUnitsInStartupOrder(t *testing.T) { func TestStartAll_SortsUnitsInStartupOrder(t *testing.T) {
u1 := config.Unit{Name: "app2", Runtime: "podman", Order: 5, Enabled: true} u1 := config.Unit{Name: "app2", Runtime: "podman", Order: 5, Enabled: true}
+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
}
]
View File
Whitespace-only changes.