23 Commits
Author SHA1 Message Date
warkanum 3f51386e2c chore(release): update package version to 1.0.0
Release / pkg-deb (push) Failing after 19s
Release / pkg-rpm (push) Successful in 47s
Release / test (push) Successful in 17s
Release / release (push) Successful in 17s
Release / pkg-aur (push) Successful in 33s
2026-10-12 22:03:16 +02:00
warkanum acd7c2f699 refactor(Makefile): simplify release-version logic and remove version-set 2026-10-12 22:03:14 +02:00
warkanum 9a3ab1b91b chore(release): update package version to 0.0.12
Release / test (push) Successful in 20s
Release / pkg-aur (push) Successful in 34s
Release / pkg-rpm (push) Successful in 38s
Release / release (push) Successful in 16s
Release / pkg-deb (push) Failing after 22s
2026-10-12 22:01:25 +02:00
warkanum 91811b4fa1 feat: order/delay in service files, enable cmd, --all on unit commands
- install/uninstall/start/stop/enable take <name> or --all; *all kept as shortcuts
- add enable (enable at boot + start); startall no longer enables
- order/delay resolved by config.ResolveOrder and written into systemd,
  OpenRC and runit service files (After=/sleep), so they apply at boot
- install --all stops and disables units before removing their files
- update regenerates all installed, enabled units and removes disabled ones
- edit creates the config dir; docs and man page updated
- tests: fake Init for cmd, ResolveOrder, backend ordering output
2026-10-12 21:31:41 +02:00
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
Hermes Agent 5026ae4d16 feat(cmd,systemd): add focused regression tests for start/stop/startall/stopall
Add cmd/start_test.go and systemd/start_stop_test.go with regression
tests covering:

- Start/Stop require exactly one unit name argument (cobra.ExactArgs(1)
  semantics verified via ValidateArgs)
- Start/Stop build correct exec commands per runtime
- StartAll sorts units by Order asc, then Name asc as tiebreaker
- StopAll sorts units by Order desc, then Name desc as tiebreaker
- StartAll skips disabled units (Enabled=false)
- StopAll skips uninstalled units

These tests enforce the CLI semantics that 'start' starts only a specific
unit and 'stop' stops only a specific unit; 'startall' and 'stopall'
operate on all matching units.

Run: make test
2026-07-16 22:26:11 +02:00
warkanum 917480670f chore(release): update package version to 0.0.10
Release / test (push) Successful in 36s
Release / release (push) Successful in 44s
Release / pkg-deb (push) Successful in 38s
Release / pkg-rpm (push) Successful in 48s
Release / pkg-aur (push) Successful in 56s
2026-07-05 15:12:36 +02:00
warkanum 029b9cee2e feat(cmd): add installall/uninstallall; fix startall/stopall and service lifecycle
- startall now uses systemctl start (not enable --now)
- stopall now uses systemctl stop (not disable --now)
- add installall command (bulk install with --dry-run support)
- add uninstallall command (bulk disable+remove in reverse order)
- fix docker ExecStart to use -a flag so the process stays attached
- prefix ExecStop with - so a stop-on-already-dead container does not fail the unit
2026-07-05 15:12:18 +02:00
warkanum e6ef6e11d6 chore(release): update package version to 0.0.9
Release / test (push) Successful in -33m2s
Release / release (push) Successful in -32m47s
Release / pkg-aur (push) Successful in -33m46s
Release / pkg-deb (push) Successful in -33m19s
Release / pkg-rpm (push) Successful in -32m33s
2026-04-12 11:08:47 +02:00
warkanum f9dcb0b561 Merge branch 'main' of git.warky.dev:wdevs/unitdore 2026-04-12 11:08:38 +02:00
warkanum 69069a2196 fix(podman): handle invalid JSON output gracefully 2026-04-12 11:08:20 +02:00
Hein 2efccc5d4f chore(release): update package version to 0.0.8
Release / test (push) Successful in -30m10s
Release / release (push) Successful in -30m3s
Release / pkg-aur (push) Successful in -29m57s
Release / pkg-rpm (push) Failing after 14m44s
Release / pkg-deb (push) Failing after 14m51s
2026-04-10 09:38:59 +02:00
Hein b58373ad2f feat(config): add restart options for unit configuration
* include Restart, RestartSec, and RestartRetries fields
* update service file generation to support restart settings
* add tests for restart behavior in unit generation
2026-04-10 09:38:45 +02:00
warkanum a273232303 chore(release): update package version to 0.0.7
Release / release (push) Successful in -30m29s
Release / pkg-aur (push) Successful in -30m19s
Release / test (push) Successful in -30m43s
Release / pkg-deb (push) Successful in -30m29s
Release / pkg-rpm (push) Successful in -29m17s
2026-04-08 19:59:11 +02:00
warkanum fae49a258c ci(release): improve RPM upload logic in release workflow 2026-04-08 19:59:00 +02:00
60 changed files with 5632 additions and 537 deletions
+10 -3
View File
@@ -53,6 +53,13 @@ jobs:
echo "Built $NAME"
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
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
@@ -166,7 +173,7 @@ jobs:
echo "Publishing AUR package version ${PKGVER}-${PKGREL}"
# 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.
sed -e "s/^pkgver=.*/pkgver=${PKGVER}/" \
@@ -319,13 +326,13 @@ jobs:
RELEASE=$(curl -s "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \
-H "Authorization: token ${GITHUB_TOKEN}")
UPLOAD_URL=$(echo "$RELEASE" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4)
for f in pkg/centos/out/*.rpm; do
while IFS= read -r f; do
FNAME=$(basename "$f")
echo "Uploading $FNAME..."
curl -s -X POST "${UPLOAD_URL}?name=${FNAME}" \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${f}" > /dev/null
done
done < <(find pkg/centos/out -name "*.rpm")
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -1,3 +1,3 @@
unitdore
/unitdore
*.exe
.codex
+127
View File
@@ -0,0 +1,127 @@
## Project Overview
**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
Keep answers short. Question everything, never assume or guess. Ask if unsure.
You must use the AMCS MCP system tools. Set **unitdore** as the active project and always capture summaries of what was done as thoughts using the `capture_thought` tool. If AMCS is not available, write files to `docs/llm/log/yyyymmdd_hh.md`.
# Tools to use
When writing Go: `doc/tools/go-skill.md` (if present), otherwise follow standard Go conventions.
## Commands
```bash
make build # Compile binary: ./unitdore
make install # Install binary to /usr/bin, man page, create /etc/unitdore
make uninstall # Remove binary and man page
make test # go test ./... -v
make test-short # go test ./...
make lint # go vet ./...
make clean # Remove built binary
make release-version # Bump patch version, commit, tag, and push
```
## Architecture
```
cmd/ # Cobra commands (edit, install, enable, list, root, start, status,
# stop, syncup, uninstall, update; each of install/enable/start/
# stop/uninstall takes <name> or --all, *all = shortcuts in
# bulk.go); order.go = ordering; bulk_test.go = fake Init
config/ # Config struct + YAML load/save (units.yaml)
runtime/ # Docker and Podman runtime abstraction, compose labels
sysuser/ # run commands as root/user (runuser), home/linger lookup
testdata/stack # podman-compose test stack + captured `podman ps` fixture
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)
```
### Key packages
- **`config`** — `Unit` and `Config` structs; `Load`, `Save`, `FindUnit`, `AddUnit`, `ResolveOrder` (fills `Unit.After`/`StartDelay`, run by `Load`)
- **`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`
- **`openrc`**, **`runit`** — same role; root only; tests stub command execution via package vars
### Config file
**Location:** `/etc/unitdore/units.yaml`
```yaml
init: "" # systemd | openrc | runit (empty = autodetect)
service_dir: "" # runit: supervised dir (empty = autodetect)
prefix: "" # prepended to service file name (e.g. "prod-")
suffix: "" # appended to service file name (e.g. "-svc")
users: [] # root only: rootless users scanned by syncup
units:
- name: nginx
runtime: podman # podman | docker | script | exec
user: "" # empty = system unit; set for rootless user unit
command: "" # override ExecStart entirely
order: 1 # startup order, lower = earlier; same = parallel; written into service files (After=/sleep)
delay: 0s # wait before start, own value, ignored in first group
enabled: true
disabled_reason: "" # auto-set by syncup reconciliation
restart: false # Restart=on-failure
restart_sec: 0 # RestartSec
restart_retries: 0 # StartLimitBurst
kind: container # container | stack (podman-compose project)
project: "" # stack: compose project
working_dir: "" # stack: compose dir (from labels)
compose_files: [] # stack: relative to working_dir
compose_bin: "" # stack: podman-compose path
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
| Scenario | Path |
|---|---|
| System unit (root) | `/etc/systemd/system/unitdore-<name>.service` |
| User unit | `/home/<user>/.config/systemd/user/unitdore-<name>.service` |
| With prefix `prod-` | `/etc/systemd/system/unitdore-prod-<name>.service` |
| With suffix `-svc` | `/etc/systemd/system/unitdore-<name>-svc.service` |
### Libraries
- `github.com/spf13/cobra` — CLI framework
- `gopkg.in/yaml.v3` — config parsing
## Testing
Tests exist in:
- `config/config_test.go`
- `runtime/runtime_test.go`
- `cmd/syncup_test.go`
- `cmd/bulk_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`
Always run `make test` before marking a task complete. Add tests for new behaviour in the same package as the code under test.
## Additional documentation
- `docs/generated-units.md` — full annotated examples of every generated unit file combination
- `docs/unitdore.1` — man page
+35
View File
@@ -0,0 +1,35 @@
# AI Usage Declaration
This Go project utilizes AI tools for the following purposes:
- Generating and improving documentation
- Writing and enhancing tests
- Refactoring and optimizing existing code
AI is **not** used for core design or architecture decisions.
All design decisions are deferred to human discussion.
AI is employed only for enhancements to human-written code.
We are aware of significant AI hallucinations; all AI-generated content is to be reviewed and verified by humans.
.-""""""-.
.' '.
/ O O \
: ` :
| |
: .------. :
\ ' ' /
'. .'
'-......-'
MEGAMIND AI
[============]
___________
/___________\
/_____________\
| ASSIMILATE |
| RESISTANCE |
| IS FUTILE |
\_____________/
\___________/
+5
View File
@@ -0,0 +1,5 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
You must now read the./AGENTS.md
+25 -13
View File
@@ -4,7 +4,7 @@ CONFIG_DIR := /etc/unitdore
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
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
@@ -42,24 +42,36 @@ lint:
clean:
rm -f $(BINARY)
## release-version: bump patch version, update source versions, commit, tag, and push
# VERSION only counts as a release version when given on the command line
# (it defaults to git describe for the build flags).
RELEASE_VERSION := $(if $(filter command line,$(origin VERSION)),$(patsubst v%,%,$(VERSION)))
## release-version: bump version (patch, or VERSION=X.Y.Z), update source and package versions, commit, tag, and push
release-version:
@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/'); \
MINOR=$$(echo $$CURRENT | sed 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\2/'); \
PATCH=$$(echo $$CURRENT | sed 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\3/'); \
NEXT="v$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
PKGVER="$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
echo "Current: $$CURRENT → Next: $$NEXT"; \
sed -i "s/^var version = .*/var version = \"$$PKGVER\"/" cmd/root.go; \
sed -i "s/^pkgver=.*/pkgver=$$PKGVER/" pkg/arch/PKGBUILD; \
sed -i "s/^Version:.*/Version: $$PKGVER/" pkg/centos/unitdore.spec; \
git add cmd/root.go pkg/arch/PKGBUILD pkg/centos/unitdore.spec; \
@if [ -n "$(RELEASE_VERSION)" ]; then \
echo "$(RELEASE_VERSION)" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$$' || { echo "usage: make release-version [VERSION=X.Y.Z]"; exit 1; }; \
PKGVER="$(RELEASE_VERSION)"; \
else \
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/'); \
MINOR=$$(echo $$CURRENT | sed 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\2/'); \
PATCH=$$(echo $$CURRENT | sed 's/v\([0-9]*\)\.\([0-9]*\)\.\([0-9]*\).*/\3/'); \
PKGVER="$$MAJOR.$$MINOR.$$((PATCH + 1))"; \
fi; \
NEXT="v$$PKGVER"; \
if git rev-parse -q --verify "refs/tags/$$NEXT" >/dev/null; then echo "tag $$NEXT already exists"; exit 1; fi; \
echo "Releasing $$NEXT"; \
scripts/bump-version.sh "$$PKGVER"; \
git add -A cmd/root.go pkg; \
git commit -m "chore(release): update package version to $$PKGVER"; \
git tag -a "$$NEXT" -m "Release $$NEXT"; \
git push origin HEAD "$$NEXT"; \
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:
@grep -E '^## ' Makefile | sed 's/## / /'
+4 -1
View File
@@ -27,7 +27,8 @@ Think of it as: **config → systemd units → running services.**
## Non-Goals
- Not a container runtime (doesn't replace Podman/Docker)
- Not Kubernetes / Compose / Quadlet
- Not Kubernetes / Quadlet
- Compose: only wraps existing podman-compose stacks (one unit per project); docker compose planned
- Not a daemon — runs on demand
---
@@ -172,6 +173,8 @@ unitdore/
| 5 | Reconcile (auto-disable missing units) in syncup |
| 6 | User unit support (rootless containers) |
| 7 | Polish: `edit`, error handling, --dry-run flag |
| 8 | podman-compose stacks (compose + pod fallback), root/user scopes |
| 9 | docker compose stacks (`com.docker.compose.*` labels) |
---
+149 -11
View File
@@ -2,7 +2,7 @@
> *A door you open and close for container units.*
Unitdore bridges your container runtime (Podman, Docker) and systemd. It discovers running containers, stores them in a config file, and generates + manages systemd `.service` units for each one.
Unitdore bridges your container runtime (Podman, Docker) and 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
@@ -30,6 +30,26 @@ sudo rpm -i 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**
```bash
@@ -54,33 +74,62 @@ sudo unitdore syncup
Queries Podman and Docker for running containers, adds new ones to the config, and reconciles any that have disappeared (marking them disabled).
- Podman first; Docker only for names Podman doesn't have
- podman-compose containers → one `kind: stack` unit per project; existing member units disabled (`part of stack <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
```bash
sudo unitdore edit
```
Opens `/etc/unitdore/units.yaml` in `$EDITOR` (falls back to `vi`).
Opens `/etc/unitdore/units.yaml` in `$EDITOR` (falls back to `nvim`, `nano`, `vi`; creates the config dir if missing).
### Install systemd unit files
### Install service files
```bash
sudo unitdore install
sudo unitdore install <name>
```
Generates `.service` files for all enabled units and writes them to systemd. Use `--dry-run` to preview without writing.
Generates the service file for one enabled unit (error if unknown or disabled). Does not enable or start it.
```bash
sudo unitdore install --dry-run
sudo unitdore install <name> --dry-run # preview, write nothing
sudo unitdore install --all # all enabled units; installed + disabled ones are stopped and removed
sudo unitdore installall # same as install --all
```
### Enable and start units
### Start / stop / enable / uninstall
```bash
sudo unitdore active
sudo unitdore start <name> # start only
sudo unitdore enable <name> # enable at boot + start
sudo unitdore stop <name>
sudo unitdore uninstall <name> # stop, disable, remove service file
```
Runs `systemctl enable --now` for all installed, enabled units in startup order.
Each takes `--all` instead of a name to act on every unit (shortcuts: `startall`, `enableall`, `stopall`, `uninstallall`, `installall`).
- `start/enable --all`: installed, enabled units in `order` (lowest first); same `order` → parallel
- `stop/uninstall --all`: all installed units, reverse `order`, one at a time
### Order and delay at boot
`order`/`delay` are written into the generated service files, so they also apply at boot.
| Backend | `order` | `delay` |
|---|---|---|
| systemd | `After=` units of the previous group | `ExecStartPre=sleep N`, `TimeoutStartSec=N+90` |
| OpenRC | `depend() { after … }` | `start_pre() { sleep N }` |
| runit | `sv -w 30 check <prev>` in `run` | `sleep N` in `run` |
- Previous group = nearest lower `order` among enabled units of the same user
- `delay` is the unit's own, ignored in the first group
- Changing `order`/`enabled` via `update` regenerates all installed, enabled units
- Delay also applies to manual `start` and to restarts
- `delay` must be a valid non-negative duration (`5s`, `1m`); an invalid value fails config load
### Check status
@@ -102,8 +151,11 @@ oldthing podman hein no no — container
**Location:** `/etc/unitdore/units.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-"
suffix: "" # optional: appended to all service names, e.g. "-svc"
users: [hein] # root only: rootless users scanned by syncup
units:
- name: nginx
@@ -111,7 +163,7 @@ units:
user: "" # empty = root/system unit
command: "" # optional: override ExecStart entirely
order: 1 # startup order (lower = earlier)
delay: 0s # delay after previous order group
delay: 0s # wait before start (own delay, not first group)
enabled: true
disabled_reason: "" # auto-set by syncup reconciliation
@@ -121,8 +173,92 @@ units:
order: 2
delay: 5s
enabled: true
- name: shop # = compose project name
kind: stack # container (default) | stack
runtime: podman
user: hein
project: shop
working_dir: /srv/shop # from compose labels (refreshed by syncup)
compose_files: [compose.yaml]
compose_bin: /usr/bin/podman-compose
order: 3
enabled: true
- 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
Set `prefix` and/or `suffix` to namespace your service files:
@@ -147,6 +283,7 @@ See **[docs/generated-units.md](docs/generated-units.md)** for full examples cov
- User units (rootless)
- Docker vs Podman
- Custom commands
- Compose stacks (compose + pod fallback)
- Prefix/suffix naming
- Startup ordering
- Unnamed containers (short ID fallback)
@@ -175,7 +312,7 @@ WantedBy=multi-user.target
## Flags
```
--config string path to units config file (default "/etc/unitdore/units.yaml")
--config string path to units config file (default "/etc/unitdore/units.yaml"; non-root: ~/.config/unitdore/units.yaml)
```
## Requirements
@@ -183,4 +320,5 @@ WantedBy=multi-user.target
- Go 1.21+
- systemd
- Podman and/or Docker (only what you have installed is used)
- podman-compose for compose-mode stacks (pod fallback without it)
- Root for system units; the relevant user for rootless units
+130
View File
@@ -0,0 +1,130 @@
package cmd
import (
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/initsys"
)
// The *all commands are kept as shortcuts for '<command> --all'.
var (
startallCmd = &cobra.Command{
Use: "startall",
Short: "Start all installed, enabled units (same as start --all)",
RunE: bulkRun(func(cfg *config.Config, ini initsys.Init) {
bulkStart(cfg, ini, os.Stdout, initsys.Init.Start, "started")
}),
}
enableallCmd = &cobra.Command{
Use: "enableall",
Short: "Enable at boot and start all installed, enabled units (same as enable --all)",
RunE: bulkRun(func(cfg *config.Config, ini initsys.Init) {
bulkStart(cfg, ini, os.Stdout, initsys.Init.Enable, "enabled")
}),
}
stopallCmd = &cobra.Command{
Use: "stopall",
Short: "Stop all installed units (same as stop --all)",
RunE: bulkRun(func(cfg *config.Config, ini initsys.Init) { stopAll(cfg, ini, os.Stdout) }),
}
uninstallallCmd = &cobra.Command{
Use: "uninstallall",
Short: "Stop, disable and remove all installed units (same as uninstall --all)",
RunE: bulkRun(func(cfg *config.Config, ini initsys.Init) { uninstallAll(cfg, ini, os.Stdout) }),
}
)
func init() {
rootCmd.AddCommand(startallCmd, enableallCmd, stopallCmd, uninstallallCmd)
}
func bulkRun(fn func(*config.Config, initsys.Init)) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
cfg, ini, err := loadInit()
if err != nil {
return err
}
fn(cfg, ini)
return nil
}
}
type unitAction func(ini initsys.Init, u config.Unit, prefix, suffix string) error
// bulkStart applies act to all enabled, installed units in startup order.
func bulkStart(cfg *config.Config, ini initsys.Init, w io.Writer, act unitAction, past string) {
prefix, suffix := cfg.Prefix, cfg.Suffix
var todo []config.Unit
for _, u := range sortUnits(cfg.Units, false) {
if !u.Enabled {
continue
}
if !ini.IsInstalled(u, prefix, suffix) {
fmt.Fprintf(w, " ! %s: not installed — run 'unitdore install %s' first\n", u.Name, u.Name)
continue
}
todo = append(todo, u)
}
ok, failed := runOrdered(todo, w, func(u config.Unit) (string, error) {
if err := act(ini, u, prefix, suffix); err != nil {
return "", err
}
return past + ": " + u.Name, nil
})
fmt.Fprintf(w, "\nDone. %s: %d Failed: %d\n", strings.ToUpper(past[:1])+past[1:], ok, failed)
}
// stopAll stops every installed unit in reverse startup order.
func stopAll(cfg *config.Config, ini initsys.Init, w io.Writer) {
prefix, suffix := cfg.Prefix, cfg.Suffix
stopped, failed := 0, 0
for _, u := range sortUnits(cfg.Units, true) {
if !ini.IsInstalled(u, prefix, suffix) {
continue
}
fmt.Fprintf(w, " ■ stopping: %s...\n", ini.ServiceName(u, prefix, suffix))
if err := ini.Stop(u, prefix, suffix); err != nil {
fmt.Fprintf(w, " ✗ failed: %s: %v\n", u.Name, err)
failed++
continue
}
fmt.Fprintf(w, " ✓ stopped: %s\n", u.Name)
stopped++
}
fmt.Fprintf(w, "\nDone. Stopped: %d Failed: %d\n", stopped, failed)
}
// uninstallAll disables and removes every installed unit in reverse startup order.
func uninstallAll(cfg *config.Config, ini initsys.Init, w io.Writer) {
prefix, suffix := cfg.Prefix, cfg.Suffix
removed, failed := 0, 0
for _, u := range sortUnits(cfg.Units, true) {
if !ini.IsInstalled(u, prefix, suffix) {
continue
}
fmt.Fprintf(w, " ■ disabling %s...\n", ini.ServiceName(u, prefix, suffix))
if err := ini.Disable(u, prefix, suffix); err != nil {
fmt.Fprintf(w, " ✗ failed to disable %s: %v\n", u.Name, err)
failed++
continue
}
path, _ := ini.UnitPath(u, prefix, suffix)
fmt.Fprintf(w, " ✗ removing %s...\n", path)
if err := ini.Uninstall(u, prefix, suffix); err != nil {
fmt.Fprintf(w, " ✗ failed to remove %s: %v\n", u.Name, err)
failed++
continue
}
fmt.Fprintf(w, " ✓ uninstalled: %s\n", u.Name)
removed++
}
fmt.Fprintf(w, "\nDone. Uninstalled: %d Failed: %d\n", removed, failed)
}
+460
View File
@@ -0,0 +1,460 @@
package cmd
import (
"bytes"
"errors"
"io"
"path/filepath"
"reflect"
"sort"
"strings"
"sync"
"testing"
"time"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/initsys"
)
// fakeInit records calls and simulates installed files in memory.
type fakeInit struct {
mu sync.Mutex
installed map[string]bool
calls []string
failOn map[string]bool // "op:name" -> error
}
func newFake(installed ...string) *fakeInit {
f := &fakeInit{installed: map[string]bool{}, failOn: map[string]bool{}}
for _, n := range installed {
f.installed[n] = true
}
return f
}
func (f *fakeInit) rec(op string, u config.Unit) error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, op+":"+u.Name)
if f.failOn[op+":"+u.Name] {
return errors.New("boom")
}
return nil
}
func (f *fakeInit) Name() string { return "fake" }
func (f *fakeInit) ServiceName(u config.Unit, p, s string) string { return p + u.Name + s }
func (f *fakeInit) UnitPath(u config.Unit, p, s string) (string, error) {
return "/fake/" + f.ServiceName(u, p, s), nil
}
func (f *fakeInit) Generate(u config.Unit, p, s string) (string, error) {
return "content-" + u.Name, nil
}
func (f *fakeInit) Install(u config.Unit, p, s string) error {
if err := f.rec("install", u); err != nil {
return err
}
f.mu.Lock()
f.installed[u.Name] = true
f.mu.Unlock()
return nil
}
func (f *fakeInit) Uninstall(u config.Unit, p, s string) error {
if err := f.rec("uninstall", u); err != nil {
return err
}
f.mu.Lock()
delete(f.installed, u.Name)
f.mu.Unlock()
return nil
}
func (f *fakeInit) Enable(u config.Unit, p, s string) error { return f.rec("enable", u) }
func (f *fakeInit) Disable(u config.Unit, p, s string) error { return f.rec("disable", u) }
func (f *fakeInit) Start(u config.Unit, p, s string) error { return f.rec("start", u) }
func (f *fakeInit) Stop(u config.Unit, p, s string) error { return f.rec("stop", u) }
func (f *fakeInit) Status(u config.Unit, p, s string) string { return "active" }
func (f *fakeInit) IsInstalled(u config.Unit, p, s string) bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.installed[u.Name]
}
func (f *fakeInit) SupportsLinger() bool { return false }
func unit(name string, order int, delay string, enabled bool) config.Unit {
return config.Unit{Name: name, Runtime: "podman", Order: order, Delay: delay, Enabled: enabled}
}
func TestSortUnits(t *testing.T) {
in := []config.Unit{unit("b", 2, "", true), unit("a", 2, "", true), unit("c", 1, "", true)}
names := func(us []config.Unit) (n []string) {
for _, u := range us {
n = append(n, u.Name)
}
return
}
if got := names(sortUnits(in, false)); !reflect.DeepEqual(got, []string{"c", "a", "b"}) {
t.Errorf("forward = %v", got)
}
if got := names(sortUnits(in, true)); !reflect.DeepEqual(got, []string{"b", "a", "c"}) {
t.Errorf("reverse = %v", got)
}
if in[0].Name != "b" {
t.Error("input was mutated")
}
}
// grouped reports calls with each parallel group sorted, so order within a group is irrelevant.
func grouped(calls []string, sizes ...int) [][]string {
var out [][]string
i := 0
for _, n := range sizes {
g := append([]string(nil), calls[i:i+n]...)
sort.Strings(g)
out = append(out, g)
i += n
}
return out
}
func TestStartAllOrder(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{
unit("db", 1, "", true),
unit("web", 2, "3s", true),
unit("web2", 2, "5s", true),
unit("off", 2, "", false),
unit("app", 3, "1s", true),
unit("missing", 3, "", true),
}}
f := newFake("db", "web", "web2", "off", "app")
var out bytes.Buffer
bulkStart(cfg, f, &out, initsys.Init.Start, "started")
want := [][]string{{"start:db"}, {"start:web", "start:web2"}, {"start:app"}}
if got := grouped(f.calls, 1, 2, 1); !reflect.DeepEqual(got, want) {
t.Errorf("calls = %v", f.calls)
}
o := out.String()
for _, s := range []string{"! missing: not installed", "Done. Started: 4 Failed: 0"} {
if !strings.Contains(o, s) {
t.Errorf("output missing %q:\n%s", s, o)
}
}
if strings.Contains(o, "off") {
t.Errorf("disabled unit should be skipped:\n%s", o)
}
}
func TestStartAllParallelWithinGroup(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true), unit("b", 1, "", true)}}
f := newFake("a", "b")
started := make(chan string, 2)
release := make(chan struct{})
act := func(_ initsys.Init, u config.Unit, _, _ string) error {
started <- u.Name
<-release // blocks until both have started: fails (deadlocks) if sequential
return nil
}
done := make(chan struct{})
go func() { bulkStart(cfg, f, &bytes.Buffer{}, act, "started"); close(done) }()
for i := 0; i < 2; i++ {
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("units in the same order did not start in parallel")
}
}
close(release)
<-done
}
func TestEnableAllUsesEnable(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true)}}
f := newFake("a")
var out bytes.Buffer
bulkStart(cfg, f, &out, initsys.Init.Enable, "enabled")
if !reflect.DeepEqual(f.calls, []string{"enable:a"}) || !strings.Contains(out.String(), "Done. Enabled: 1") {
t.Errorf("calls=%v out=%s", f.calls, out.String())
}
}
func TestStartAllFailureCounted(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true), unit("b", 2, "", true)}}
f := newFake("a", "b")
f.failOn["start:a"] = true
var out bytes.Buffer
bulkStart(cfg, f, &out, initsys.Init.Start, "started")
if !strings.Contains(out.String(), "Done. Started: 1 Failed: 1") {
t.Errorf("out=%s", out.String())
}
}
func TestStopAllReverseOrder(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true), unit("b", 2, "", false), unit("c", 3, "", true), unit("d", 3, "", true)}}
f := newFake("a", "b", "c", "d")
var out bytes.Buffer
stopAll(cfg, f, &out)
if want := []string{"stop:d", "stop:c", "stop:b", "stop:a"}; !reflect.DeepEqual(f.calls, want) {
t.Errorf("calls = %v", f.calls)
}
if !strings.Contains(out.String(), "Stopped: 4") {
t.Errorf("out=%s", out.String())
}
}
func TestUninstallAllReverseOrderAndFailure(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true), unit("b", 2, "", true), unit("c", 3, "", true)}}
f := newFake("a", "b") // c not installed
f.failOn["disable:b"] = true
var out bytes.Buffer
uninstallAll(cfg, f, &out)
if want := []string{"disable:b", "disable:a", "uninstall:a"}; !reflect.DeepEqual(f.calls, want) {
t.Errorf("calls = %v", f.calls)
}
if !f.installed["b"] {
t.Error("b must stay installed when disable fails")
}
if !strings.Contains(out.String(), "Uninstalled: 1 Failed: 1") {
t.Errorf("out=%s", out.String())
}
}
func TestInstallAll(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{
unit("on", 1, "", true),
unit("off-installed", 2, "", false),
unit("off-absent", 3, "", false),
}}
f := newFake("off-installed")
var out bytes.Buffer
installAll(cfg, f, false, &out)
// a disabled unit must be stopped before its file is removed
if want := []string{"install:on", "disable:off-installed", "uninstall:off-installed"}; !reflect.DeepEqual(f.calls, want) {
t.Errorf("calls = %v", f.calls)
}
if !strings.Contains(out.String(), "Installed: 1 Removed: 1 Skipped: 1") {
t.Errorf("out=%s", out.String())
}
}
func TestInstallAllDisableFailureKeepsFile(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{unit("x", 1, "", false)}}
f := newFake("x")
f.failOn["disable:x"] = true
installAll(cfg, f, false, &bytes.Buffer{})
if !f.installed["x"] || len(f.calls) != 1 {
t.Errorf("file must be kept when disable fails; calls=%v", f.calls)
}
}
func TestInstallAllDryRunWritesNothing(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{unit("on", 1, "", true), unit("off", 2, "", false)}}
f := newFake("off")
var out bytes.Buffer
installAll(cfg, f, true, &out)
if len(f.calls) != 0 {
t.Errorf("dry run touched system: %v", f.calls)
}
for _, s := range []string{"--- /fake/on ---", "content-on", "would remove: off", "Would install: 1"} {
if !strings.Contains(out.String(), s) {
t.Errorf("output missing %q:\n%s", s, out.String())
}
}
}
func updateCfg(t *testing.T) (*config.Config, string) {
t.Helper()
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true)}}
return cfg, filepath.Join(t.TempDir(), "units.yaml")
}
func TestUpdateOrderReinstallsInstalled(t *testing.T) {
cfg, path := updateCfg(t)
f := newFake("a")
order := 7
if err := applyUpdate(cfg, f, "a", &order, false, false, path, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(f.calls, []string{"install:a"}) {
t.Errorf("calls = %v", f.calls)
}
saved, err := config.Load(path)
if err != nil || saved.Units[0].Order != 7 {
t.Errorf("saved = %+v, %v", saved, err)
}
}
func TestUpdateDisableStopsAndRemoves(t *testing.T) {
cfg, path := updateCfg(t)
f := newFake("a")
if err := applyUpdate(cfg, f, "a", nil, false, true, path, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
if want := []string{"disable:a", "uninstall:a"}; !reflect.DeepEqual(f.calls, want) {
t.Errorf("calls = %v", f.calls)
}
saved, _ := config.Load(path)
if saved.Units[0].Enabled {
t.Error("unit should be saved as disabled")
}
}
func TestUpdateNotInstalledOnlySaves(t *testing.T) {
cfg, path := updateCfg(t)
f := newFake()
if err := applyUpdate(cfg, f, "a", nil, false, true, path, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
if len(f.calls) != 0 {
t.Errorf("calls = %v", f.calls)
}
}
func TestUpdateEnableClearsReason(t *testing.T) {
cfg, path := updateCfg(t)
cfg.Units[0].Enabled = false
cfg.Units[0].DisabledReason = "container not found"
if err := applyUpdate(cfg, newFake(), "a", nil, true, false, path, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
saved, _ := config.Load(path)
if !saved.Units[0].Enabled || saved.Units[0].DisabledReason != "" {
t.Errorf("saved = %+v", saved.Units[0])
}
}
func TestUpdateUnknownUnit(t *testing.T) {
cfg, path := updateCfg(t)
if err := applyUpdate(cfg, newFake(), "nope", nil, false, false, path, &bytes.Buffer{}); err == nil {
t.Error("expected not found error")
}
}
func TestUpdateOrderRefreshesNeighbours(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true), unit("b", 2, "", true), unit("off", 3, "", false)}}
path := filepath.Join(t.TempDir(), "units.yaml")
f := newFake("a", "b", "off")
order := 5
if err := applyUpdate(cfg, f, "a", &order, false, false, path, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
// b's After= changes when a moves behind it; disabled units are left alone
if want := []string{"install:a", "install:b"}; !reflect.DeepEqual(f.calls, want) {
t.Errorf("calls = %v", f.calls)
}
if got := cfg.Units[0].After; len(got) != 1 || got[0] != "b" {
t.Errorf("a.After = %v, want [b]", got)
}
}
func TestInstallOneOnlyNamedUnit(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true), unit("b", 2, "", true), unit("off", 3, "", false)}}
f := newFake("off")
var out bytes.Buffer
if err := installOne(cfg, f, "b", false, &out); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(f.calls, []string{"install:b"}) {
t.Errorf("calls = %v", f.calls)
}
if !strings.Contains(out.String(), "installed: /fake/b") {
t.Errorf("out=%s", out.String())
}
}
func TestInstallOneDryRun(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true)}}
f := newFake()
var out bytes.Buffer
if err := installOne(cfg, f, "a", true, &out); err != nil {
t.Fatal(err)
}
if len(f.calls) != 0 || !strings.Contains(out.String(), "content-a") {
t.Errorf("calls=%v out=%s", f.calls, out.String())
}
}
func TestInstallOneErrors(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{unit("off", 1, "", false), unit("bad", 2, "", true)}}
f := newFake("off")
if err := installOne(cfg, f, "nope", false, &bytes.Buffer{}); err == nil {
t.Error("unknown unit must error")
}
if err := installOne(cfg, f, "off", false, &bytes.Buffer{}); err == nil {
t.Error("disabled unit must error")
}
f.failOn["install:bad"] = true
if err := installOne(cfg, f, "bad", false, &bytes.Buffer{}); err == nil {
t.Error("install failure must be returned")
}
if len(f.calls) != 1 || !f.installed["off"] {
t.Errorf("disabled unit must be left untouched; calls=%v", f.calls)
}
}
func TestNameOrAll(t *testing.T) {
cases := []struct {
all bool
args []string
ok bool
}{
{false, nil, false},
{false, []string{"a", "b"}, false},
{false, []string{"a"}, true},
{true, nil, true},
{true, []string{"a"}, false},
}
for _, c := range cases {
all := c.all
if err := nameOrAll(&all)(nil, c.args); (err == nil) != c.ok {
t.Errorf("all=%v args=%v: err=%v", c.all, c.args, err)
}
}
}
func TestSingleUnitCommands(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{unit("a", 1, "", true), unit("b", 2, "", true), unit("gone", 3, "", true)}}
f := newFake("a", "b")
var out bytes.Buffer
for _, step := range []struct {
op string
run func(*config.Config, initsys.Init, string, io.Writer) error
want string
}{
{"start", startOne, "start:a"},
{"enable", enableOne, "enable:a"},
{"stop", stopOne, "stop:a"},
{"uninstall", uninstallOne, "uninstall:a"},
} {
f.calls = nil
f.installed["a"] = true
if err := step.run(cfg, f, "a", &out); err != nil {
t.Fatalf("%s: %v", step.op, err)
}
// only the named unit is touched
for _, c := range f.calls {
if strings.HasSuffix(c, ":b") {
t.Errorf("%s touched b: %v", step.op, f.calls)
}
}
if got := f.calls[len(f.calls)-1]; got != step.want {
t.Errorf("%s last call = %s, want %s", step.op, got, step.want)
}
if step.op == "uninstall" && !reflect.DeepEqual(f.calls, []string{"disable:a", "uninstall:a"}) {
t.Errorf("uninstall calls = %v", f.calls)
}
f.calls = nil
if err := step.run(cfg, f, "gone", &out); err == nil {
t.Errorf("%s: not-installed unit must error", step.op)
}
if err := step.run(cfg, f, "nope", &out); err == nil {
t.Errorf("%s: unknown unit must error", step.op)
}
if len(f.calls) != 0 {
t.Errorf("%s: errors must not act: %v", step.op, f.calls)
}
}
}
+4
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/spf13/cobra"
)
@@ -31,6 +32,9 @@ func runEdit(cmd *cobra.Command, args []string) error {
if editor == "" {
return fmt.Errorf("no editor found; set $EDITOR")
}
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
return fmt.Errorf("creating config dir: %w", err)
}
c := exec.Command(editor, configPath)
c.Stdin = os.Stdin
c.Stdout = os.Stdout
+52
View File
@@ -0,0 +1,52 @@
package cmd
import (
"fmt"
"io"
"os"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/initsys"
)
var enableAll bool
var enableCmd = &cobra.Command{
Use: "enable <name> | --all",
Short: "Enable at boot and start a unit (or all with --all)",
Long: `Enable enables the named installed unit at boot and starts it.
With --all (same as 'enableall') it does so for every enabled, installed unit
in startup order, like 'start --all'.`,
Args: nameOrAll(&enableAll),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, ini, err := loadInit()
if err != nil {
return err
}
if enableAll {
bulkStart(cfg, ini, os.Stdout, initsys.Init.Enable, "enabled")
return nil
}
return enableOne(cfg, ini, args[0], os.Stdout)
},
}
func init() {
enableCmd.Flags().BoolVar(&enableAll, "all", false, "enable all enabled, installed units")
rootCmd.AddCommand(enableCmd)
}
func enableOne(cfg *config.Config, ini initsys.Init, name string, w io.Writer) error {
u, err := installedUnit(cfg, ini, name)
if err != nil {
return err
}
fmt.Fprintf(w, " ▶ enabling %s...\n", ini.ServiceName(*u, cfg.Prefix, cfg.Suffix))
if err := ini.Enable(*u, cfg.Prefix, cfg.Suffix); err != nil {
return err
}
fmt.Fprintf(w, " ✓ enabled: %s\n", name)
return nil
}
+70
View File
@@ -0,0 +1,70 @@
package cmd
import (
"errors"
"fmt"
"github.com/spf13/cobra"
"io"
"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(w io.Writer, 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.Fprintf(w, " ! linger not enabled for %s — user units won't start at boot: sudo loginctl enable-linger %s\n", u.User, u.User)
}
// nameOrAll requires exactly one unit name, or none when --all is set.
func nameOrAll(all *bool) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if *all {
if len(args) != 0 {
return errors.New("--all takes no unit name")
}
return nil
}
if len(args) != 1 {
return errors.New("requires a unit name, or --all")
}
return nil
}
}
// installedUnit finds a unit by name and checks that its service file exists.
func installedUnit(cfg *config.Config, ini initsys.Init, name string) (*config.Unit, error) {
u := cfg.FindUnit(name)
if u == nil {
return nil, fmt.Errorf("unit %q not found", name)
}
if !ini.IsInstalled(*u, cfg.Prefix, cfg.Suffix) {
return nil, fmt.Errorf("%s is not installed — run 'unitdore install %s' first", name, name)
}
return u, nil
}
// loadInit loads the config and selects the init backend.
func loadInit() (*config.Config, initsys.Init, error) {
cfg, err := config.Load(configPath)
if err != nil {
return nil, nil, err
}
ini, err := initsys.New(cfg)
if err != nil {
return nil, nil, err
}
return cfg, ini, nil
}
+145 -57
View File
@@ -2,27 +2,48 @@ package cmd
import (
"fmt"
"io"
"os"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/initsys"
)
var dryRun bool
var dryRun, installAllFlag bool
var installCmd = &cobra.Command{
Use: "install",
Short: "Generate and install systemd unit files for all enabled units",
Long: `Install generates .service files for all enabled units and writes them
to the appropriate systemd directory. It does not enable or start them.
Use: "install <name> | --all",
Short: "Generate and install the service file for one unit (or all with --all)",
Long: `Install generates the service file for the named unit using the configured
init system. It does not enable or start it. The unit must be enabled in the
config.
Use --dry-run to preview the generated unit files without writing anything.`,
With --all it installs every enabled unit (same as 'installall'); disabled
units that are installed are stopped, disabled and removed.
Use --dry-run to preview the generated files without writing anything.`,
Args: nameOrAll(&installAllFlag),
RunE: runInstall,
}
var installallCmd = &cobra.Command{
Use: "installall",
Short: "Generate and install service files for all enabled units",
Long: `Installall generates service files for all enabled units using the configured
init system. It does not enable or start them. Units that are disabled in the
config are stopped, disabled and their service files removed.
Use --dry-run to preview the generated files without writing anything.`,
RunE: runInstallall,
}
func init() {
installCmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview unit files without writing")
rootCmd.AddCommand(installCmd)
installCmd.Flags().BoolVar(&installAllFlag, "all", false, "install all enabled units")
for _, c := range []*cobra.Command{installCmd, installallCmd} {
c.Flags().BoolVar(&dryRun, "dry-run", false, "preview service files without writing")
rootCmd.AddCommand(c)
}
}
func runInstall(cmd *cobra.Command, args []string) error {
@@ -30,55 +51,122 @@ func runInstall(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
prefix, suffix := cfg.Prefix, cfg.Suffix
installed := 0
skipped := 0
removed := 0
for _, u := range cfg.Units {
if !u.Enabled {
// Remove unit file if it exists for disabled units
if systemd.IsInstalled(u, prefix, suffix) {
if dryRun {
fmt.Printf(" ~ would remove: %s\n", systemd.ServiceName(u, prefix, suffix))
} else {
if err := systemd.Uninstall(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err)
} else {
fmt.Printf(" - removed: %s (disabled)\n", systemd.ServiceName(u, prefix, suffix))
removed++
}
}
} else {
skipped++
}
continue
}
if dryRun {
content, err := systemd.Generate(u, prefix, suffix)
if err != nil {
fmt.Printf(" ✗ %s: %v\n", u.Name, err)
continue
}
path, _ := systemd.UnitPath(u, prefix, suffix)
fmt.Printf("\n--- %s ---\n%s\n", path, content)
installed++
continue
}
if err := systemd.Install(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
} else {
path, _ := systemd.UnitPath(u, prefix, suffix)
fmt.Printf(" ✓ installed: %s\n", path)
installed++
}
ini, err := initsys.New(cfg)
if err != nil {
return err
}
if !dryRun {
fmt.Printf("\nDone. Installed: %d Removed: %d Skipped: %d\n", installed, removed, skipped)
if installAllFlag {
installAll(cfg, ini, dryRun, os.Stdout)
return nil
}
return installOne(cfg, ini, args[0], dryRun, os.Stdout)
}
func runInstallall(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath)
if err != nil {
return err
}
ini, err := initsys.New(cfg)
if err != nil {
return err
}
installAll(cfg, ini, dryRun, os.Stdout)
return nil
}
// installOne installs a single enabled unit. Disabled units are an error:
// removing files is installall's and update's job.
func installOne(cfg *config.Config, ini initsys.Init, name string, dry bool, w io.Writer) error {
u := cfg.FindUnit(name)
if u == nil {
return fmt.Errorf("unit %q not found", name)
}
if !u.Enabled {
return fmt.Errorf("unit %q is disabled — run 'unitdore update %s --enable' first", name, name)
}
prefix, suffix := cfg.Prefix, cfg.Suffix
path, err := ini.UnitPath(*u, prefix, suffix)
if err != nil {
return err
}
if dry {
content, err := ini.Generate(*u, prefix, suffix)
if err != nil {
return err
}
fmt.Fprintf(w, "\n--- %s ---\n%s\n", path, content)
return nil
}
if err := ini.Install(*u, prefix, suffix); err != nil {
return err
}
fmt.Fprintf(w, " ✓ installed: %s\n", path)
lingerHint(w, ini, map[string]bool{}, *u)
return nil
}
func installAll(cfg *config.Config, ini initsys.Init, dry bool, w io.Writer) {
prefix, suffix := cfg.Prefix, cfg.Suffix
installed, skipped, removed := 0, 0, 0
lingerSeen := map[string]bool{}
for _, u := range cfg.Units {
name := ini.ServiceName(u, prefix, suffix)
if !u.Enabled {
if !ini.IsInstalled(u, prefix, suffix) {
skipped++
continue
}
if dry {
fmt.Fprintf(w, " ~ would remove: %s\n", name)
continue
}
// Stop and disable first so the service doesn't outlive its file
if err := ini.Disable(u, prefix, suffix); err != nil {
fmt.Fprintf(w, " ✗ failed to disable %s: %v\n", u.Name, err)
continue
}
if err := ini.Uninstall(u, prefix, suffix); err != nil {
fmt.Fprintf(w, " ✗ failed to remove %s: %v\n", u.Name, err)
continue
}
fmt.Fprintf(w, " - removed: %s (disabled)\n", name)
removed++
continue
}
if dry {
content, err := ini.Generate(u, prefix, suffix)
if err != nil {
fmt.Fprintf(w, " ✗ %s: %v\n", u.Name, err)
continue
}
path, err := ini.UnitPath(u, prefix, suffix)
if err != nil {
fmt.Fprintf(w, " ✗ %s: %v\n", u.Name, err)
continue
}
fmt.Fprintf(w, "\n--- %s ---\n%s\n", path, content)
installed++
continue
}
if err := ini.Install(u, prefix, suffix); err != nil {
fmt.Fprintf(w, " ✗ failed: %s: %v\n", u.Name, err)
continue
}
path, _ := ini.UnitPath(u, prefix, suffix)
fmt.Fprintf(w, " ✓ installed: %s\n", path)
lingerHint(w, ini, lingerSeen, u)
installed++
}
verb := "Installed"
if dry {
verb = "Would install"
}
fmt.Fprintf(w, "\nDone. %s: %d Removed: %d Skipped: %d\n", verb, installed, removed, skipped)
}
+9 -13
View File
@@ -2,12 +2,11 @@ package cmd
import (
"fmt"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/initsys"
)
var listCmd = &cobra.Command{
@@ -31,16 +30,13 @@ func runList(cmd *cobra.Command, args []string) error {
return nil
}
units := make([]config.Unit, len(cfg.Units))
copy(units, cfg.Units)
sort.Slice(units, func(i, j int) bool {
if units[i].Order != units[j].Order {
return units[i].Order < units[j].Order
}
return units[i].Name < units[j].Name
})
units := sortUnits(cfg.Units, false)
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 }
@@ -53,14 +49,14 @@ func runList(cmd *cobra.Command, args []string) error {
installed := "no"
unitPath := "—"
if p, err := systemd.UnitPath(u, prefix, suffix); err == nil {
if p, err := ini.UnitPath(u, prefix, suffix); err == nil {
unitPath = p
if systemd.IsInstalled(u, prefix, suffix) {
if ini.IsInstalled(u, prefix, suffix) {
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"}
+67
View File
@@ -0,0 +1,67 @@
package cmd
import (
"fmt"
"io"
"sort"
"sync"
"github.com/warkanum/unitdore/config"
)
// sortUnits returns a copy of units sorted by order then name; reverse flips both.
func sortUnits(units []config.Unit, reverse bool) []config.Unit {
out := make([]config.Unit, len(units))
copy(out, units)
sort.SliceStable(out, func(i, j int) bool {
if out[i].Order != out[j].Order {
return (out[i].Order < out[j].Order) != reverse
}
return (out[i].Name < out[j].Name) != reverse
})
return out
}
// groupByOrder splits order-sorted units into groups sharing the same order.
func groupByOrder(sorted []config.Unit) [][]config.Unit {
var groups [][]config.Unit
for _, u := range sorted {
n := len(groups)
if n > 0 && groups[n-1][0].Order == u.Order {
groups[n-1] = append(groups[n-1], u)
continue
}
groups = append(groups, []config.Unit{u})
}
return groups
}
// runOrdered runs fn for every unit: groups of equal order one after another,
// units within a group in parallel. Start delays live in the generated service
// files, so a blocking start covers them. Output is printed per group in name
// order. Returns the number of units fn succeeded and failed for.
func runOrdered(units []config.Unit, w io.Writer, fn func(u config.Unit) (string, error)) (ok, failed int) {
for _, g := range groupByOrder(sortUnits(units, false)) {
msgs := make([]string, len(g))
errs := make([]error, len(g))
var wg sync.WaitGroup
for j, u := range g {
wg.Add(1)
go func(j int, u config.Unit) {
defer wg.Done()
msgs[j], errs[j] = fn(u)
}(j, u)
}
wg.Wait()
for j, u := range g {
if errs[j] != nil {
fmt.Fprintf(w, " ✗ failed: %s: %v\n", u.Name, errs[j])
failed++
continue
}
fmt.Fprintf(w, " ✓ %s\n", msgs[j])
ok++
}
}
return ok, failed
}
+22 -5
View File
@@ -5,20 +5,37 @@ import (
"os"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/sysuser"
)
var version = "0.0.6"
var version = "1.0.0"
var configPath string
var rootCmd = &cobra.Command{
Use: "unitdore",
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,
and generates + manages systemd .service units for each one.`,
It discovers running containers and podman-compose stacks, stores them in a
config file, and generates + manages 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,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Normal users get their own config and manage only their user units
if !cmd.Flags().Changed("config") && !sysuser.IsRoot() {
home, err := sysuser.HomeDir(sysuser.Current())
if err != nil {
return err
}
configPath = config.UserConfigPath(home)
}
return nil
},
}
func Execute() {
@@ -29,5 +46,5 @@ func Execute() {
}
func init() {
rootCmd.PersistentFlags().StringVar(&configPath, "config", "/etc/unitdore/units.yaml", "path to units config file")
rootCmd.PersistentFlags().StringVar(&configPath, "config", config.DefaultConfigPath, "path to units config file (default for non-root: ~/.config/unitdore/units.yaml)")
}
+30 -23
View File
@@ -2,45 +2,52 @@ package cmd
import (
"fmt"
"io"
"os"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/initsys"
)
var startAll bool
var startCmd = &cobra.Command{
Use: "start <name>",
Short: "Start a unit by name",
Args: cobra.ExactArgs(1),
RunE: runStart,
Use: "start <name> | --all",
Short: "Start a unit by name (or all with --all)",
Long: `Start starts the named installed unit without enabling it at boot.
With --all (same as 'startall') it starts every enabled, installed unit in
startup order: equal 'order' values in parallel, groups one after another.
'delay' is waited inside the generated service.`,
Args: nameOrAll(&startAll),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, ini, err := loadInit()
if err != nil {
return err
}
if startAll {
bulkStart(cfg, ini, os.Stdout, initsys.Init.Start, "started")
return nil
}
return startOne(cfg, ini, args[0], os.Stdout)
},
}
func init() {
startCmd.Flags().BoolVar(&startAll, "all", false, "start all enabled, installed units")
rootCmd.AddCommand(startCmd)
}
func runStart(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath)
func startOne(cfg *config.Config, ini initsys.Init, name string, w io.Writer) error {
u, err := installedUnit(cfg, ini, name)
if err != nil {
return err
}
name := args[0]
u := cfg.FindUnit(name)
if u == nil {
return fmt.Errorf("unit %q not found", name)
}
prefix, suffix := cfg.Prefix, cfg.Suffix
if !systemd.IsInstalled(*u, prefix, suffix) {
return fmt.Errorf("%s is not installed — run 'unitdore install' first", name)
}
fmt.Printf(" ▶ starting %s...\n", systemd.ServiceName(*u, prefix, suffix))
if err := systemd.Start(*u, prefix, suffix); err != nil {
fmt.Fprintf(w, " ▶ starting %s...\n", ini.ServiceName(*u, cfg.Prefix, cfg.Suffix))
if err := ini.Start(*u, cfg.Prefix, cfg.Suffix); err != nil {
return err
}
fmt.Printf(" ✓ started: %s\n", name)
fmt.Fprintf(w, " ✓ started: %s\n", name)
return nil
}
+46
View File
@@ -0,0 +1,46 @@
package cmd
import (
"fmt"
"testing"
"github.com/spf13/cobra"
)
// TestStartArgCount verifies cobra.ExactArgs(1) rejects wrong arg counts for start.
func TestStartArgCount(t *testing.T) {
cmd := &cobra.Command{Use: "start", Short: "fake"}
cmd.Args = exactArgsOne
if err := cmd.ValidateArgs([]string{}); err == nil {
t.Error("expected error when called with no args")
}
if err := cmd.ValidateArgs([]string{"a", "b"}); err == nil {
t.Error("expected error when called with too many args")
}
if err := cmd.ValidateArgs([]string{"myunit"}); err != nil {
t.Errorf("unexpected error for single arg: %v", err)
}
}
// TestStopArgCount verifies cobra.ExactArgs(1) rejects wrong arg counts for stop.
func TestStopArgCount(t *testing.T) {
cmd := &cobra.Command{Use: "stop", Short: "fake"}
cmd.Args = exactArgsOne
if err := cmd.ValidateArgs([]string{}); err == nil {
t.Error("expected error when called with no args")
}
if err := cmd.ValidateArgs([]string{"a", "b"}); err == nil {
t.Error("expected error when called with too many args")
}
if err := cmd.ValidateArgs([]string{"myunit"}); err != nil {
t.Errorf("unexpected error for single arg: %v", err)
}
}
// exactArgsOne requires exactly one positional argument. Mirrors cobra.ExactArgs(1).
func exactArgsOne(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return fmt.Errorf("start/stop requires exactly 1 argument")
}
return nil
}
-64
View File
@@ -1,64 +0,0 @@
package cmd
import (
"fmt"
"sort"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
)
var startallCmd = &cobra.Command{
Use: "startall",
Short: "Enable and start all installed, enabled units",
Long: `Startall runs 'systemctl enable --now' for all enabled units that have
been installed. Units must be installed first via 'unitdore install'.`,
RunE: runStartall,
}
func init() {
rootCmd.AddCommand(startallCmd)
}
func runStartall(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath)
if err != nil {
return err
}
prefix, suffix := cfg.Prefix, cfg.Suffix
units := make([]config.Unit, len(cfg.Units))
copy(units, cfg.Units)
sort.Slice(units, func(i, j int) bool {
if units[i].Order != units[j].Order {
return units[i].Order < units[j].Order
}
return units[i].Name < units[j].Name
})
started := 0
failed := 0
for _, u := range units {
if !u.Enabled {
continue
}
if !systemd.IsInstalled(u, prefix, suffix) {
fmt.Printf(" ! %s: not installed — run 'unitdore install' first\n", u.Name)
continue
}
fmt.Printf(" ▶ starting: %s...\n", systemd.ServiceName(u, prefix, suffix))
if err := systemd.Enable(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
failed++
} else {
fmt.Printf(" ✓ started: %s\n", u.Name)
started++
}
}
fmt.Printf("\nDone. Started: %d Failed: %d\n", started, failed)
return nil
}
+10 -14
View File
@@ -2,12 +2,11 @@ package cmd
import (
"fmt"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/initsys"
)
var statusCmd = &cobra.Command{
@@ -37,20 +36,17 @@ func runStatus(cmd *cobra.Command, args []string) error {
}
prefix, suffix := cfg.Prefix, cfg.Suffix
ini, err := initsys.New(cfg)
if err != nil {
return err
}
if len(cfg.Units) == 0 {
fmt.Println("No units configured. Run 'unitdore syncup' to discover containers.")
return nil
}
units := make([]config.Unit, len(cfg.Units))
copy(units, cfg.Units)
sort.Slice(units, func(i, j int) bool {
if units[i].Order != units[j].Order {
return units[i].Order < units[j].Order
}
return units[i].Name < units[j].Name
})
units := sortUnits(cfg.Units, false)
rows := make([]statusRow, 0, len(units))
for _, u := range units {
@@ -65,18 +61,18 @@ func runStatus(cmd *cobra.Command, args []string) error {
}
installed := "yes"
if !systemd.IsInstalled(u, prefix, suffix) {
if !ini.IsInstalled(u, prefix, suffix) {
installed = "no"
}
state := "—"
if systemd.IsInstalled(u, prefix, suffix) {
state = systemd.Status(u, prefix, suffix)
if ini.IsInstalled(u, prefix, suffix) {
state = ini.Status(u, prefix, suffix)
}
rows = append(rows, statusRow{
Name: u.Name,
Runtime: u.Runtime,
Runtime: runtimeLabel(u),
User: user,
Enabled: enabled,
Installed: installed,
+29 -23
View File
@@ -2,45 +2,51 @@ package cmd
import (
"fmt"
"io"
"os"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/initsys"
)
var stopAllFlag bool
var stopCmd = &cobra.Command{
Use: "stop <name>",
Short: "Stop a unit by name",
Args: cobra.ExactArgs(1),
RunE: runStop,
Use: "stop <name> | --all",
Short: "Stop a unit by name (or all with --all)",
Long: `Stop stops the named installed unit.
With --all (same as 'stopall') it stops every installed unit in reverse
startup order, one at a time.`,
Args: nameOrAll(&stopAllFlag),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, ini, err := loadInit()
if err != nil {
return err
}
if stopAllFlag {
stopAll(cfg, ini, os.Stdout)
return nil
}
return stopOne(cfg, ini, args[0], os.Stdout)
},
}
func init() {
stopCmd.Flags().BoolVar(&stopAllFlag, "all", false, "stop all installed units")
rootCmd.AddCommand(stopCmd)
}
func runStop(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath)
func stopOne(cfg *config.Config, ini initsys.Init, name string, w io.Writer) error {
u, err := installedUnit(cfg, ini, name)
if err != nil {
return err
}
name := args[0]
u := cfg.FindUnit(name)
if u == nil {
return fmt.Errorf("unit %q not found", name)
}
prefix, suffix := cfg.Prefix, cfg.Suffix
if !systemd.IsInstalled(*u, prefix, suffix) {
return fmt.Errorf("%s is not installed", name)
}
fmt.Printf(" ■ stopping %s...\n", systemd.ServiceName(*u, prefix, suffix))
if err := systemd.Stop(*u, prefix, suffix); err != nil {
fmt.Fprintf(w, " ■ stopping %s...\n", ini.ServiceName(*u, cfg.Prefix, cfg.Suffix))
if err := ini.Stop(*u, cfg.Prefix, cfg.Suffix); err != nil {
return err
}
fmt.Printf(" ✓ stopped: %s\n", name)
fmt.Fprintf(w, " ✓ stopped: %s\n", name)
return nil
}
-60
View File
@@ -1,60 +0,0 @@
package cmd
import (
"fmt"
"sort"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
)
var stopallCmd = &cobra.Command{
Use: "stopall",
Short: "Stop and disable all running managed units",
Long: `Stopall runs 'systemctl disable --now' for all enabled, installed units.`,
RunE: runStopall,
}
func init() {
rootCmd.AddCommand(stopallCmd)
}
func runStopall(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath)
if err != nil {
return err
}
prefix, suffix := cfg.Prefix, cfg.Suffix
// Reverse order for shutdown
units := make([]config.Unit, len(cfg.Units))
copy(units, cfg.Units)
sort.Slice(units, func(i, j int) bool {
if units[i].Order != units[j].Order {
return units[i].Order > units[j].Order
}
return units[i].Name > units[j].Name
})
stopped := 0
failed := 0
for _, u := range units {
if !systemd.IsInstalled(u, prefix, suffix) {
continue
}
fmt.Printf(" ■ stopping: %s...\n", systemd.ServiceName(u, prefix, suffix))
if err := systemd.Disable(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
failed++
} else {
fmt.Printf(" ✓ stopped: %s\n", u.Name)
stopped++
}
}
fmt.Printf("\nDone. Stopped: %d Failed: %d\n", stopped, failed)
return nil
}
+233 -56
View File
@@ -2,11 +2,15 @@ package cmd
import (
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/runtime"
"github.com/warkanum/unitdore/service"
"github.com/warkanum/unitdore/sysuser"
)
var syncupCmd = &cobra.Command{
@@ -15,8 +19,17 @@ var syncupCmd = &cobra.Command{
Long: `Syncup queries all available runtimes for running containers and adds
any new ones to the config file. Existing entries are never removed.
It also reconciles: if a configured unit's container no longer exists,
it is marked disabled with a reason.`,
Podman is queried first. Docker is only a fallback: a container name found
in podman is never registered as docker.
Containers created by podman-compose are grouped into one stack unit per
project. Existing per-container units of stack members are disabled.
Scopes: as root, root's containers plus rootless containers of each user in
'users:' (podman only). As a normal user, the user's own podman containers.
It also reconciles: if a configured unit's container or stack no longer
exists, it is marked disabled with a reason.`,
RunE: runSyncup,
}
@@ -24,71 +37,235 @@ func init() {
rootCmd.AddCommand(syncupCmd)
}
const (
reasonNotFound = "container not found"
reasonStackNotFound = "stack not found"
reasonMemberPrefix = "part of stack "
)
type syncStats struct {
added, unchanged, disabled, reenabled int
}
func runSyncup(cmd *cobra.Command, args []string) error {
cfg, err := config.LoadOrEmpty(configPath)
if err != nil {
return err
}
added := 0
unchanged := 0
discovered := map[string]bool{}
containers := discover(syncScopes(cfg))
// Discover running containers across all runtimes
for _, rt := range runtime.Available() {
containers, err := rt.ListRunning()
if err != nil {
fmt.Fprintf(os.Stderr, "warning: %s discovery failed: %v\n", rt.Name(), err)
continue
}
for _, c := range containers {
discovered[c.Name] = true
unit := config.Unit{
Name: c.Name,
Runtime: c.Runtime,
Order: len(cfg.Units) + 1,
Enabled: true,
}
if cfg.AddUnit(unit) {
fmt.Printf(" + added: %s (%s)\n", c.Name, c.Runtime)
added++
} else {
unchanged++
}
}
}
// Reconcile: check if existing units' containers still exist
disabled := 0
reenabled := 0
for i := range cfg.Units {
u := &cfg.Units[i]
rt := runtime.Get(u.Runtime)
if rt == nil {
continue
}
exists, err := rt.Exists(u.Name)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: checking %s: %v\n", u.Name, err)
continue
}
if !exists && u.Enabled {
u.Enabled = false
u.DisabledReason = "container not found"
fmt.Printf(" ! disabled: %s (container not found)\n", u.Name)
disabled++
} else if exists && !u.Enabled && u.DisabledReason == "container not found" {
u.Enabled = true
u.DisabledReason = ""
fmt.Printf(" ✓ re-enabled: %s (container found)\n", u.Name)
reenabled++
}
}
var st syncStats
running := mergeDiscovered(cfg, containers, composeBinFor, os.Stdout, &st)
reconcile(cfg, running, unitExists, os.Stdout, &st)
if err := cfg.Save(configPath); err != nil {
return err
}
fmt.Printf("\nDone. Added: %d Unchanged: %d Disabled: %d Re-enabled: %d\n", added, unchanged, disabled, reenabled)
fmt.Printf("\nDone. Added: %d Unchanged: %d Disabled: %d Re-enabled: %d\n", st.added, st.unchanged, st.disabled, st.reenabled)
return nil
}
// syncScopes returns the user scopes to scan ("" = root/system).
func syncScopes(cfg *config.Config) []string {
if sysuser.IsRoot() {
return append([]string{""}, cfg.Users...)
}
return []string{sysuser.Current()}
}
// discover lists running containers in every scope, podman first.
func discover(scopes []string) []runtime.Container {
var all []runtime.Container
for _, user := range scopes {
for _, rt := range runtime.Available() {
// Docker is a system daemon; user scopes only see rootless podman
if user != "" && rt.Name() == "docker" {
continue
}
containers, err := rt.ListRunning(user)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: %s discovery failed (%s): %v\n", rt.Name(), scopeName(user), err)
continue
}
all = append(all, containers...)
}
}
return all
}
func composeBinFor(user string) string {
return sysuser.LookPath(user, "podman-compose")
}
func scopeName(user string) string {
if user == "" {
return "root"
}
return user
}
func scopeKey(user, name string) string { return user + "/" + name }
// mergeDiscovered adds discovered containers and stacks to cfg. Returns the
// set of running scopeKey(user, name) for containers and stack projects.
func mergeDiscovered(cfg *config.Config, containers []runtime.Container, composeBin func(user string) string, w io.Writer, st *syncStats) map[string]bool {
running := map[string]bool{}
podmanNames := map[string]bool{}
members := map[string]string{} // scopeKey(user, container) -> project
for _, c := range containers {
if c.Runtime == "podman" {
podmanNames[scopeKey(c.User, c.Name)] = true
}
}
// Group podman-compose containers into one stack unit per (user, project)
for _, c := range containers {
project := c.Project()
if c.Runtime != "podman" || project == "" {
continue
}
members[scopeKey(c.User, c.Name)] = project
key := scopeKey(c.User, project)
if running[key] {
continue
}
running[key] = true
stack := config.Unit{
Name: project,
Runtime: "podman",
User: c.User,
Kind: config.KindStack,
Project: project,
WorkingDir: c.WorkingDir(),
ComposeFiles: c.ComposeFiles(),
ComposeBin: composeBin(c.User),
Order: len(cfg.Units) + 1,
Enabled: true,
}
existing := cfg.FindUnit(project)
switch {
case existing == nil:
cfg.AddUnit(stack)
fmt.Fprintf(w, " + added stack: %s (%s, %s)\n", project, scopeName(c.User), strings.Join(stack.ComposeFiles, ","))
st.added++
case existing.IsStack() && existing.Project == project && existing.User == c.User:
// Labels are the source of truth for where the stack lives
existing.WorkingDir = stack.WorkingDir
existing.ComposeFiles = stack.ComposeFiles
if stack.ComposeBin != "" {
existing.ComposeBin = stack.ComposeBin
}
st.unchanged++
default:
fmt.Fprintf(os.Stderr, "warning: stack %s (%s) skipped: unit name already used\n", project, scopeName(c.User))
}
}
for _, c := range containers {
key := scopeKey(c.User, c.Name)
// Podman is queried first and wins on name clash (e.g. podman-docker shim)
if c.Runtime != "podman" && podmanNames[key] {
continue
}
running[key] = true
if _, ok := members[key]; ok {
continue
}
unit := config.Unit{
Name: c.Name,
Runtime: c.Runtime,
User: c.User,
Order: len(cfg.Units) + 1,
Enabled: true,
}
if cfg.AddUnit(unit) {
fmt.Fprintf(w, " + added: %s (%s, %s)\n", c.Name, c.Runtime, scopeName(c.User))
st.added++
continue
}
if existing := cfg.FindUnit(c.Name); existing.User != c.User {
fmt.Fprintf(os.Stderr, "warning: %s (%s) skipped: unit name already used by %s\n", c.Name, scopeName(c.User), scopeName(existing.User))
continue
}
st.unchanged++
}
for i := range cfg.Units {
u := &cfg.Units[i]
if u.IsStack() {
continue
}
key := scopeKey(u.User, u.Name)
if u.Runtime == "docker" && podmanNames[key] {
u.Runtime = "podman"
fmt.Fprintf(w, " ~ runtime: %s docker -> podman\n", u.Name)
}
if project, ok := members[key]; ok && u.DisabledReason != reasonMemberPrefix+project {
wasEnabled := u.Enabled
u.Enabled = false
u.DisabledReason = reasonMemberPrefix + project
if wasEnabled {
fmt.Fprintf(w, " ! disabled: %s (%s)\n", u.Name, u.DisabledReason)
st.disabled++
}
}
}
return running
}
// unitExists checks whether a unit's container or stack still exists.
func unitExists(u config.Unit) (bool, error) {
if u.IsStack() {
if 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)
}
}
+32 -28
View File
@@ -2,54 +2,58 @@ package cmd
import (
"fmt"
"io"
"os"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/initsys"
)
var uninstallAllFlag bool
var uninstallCmd = &cobra.Command{
Use: "uninstall <name>",
Short: "Disable, stop, and remove the service file for a unit",
Args: cobra.ExactArgs(1),
RunE: runUninstall,
Use: "uninstall <name> | --all",
Short: "Stop, disable and remove the service file for a unit (or all with --all)",
Long: `Uninstall stops, disables and removes the service file of the named unit.
With --all (same as 'uninstallall') it does so for every installed unit in
reverse startup order.`,
Args: nameOrAll(&uninstallAllFlag),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, ini, err := loadInit()
if err != nil {
return err
}
if uninstallAllFlag {
uninstallAll(cfg, ini, os.Stdout)
return nil
}
return uninstallOne(cfg, ini, args[0], os.Stdout)
},
}
func init() {
uninstallCmd.Flags().BoolVar(&uninstallAllFlag, "all", false, "uninstall all installed units")
rootCmd.AddCommand(uninstallCmd)
}
func runUninstall(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath)
func uninstallOne(cfg *config.Config, ini initsys.Init, name string, w io.Writer) error {
u, err := installedUnit(cfg, ini, name)
if err != nil {
return err
}
name := args[0]
u := cfg.FindUnit(name)
if u == nil {
return fmt.Errorf("unit %q not found", name)
}
prefix, suffix := cfg.Prefix, cfg.Suffix
if !systemd.IsInstalled(*u, prefix, suffix) {
return fmt.Errorf("%s is not installed", name)
}
svcName := systemd.ServiceName(*u, prefix, suffix)
fmt.Printf(" ■ disabling %s...\n", svcName)
if err := systemd.Disable(*u, prefix, suffix); err != nil {
fmt.Fprintf(w, " ■ disabling %s...\n", ini.ServiceName(*u, prefix, suffix))
if err := ini.Disable(*u, prefix, suffix); err != nil {
return fmt.Errorf("disabling unit: %w", err)
}
path, _ := systemd.UnitPath(*u, prefix, suffix)
fmt.Printf(" ✗ removing %s...\n", path)
if err := systemd.Uninstall(*u, prefix, suffix); err != nil {
path, _ := ini.UnitPath(*u, prefix, suffix)
fmt.Fprintf(w, " ✗ removing %s...\n", path)
if err := ini.Uninstall(*u, prefix, suffix); err != nil {
return fmt.Errorf("removing service file: %w", err)
}
fmt.Printf(" ✓ uninstalled: %s\n", name)
fmt.Fprintf(w, " ✓ uninstalled: %s\n", name)
return nil
}
+50 -15
View File
@@ -3,10 +3,12 @@ package cmd
import (
"errors"
"fmt"
"io"
"os"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/initsys"
)
var (
@@ -38,40 +40,73 @@ func runUpdate(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
ini, err := initsys.New(cfg)
if err != nil {
return err
}
name := args[0]
var order *int
if cmd.Flags().Changed("order") {
order = &updateOrder
}
return applyUpdate(cfg, ini, args[0], order, updateEnable, updateDisable, configPath, os.Stdout)
}
// applyUpdate changes a unit's config, saves it and brings the installed
// service file in line: re-created if enabled, stopped and removed if disabled.
func applyUpdate(cfg *config.Config, ini initsys.Init, name string, order *int, enable, disable bool, path string, w io.Writer) error {
u := cfg.FindUnit(name)
if u == nil {
return fmt.Errorf("unit %q not found", name)
}
if cmd.Flags().Changed("order") {
u.Order = updateOrder
fmt.Printf(" order → %d\n", u.Order)
if order != nil {
u.Order = *order
fmt.Fprintf(w, " order → %d\n", u.Order)
}
if updateEnable {
if enable {
u.Enabled = true
u.DisabledReason = ""
fmt.Printf(" enabled\n")
fmt.Fprintf(w, " enabled\n")
}
if updateDisable {
if disable {
u.Enabled = false
fmt.Printf(" disabled\n")
fmt.Fprintf(w, " disabled\n")
}
if err := cfg.Save(configPath); err != nil {
if err := cfg.ResolveOrder(); err != nil {
return err
}
if err := cfg.Save(path); err != nil {
return err
}
prefix, suffix := cfg.Prefix, cfg.Suffix
if systemd.IsInstalled(*u, prefix, suffix) {
if err := systemd.Install(*u, prefix, suffix); err != nil {
return fmt.Errorf("recreating service file: %w", err)
// The target is stopped and removed if now disabled
if ini.IsInstalled(*u, prefix, suffix) && !u.Enabled {
svcPath, _ := ini.UnitPath(*u, prefix, suffix)
if err := ini.Disable(*u, prefix, suffix); err != nil {
return fmt.Errorf("disabling unit: %w", err)
}
path, _ := systemd.UnitPath(*u, prefix, suffix)
fmt.Printf(" ✓ service file recreated: %s\n", path)
if err := ini.Uninstall(*u, prefix, suffix); err != nil {
return fmt.Errorf("removing service file: %w", err)
}
fmt.Fprintf(w, " ✓ service stopped and removed: %s\n", svcPath)
}
// Order and enabled state change the After=/delay of other units too, so
// every installed, enabled unit is regenerated
for i := range cfg.Units {
o := cfg.Units[i]
if !o.Enabled || !ini.IsInstalled(o, prefix, suffix) {
continue
}
if err := ini.Install(o, prefix, suffix); err != nil {
return fmt.Errorf("recreating service file for %s: %w", o.Name, err)
}
svcPath, _ := ini.UnitPath(o, prefix, suffix)
fmt.Fprintf(w, " ✓ service file recreated: %s\n", svcPath)
}
return nil
}
+123 -3
View File
@@ -4,12 +4,33 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"time"
"gopkg.in/yaml.v3"
)
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.
type Unit struct {
Name string `yaml:"name"`
@@ -20,13 +41,61 @@ type Unit struct {
Delay string `yaml:"delay,omitempty"` // e.g. "5s"
Enabled bool `yaml:"enabled"`
DisabledReason string `yaml:"disabled_reason,omitempty"`
Restart bool `yaml:"restart,omitempty"` // enable Restart=on-failure
RestartSec int `yaml:"restart_sec,omitempty"` // seconds between restarts
RestartRetries int `yaml:"restart_retries,omitempty"` // max restart attempts (StartLimitBurst)
// Resolved from order/delay by Config.ResolveOrder; never stored.
After []string `yaml:"-"` // enabled same-user units of the previous order group
StartDelay time.Duration `yaml:"-"` // wait before start (zero for the first group)
// 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.
type Config struct {
Units []Unit `yaml:"units"`
Prefix string `yaml:"prefix,omitempty"` // prepended to generated service name, e.g. "prod-"
Suffix string `yaml:"suffix,omitempty"` // appended to generated service name, e.g. "-svc"
Units []Unit `yaml:"units"`
Prefix string `yaml:"prefix,omitempty"` // prepended to generated service name, e.g. "prod-"
Suffix string `yaml:"suffix,omitempty"` // appended to generated service name, e.g. "-svc"
Users []string `yaml:"users,omitempty"` // rootless users scanned by syncup when run as root
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.
@@ -39,9 +108,60 @@ func Load(path string) (*Config, error) {
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
if err := cfg.ResolveOrder(); err != nil {
return nil, err
}
return &cfg, nil
}
// ResolveOrder fills After and StartDelay on every unit from order and delay.
// After lists the enabled units of the nearest lower order group in the same
// scope (user). StartDelay is the unit's own delay, ignored for the first
// group. Call again after changing order, delay or enabled.
func (c *Config) ResolveOrder() error {
delays := make([]time.Duration, len(c.Units))
for i := range c.Units {
if c.Units[i].Delay == "" {
continue
}
d, err := time.ParseDuration(c.Units[i].Delay)
if err != nil || d < 0 {
return fmt.Errorf("unit %q: invalid delay %q", c.Units[i].Name, c.Units[i].Delay)
}
delays[i] = d
}
for i := range c.Units {
u := &c.Units[i]
u.After, u.StartDelay = nil, 0
if !u.Enabled {
continue
}
hasLower, best := false, 0
for j := range c.Units {
o := c.Units[j]
if !o.Enabled || o.Order >= u.Order {
continue
}
hasLower = true
if o.User != u.User {
continue
}
switch {
case u.After == nil || o.Order > best:
best, u.After = o.Order, []string{o.Name}
case o.Order == best:
u.After = append(u.After, o.Name)
}
}
sort.Strings(u.After)
if hasLower {
u.StartDelay = delays[i]
}
}
return nil
}
// LoadOrEmpty loads the config file, or returns an empty config if it doesn't exist.
func LoadOrEmpty(path string) (*Config, error) {
_, err := os.Stat(path)
+108
View File
@@ -3,7 +3,9 @@ package config
import (
"os"
"path/filepath"
"reflect"
"testing"
"time"
)
func TestLoadOrEmpty_NoFile(t *testing.T) {
@@ -144,3 +146,109 @@ func TestDisabledReason_Roundtrip(t *testing.T) {
t.Errorf("expected disabled_reason to survive roundtrip, got: %q", loaded.Units[0].DisabledReason)
}
}
func TestSaveAndLoad_Stack(t *testing.T) {
path := filepath.Join(t.TempDir(), "units.yaml")
cfg := &Config{
Users: []string{"hein"},
Units: []Unit{{
Name: "shop", Runtime: "podman", Kind: KindStack, Project: "shop",
WorkingDir: "/srv/shop", ComposeFiles: []string{"compose.yaml"},
ComposeBin: "/usr/bin/podman-compose", User: "hein", Enabled: true,
}},
}
if err := cfg.Save(path); err != nil {
t.Fatalf("Save failed: %v", err)
}
loaded, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
u := loaded.Units[0]
if !u.IsStack() || u.Project != "shop" || u.WorkingDir != "/srv/shop" ||
len(u.ComposeFiles) != 1 || u.ComposeBin != "/usr/bin/podman-compose" {
t.Errorf("stack fields not round-tripped: %+v", u)
}
if len(loaded.Users) != 1 || loaded.Users[0] != "hein" {
t.Errorf("users not round-tripped: %v", loaded.Users)
}
}
func TestIsStack_DefaultKind(t *testing.T) {
if (Unit{Name: "x"}).IsStack() {
t.Error("unit without kind must be a container")
}
}
func TestUserConfigPath(t *testing.T) {
if got := UserConfigPath("/home/hein"); got != "/home/hein/.config/unitdore/units.yaml" {
t.Errorf("UserConfigPath() = %q", got)
}
}
func TestResolveOrder(t *testing.T) {
cfg := &Config{Units: []Unit{
{Name: "db", Order: 1, Enabled: true, Delay: "9s"},
{Name: "cache", Order: 1, Enabled: true},
{Name: "off", Order: 2, Enabled: false},
{Name: "web", Order: 3, Enabled: true, Delay: "5s"},
{Name: "web-user", Order: 3, Enabled: true, User: "hein"},
{Name: "user-db", Order: 1, Enabled: true, User: "hein"},
}}
if err := cfg.ResolveOrder(); err != nil {
t.Fatal(err)
}
get := func(n string) Unit { return *cfg.FindUnit(n) }
if u := get("db"); len(u.After) != 0 || u.StartDelay != 0 {
t.Errorf("first group must not wait: %+v", u)
}
if u := get("off"); len(u.After) != 0 || u.StartDelay != 0 {
t.Errorf("disabled unit gets no ordering: %+v", u)
}
// skips the disabled order-2 unit, picks the enabled order-1 same-scope units
if u := get("web"); !reflect.DeepEqual(u.After, []string{"cache", "db"}) || u.StartDelay != 5*time.Second {
t.Errorf("web: %+v", u)
}
// user units only order after units of the same user
if u := get("web-user"); !reflect.DeepEqual(u.After, []string{"user-db"}) || u.StartDelay != 0 {
t.Errorf("web-user: %+v", u)
}
// re-resolving after a change resets stale values
cfg.FindUnit("db").Order = 9
if err := cfg.ResolveOrder(); err != nil {
t.Fatal(err)
}
if u := get("web"); !reflect.DeepEqual(u.After, []string{"cache"}) {
t.Errorf("web after reorder: %+v", u)
}
}
func TestResolveOrder_InvalidDelay(t *testing.T) {
for _, d := range []string{"abc", "-2s"} {
cfg := &Config{Units: []Unit{{Name: "a", Delay: d, Enabled: true}}}
if err := cfg.ResolveOrder(); err == nil {
t.Errorf("delay %q should be rejected", d)
}
}
}
func TestLoad_ResolvesOrderAndRejectsBadDelay(t *testing.T) {
dir := t.TempDir()
good := filepath.Join(dir, "good.yaml")
os.WriteFile(good, []byte("units:\n - {name: a, order: 1, enabled: true}\n - {name: b, order: 2, delay: 4s, enabled: true}\n"), 0644)
cfg, err := Load(good)
if err != nil {
t.Fatal(err)
}
if b := cfg.Units[1]; !reflect.DeepEqual(b.After, []string{"a"}) || b.StartDelay != 4*time.Second {
t.Errorf("b = %+v", b)
}
bad := filepath.Join(dir, "bad.yaml")
os.WriteFile(bad, []byte("units:\n - {name: a, delay: nope, enabled: true}\n"), 0644)
if _, err := Load(bad); err == nil {
t.Error("Load must reject invalid delay")
}
}
+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.
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
| Scenario | Path |
@@ -286,3 +659,5 @@ units:
| With prefix `prod-` | `/etc/systemd/system/unitdore-prod-<name>.service` |
| With suffix `-svc` | `/etc/systemd/system/unitdore-<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`) |
+65 -24
View File
@@ -1,6 +1,6 @@
.TH UNITDORE 1 "2026-04-08" "0.1.0" "User Commands"
.SH NAME
unitdore \- manage container units via systemd
unitdore \- manage container units via systemd, OpenRC or runit
.SH SYNOPSIS
.B unitdore
[\fB\-\-config\fR \fIpath\fR]
@@ -9,42 +9,59 @@ unitdore \- manage container units via systemd
.SH DESCRIPTION
.B unitdore
bridges your container runtime (Podman, Docker) and systemd.
It discovers running containers, stores them in a config file, and generates
and manages systemd \fI.service\fR units for each one.
It discovers running containers and podman\-compose stacks, stores them in a
config file, and generates and manages systemd \fI.service\fR units for each one.
.PP
As root: system units, plus user units for users listed in \fBusers:\fR.
As a normal user: own config (\fI~/.config/unitdore/units.yaml\fR) and user units only.
.SH OPTIONS
.TP
.BR \-\-config " " \fIpath\fR
Path to the units config file. Default: \fI/etc/unitdore/units.yaml\fR
(non\-root: \fI~/.config/unitdore/units.yaml\fR)
.SH COMMANDS
.TP
.B syncup
Query all available runtimes for running containers and add any new ones to
the config. Reconciles existing entries: if a configured unit's container no
longer exists it is marked disabled with a reason.
Podman is queried first; Docker only for names Podman does not have.
podman\-compose containers become one \fBkind: stack\fR unit per project;
existing member units are disabled. As root, rootless Podman containers of
each user in \fBusers:\fR are also discovered.
.TP
.B install
Generate \fI.service\fR files for all enabled units and write them to the
appropriate systemd directory. Use \fB\-\-dry\-run\fR to preview without writing.
.B install \fIname\fR | \fB\-\-all\fR
Generate the service file for one enabled unit. Does not enable or start it.
Use \fB\-\-dry\-run\fR to preview without writing. With \fB\-\-all\fR (and no
name) it behaves like \fBinstallall\fR.
.TP
.B uninstall \fIname\fR
Disable, stop, and remove the service file for a specific unit.
.B installall
Generate service files for all enabled units. Disabled units that are
installed are stopped, disabled and removed. Supports \fB\-\-dry\-run\fR.
.TP
.B startall
Run \fBsystemctl enable \-\-now\fR for all enabled, installed units in startup
order.
.B uninstall \fIname\fR | \fB\-\-all\fR
Disable, stop, and remove the service file for a unit. With \fB\-\-all\fR, for
every installed unit in reverse startup order (same as \fBuninstallall\fR).
.TP
.B stopall
Run \fBsystemctl disable \-\-now\fR for all installed units in reverse startup
order.
.B start \fIname\fR | \fB\-\-all\fR
Start an installed unit without enabling it at boot. With \fB\-\-all\fR, start
all enabled, installed units in startup order: equal \fBorder\fR in parallel,
groups one after another (same as \fBstartall\fR). \fBorder\fR and \fBdelay\fR are
also written into the service files (systemd \fBAfter=\fR and \fBExecStartPre\fR,
OpenRC \fBafter\fR and \fBstart_pre\fR, runit \fBsv check\fR and \fBsleep\fR), so
they apply at boot.
.TP
.B start \fIname\fR
Start a specific unit by name (\fBsystemctl start\fR).
.B enable \fIname\fR | \fB\-\-all\fR
Like \fBstart\fR, but also enable at boot (same as \fBenableall\fR).
.TP
.B stop \fIname\fR
Stop a specific unit by name (\fBsystemctl stop\fR).
.B stop \fIname\fR | \fB\-\-all\fR
Stop an installed unit. With \fB\-\-all\fR, stop all installed units one at a time
in reverse startup order (same as \fBstopall\fR).
.TP
.B update \fIname\fR
Update a unit's config and recreate its service file.
Update a unit's config. An installed unit's service file is recreated, or
stopped and removed if the unit is now disabled. Other installed units are
regenerated too, as ordering depends on their neighbours.
.RS
.TP
.BR \-\-order " " \fIn\fR
@@ -74,7 +91,7 @@ Open the units config file in \fB$EDITOR\fR. Falls back to \fBnvim\fR,
.nf
prefix: "" # prepended to all service names, e.g. "prod-"
suffix: "" # appended to all service names, e.g. "-svc"
service_user: "" # User= in [Service]; defaults to current OS user
users: [] # root only: rootless users scanned by syncup
units:
- name: nginx
@@ -85,7 +102,26 @@ units:
delay: 0s # delay after previous order group
enabled: true
disabled_reason: "" # auto-set by syncup reconciliation
- name: shop # compose project name
kind: stack # container (default) | stack
runtime: podman
project: shop
working_dir: /srv/shop
compose_files: [compose.yaml]
compose_bin: /usr/bin/podman-compose
.fi
.PP
Stack units run \fBpodman\-compose up\fR/\fBstop\fR when compose_bin,
working_dir and compose_files exist; otherwise \fBpodman pod start\fR/\fBstop
pod_\fR\fIproject\fR.
.SH 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
System units are written to \fI/etc/systemd/system/\fR and user units to
\fI~/.config/systemd/user/\fR.
@@ -100,15 +136,15 @@ Discover containers and install their service files:
.RS
.nf
sudo unitdore syncup
sudo unitdore install
sudo unitdore startall
sudo unitdore install \-\-all
sudo unitdore start \-\-all
.fi
.RE
.PP
Preview generated service files without writing:
.PP
.RS
sudo unitdore install \-\-dry\-run
sudo unitdore install nginx \-\-dry\-run
.RE
.PP
Start a single container unit:
@@ -127,13 +163,18 @@ sudo unitdore update nginx \-\-order 2
.I /etc/unitdore/units.yaml
Default configuration file.
.TP
.I ~/.config/unitdore/units.yaml
Configuration file when run as a normal user.
.TP
.I /etc/systemd/system/unitdore-*.service
Generated system unit files.
.TP
.I ~/.config/systemd/user/unitdore-*.service
Generated rootless user unit files.
.SH REQUIREMENTS
systemd; Podman and/or Docker (only installed runtimes are used).
systemd, 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.
.SH SEE ALSO
.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")
}
}
+300
View File
@@ -0,0 +1,300 @@
// 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"
"time"
"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")
}
if len(u.After) > 0 {
names := make([]string, len(u.After))
for i, n := range u.After {
names[i] = service.BaseName(config.Unit{Name: n}, prefix, suffix)
}
fmt.Fprintf(&w, "\tafter %s\n", strings.Join(names, " "))
}
w.WriteString("}\n")
var pre []string
if spec.Type != service.TypeOneshot && user != nil {
pre = append(pre, fmt.Sprintf("checkpath -d -m 0700 -o %s %s",
service.ShellQuote(user.Name), service.ShellQuote(user.RuntimeDir)))
}
if secs := delaySeconds(u); secs > 0 {
pre = append(pre, fmt.Sprintf("sleep %d", secs))
}
if len(pre) > 0 {
fmt.Fprintf(&w, "\nstart_pre() {\n\t%s\n}\n", strings.Join(pre, "\n\t"))
}
if spec.Type != service.TypeOneshot {
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) + `"`
}
// delaySeconds rounds the unit's start delay up to whole seconds.
func delaySeconds(u config.Unit) int {
return int((u.StartDelay + time.Second - 1) / time.Second)
}
+253
View File
@@ -0,0 +1,253 @@
package openrc
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"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)
}
}
}
func TestGenerate_OrderAndDelay(t *testing.T) {
setup(t)
u := config.Unit{Name: "web", Runtime: "podman", Enabled: true, After: []string{"db", "cache"}, StartDelay: 5 * time.Second}
got, err := Backend{}.Generate(u, "p-", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got, "need net\n\tafter unitdore-p-db unitdore-p-cache\n}", "start_pre() {\n\tsleep 5\n}")
}
func TestGenerate_OneshotDelay(t *testing.T) {
setup(t)
u := config.Unit{Name: "job", Runtime: "exec", Executable: "/bin/true", ServiceType: "oneshot", Enabled: true, StartDelay: time.Second}
got, err := Backend{}.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
mustContain(t, got, "start_pre() {\n\tsleep 1\n}")
}
func TestGenerate_NoOrderNoExtras(t *testing.T) {
setup(t)
got, err := Backend{}.Generate(config.Unit{Name: "a", Runtime: "podman", Enabled: true}, "", "")
if err != nil {
t.Fatal(err)
}
if strings.Contains(got, "after ") || strings.Contains(got, "start_pre") {
t.Errorf("unexpected ordering output:\n%s", got)
}
}
+5 -5
View File
@@ -1,6 +1,6 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=unitdore
pkgver=0.0.6
pkgver=1.0.0
pkgrel=1
pkgdesc="A door you open and close for container units — manage containers via systemd"
arch=('x86_64' 'aarch64')
@@ -12,11 +12,11 @@ optdepends=(
'docker: Docker container runtime support'
)
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')
build() {
cd "$pkgname"
cd "$pkgname-$pkgver"
export CGO_ENABLED=0
go build \
-trimpath \
@@ -25,12 +25,12 @@ build() {
}
check() {
cd "$pkgname"
cd "$pkgname-$pkgver"
go test ./...
}
package() {
cd "$pkgname"
cd "$pkgname-$pkgver"
# Binary
install -Dm755 "$pkgname" "$pkgdir/usr/bin/$pkgname"
+1 -1
View File
@@ -1,5 +1,5 @@
Name: unitdore
Version: 0.0.6
Version: 1.0.0
Release: 1%{?dist}
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=1.0.0
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=UPDATE_AFTER_RELEASE
post_install() {
vman docs/unitdore.1
vlicense LICENSE
vmkdir etc/unitdore
}
+382
View File
@@ -0,0 +1,382 @@
// 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" + b.waitFor(u, prefix, suffix) + 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
}
// predecessorWait is how long run waits for each unit of the previous group.
const predecessorWait = 30
// waitFor holds the service until the previous order group is up, then sleeps
// the unit's delay. `sv check` never starts a service, and returns at once for
// one that is down or not installed.
func (b Backend) waitFor(u config.Unit, prefix, suffix string) string {
var s string
for _, n := range u.After {
link := b.link(config.Unit{Name: n}, prefix, suffix)
s += fmt.Sprintf("sv -w %d check %s >/dev/null 2>&1 || true\n", predecessorWait, service.ShellQuote(link))
}
if secs := int((u.StartDelay + time.Second - 1) / time.Second); secs > 0 {
s += fmt.Sprintf("sleep %d\n", secs)
}
return s
}
// 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
}
+324
View File
@@ -0,0 +1,324 @@
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)
}
}
}
func TestGenerate_OrderAndDelay(t *testing.T) {
b, _ := setup(t)
u := config.Unit{Name: "web", Runtime: "podman", Enabled: true, After: []string{"db"}, StartDelay: 3 * time.Second}
got, err := b.Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
link := filepath.Join(b.ServiceDir, "unitdore-db")
wait := "sv -w 30 check " + link + " >/dev/null 2>&1 || true\nsleep 3\n"
if !strings.Contains(got, wait) {
t.Errorf("missing %q in:\n%s", wait, got)
}
// waiting happens before the service command runs
if strings.Index(got, "sleep 3") > strings.Index(got, "exec /usr/bin/podman") {
t.Errorf("delay must precede exec:\n%s", got)
}
}
func TestGenerate_NoOrderNoExtras(t *testing.T) {
b, _ := setup(t)
got, err := b.Generate(config.Unit{Name: "a", Runtime: "podman", Enabled: true}, "", "")
if err != nil {
t.Fatal(err)
}
if strings.Contains(got, "sv -w") || strings.Contains(got, "sleep") {
t.Errorf("unexpected ordering output:\n%s", got)
}
}
+59 -7
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"os/exec"
"strings"
"github.com/warkanum/unitdore/sysuser"
)
type Docker struct{}
@@ -17,21 +19,44 @@ type dockerContainer struct {
Image string `json:"Image"`
Command string `json:"Command"`
State string `json:"State"`
Labels string `json:"Labels"`
}
func (d *Docker) ListRunning() ([]Container, error) {
// dockerCmd builds a docker command for the given user scope.
// Returns nil if docker is not installed.
func dockerCmd(user string, args ...string) (*exec.Cmd, error) {
bin, err := exec.LookPath("docker")
if err != nil {
return nil, nil
}
return sysuser.Command(user, bin, args...)
}
func (d *Docker) ListRunning(user string) ([]Container, error) {
cmd, err := dockerCmd(user, "ps", "--format", "json")
if err != nil {
return nil, err
}
if cmd == nil {
return nil, nil // docker not installed — not an error
}
out, err := exec.Command(bin, "ps", "--format", "json").Output()
out, err := cmd.Output()
if err != nil {
// Docker installed but daemon not running — treat as no containers
return nil, nil
}
// Docker outputs one JSON object per line (not a JSON array)
containers := parseDockerPS(out)
for i := range containers {
containers[i].User = user
}
return containers, nil
}
// parseDockerPS parses `docker ps --format json` output
// (one JSON object per line, not a JSON array).
func parseDockerPS(out []byte) []Container {
var containers []Container
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
@@ -57,18 +82,45 @@ func (d *Docker) ListRunning() ([]Container, error) {
Image: c.Image,
Command: c.Command,
Runtime: "docker",
Labels: parseDockerLabels(c.Labels),
})
}
return containers, nil
return containers
}
func (d *Docker) Exists(name string) (bool, error) {
bin, err := exec.LookPath("docker")
// parseDockerLabels parses docker's "k=v,k=v" label string. A segment without
// "=" is a continuation of the previous value (values may contain commas,
// e.g. multiple compose config files).
func parseDockerLabels(s string) map[string]string {
labels := map[string]string{}
last := ""
for _, part := range strings.Split(s, ",") {
if part == "" {
continue
}
k, v, ok := strings.Cut(part, "=")
if !ok {
if last != "" {
labels[last] += "," + part
}
continue
}
labels[k] = v
last = k
}
return labels
}
func (d *Docker) Exists(user, name string) (bool, error) {
cmd, err := dockerCmd(user, "ps", "--format", "{{.Names}}")
if err != nil {
return false, err
}
if cmd == nil {
return false, nil
}
out, err := exec.Command(bin, "ps", "--format", "{{.Names}}").Output()
out, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("docker ps: %w", err)
}
+65 -11
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"os/exec"
"strings"
"github.com/warkanum/unitdore/sysuser"
)
type Podman struct{}
@@ -12,28 +14,55 @@ type Podman struct{}
func (p *Podman) Name() string { return "podman" }
type podmanContainer struct {
ID string `json:"Id"`
Names []string `json:"Names"`
Image string `json:"Image"`
Command []string `json:"Command"`
State string `json:"State"`
ID string `json:"Id"`
Names []string `json:"Names"`
Image string `json:"Image"`
Command []string `json:"Command"`
State string `json:"State"`
Labels map[string]string `json:"Labels"`
}
func (p *Podman) ListRunning() ([]Container, error) {
// podmanCmd builds a podman command for the given user scope.
// Returns nil if podman is not installed.
func podmanCmd(user string, args ...string) (*exec.Cmd, error) {
bin, err := exec.LookPath("podman")
if err != nil {
return nil, nil
}
return sysuser.Command(user, bin, args...)
}
func (p *Podman) ListRunning(user string) ([]Container, error) {
cmd, err := podmanCmd(user, "ps", "--format", "json")
if err != nil {
return nil, err
}
if cmd == nil {
return nil, nil // podman not installed — not an error
}
out, err := exec.Command(bin, "ps", "--format", "json").Output()
out, err := cmd.Output()
if err != nil {
// Podman installed but not usable — treat as no containers
return nil, nil
}
containers, err := parsePodmanPS(out)
if err != nil {
// Podman installed but output is not valid JSON (e.g. OCI runtime misconfigured)
return nil, nil
}
for i := range containers {
containers[i].User = user
}
return containers, nil
}
// parsePodmanPS parses `podman ps --format json` output (a JSON array).
func parsePodmanPS(out []byte) ([]Container, error) {
var raw []podmanContainer
if err := json.Unmarshal(out, &raw); err != nil {
return nil, fmt.Errorf("parsing podman output: %w", err)
return nil, err
}
var containers []Container
@@ -57,18 +86,22 @@ func (p *Podman) ListRunning() ([]Container, error) {
Image: c.Image,
Command: strings.Join(c.Command, " "),
Runtime: "podman",
Labels: c.Labels,
})
}
return containers, nil
}
func (p *Podman) Exists(name string) (bool, error) {
bin, err := exec.LookPath("podman")
func (p *Podman) Exists(user, name string) (bool, error) {
cmd, err := podmanCmd(user, "ps", "--format", "{{.Names}}")
if err != nil {
return false, err
}
if cmd == nil {
return false, nil
}
out, err := exec.Command(bin, "ps", "--format", "{{.Names}}").Output()
out, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("podman ps: %w", err)
}
@@ -80,3 +113,24 @@ func (p *Podman) Exists(name string) (bool, error) {
}
return false, nil
}
// PodExists reports whether a pod with the given name exists (any state).
func (p *Podman) PodExists(user, pod string) (bool, error) {
cmd, err := podmanCmd(user, "pod", "exists", pod)
if err != nil {
return false, err
}
if cmd == nil {
return false, nil
}
if err := cmd.Run(); err != nil {
if _, ok := err.(*exec.ExitError); ok {
return false, nil // exit 1 = pod does not exist
}
return false, fmt.Errorf("podman pod exists: %w", err)
}
return true, nil
}
// PodName returns the pod name podman-compose uses for a project.
func PodName(project string) string { return "pod_" + project }
+45 -3
View File
@@ -1,21 +1,63 @@
package runtime
import (
"path/filepath"
"strings"
)
// Compose labels set by podman-compose on every container it creates.
const (
LabelPodmanComposeProject = "io.podman.compose.project"
LabelComposeWorkingDir = "com.docker.compose.project.working_dir"
LabelComposeConfigFiles = "com.docker.compose.project.config_files"
)
// Container represents a discovered running container.
type Container struct {
Name string
Image string
Command string
Runtime string // "podman" or "docker"
User string // empty = root/system scope
Labels map[string]string
}
// Project returns the podman-compose project the container belongs to, or "".
func (c Container) Project() string {
return c.Labels[LabelPodmanComposeProject]
}
// WorkingDir returns the compose project working directory, or "".
func (c Container) WorkingDir() string {
return c.Labels[LabelComposeWorkingDir]
}
// ComposeFiles returns the compose files used to create the container.
// Relative paths are relative to WorkingDir.
func (c Container) ComposeFiles() []string {
raw := c.Labels[LabelComposeConfigFiles]
if raw == "" {
return nil
}
var files []string
for _, f := range strings.Split(raw, ",") {
if f = strings.TrimSpace(f); f != "" {
files = append(files, filepath.Clean(f))
}
}
return files
}
// Runtime is the interface all container runtimes must implement.
// user selects the scope: "" = root/system, otherwise a rootless user.
type Runtime interface {
Name() string
ListRunning() ([]Container, error)
Exists(name string) (bool, error)
ListRunning(user string) ([]Container, error)
Exists(user, name string) (bool, error)
}
// Available returns all supported runtimes (callers skip those with no binary).
// Available returns all supported runtimes in priority order: podman first,
// docker as fallback (callers skip those with no binary).
func Available() []Runtime {
return []Runtime{&Podman{}, &Docker{}}
}
+80 -2
View File
@@ -1,6 +1,8 @@
package runtime
import (
"os"
"reflect"
"testing"
)
@@ -53,7 +55,7 @@ func TestAvailable_ReturnsRuntimes(t *testing.T) {
func TestPodman_ListRunning_NoBinary(t *testing.T) {
// This test is meaningful if podman isn't installed; if it is, we just check no error occurs
p := &Podman{}
containers, err := p.ListRunning()
containers, err := p.ListRunning("")
if err != nil {
t.Errorf("ListRunning() should not error when podman is absent: %v", err)
}
@@ -63,9 +65,85 @@ func TestPodman_ListRunning_NoBinary(t *testing.T) {
func TestDocker_ListRunning_NoBinary(t *testing.T) {
d := &Docker{}
containers, err := d.ListRunning()
containers, err := d.ListRunning("")
if err != nil {
t.Errorf("ListRunning() should not error when docker is absent: %v", err)
}
_ = containers
}
func TestAvailable_PodmanFirst(t *testing.T) {
runtimes := Available()
if len(runtimes) < 2 || runtimes[0].Name() != "podman" || runtimes[1].Name() != "docker" {
t.Errorf("Available() order must be podman, docker; got %v", runtimes)
}
}
// Fixture captured from `podman-compose up -d` of testdata/stack/compose.yaml.
func TestParsePodmanPS_ComposeStack(t *testing.T) {
data, err := os.ReadFile("../testdata/stack/podman_ps.json")
if err != nil {
t.Fatalf("reading fixture: %v", err)
}
containers, err := parsePodmanPS(data)
if err != nil {
t.Fatalf("parsePodmanPS() error: %v", err)
}
if len(containers) != 2 {
t.Fatalf("expected 2 containers, got %d", len(containers))
}
for _, c := range containers {
if c.Project() != "unitdore-test" {
t.Errorf("%s: Project() = %q, want unitdore-test", c.Name, c.Project())
}
if c.WorkingDir() == "" {
t.Errorf("%s: WorkingDir() empty", c.Name)
}
if !reflect.DeepEqual(c.ComposeFiles(), []string{"compose.yaml"}) {
t.Errorf("%s: ComposeFiles() = %v", c.Name, c.ComposeFiles())
}
}
}
func TestContainer_NoProject(t *testing.T) {
c := Container{Name: "plain"}
if c.Project() != "" || c.WorkingDir() != "" || c.ComposeFiles() != nil {
t.Errorf("container without labels should have no stack info: %+v", c)
}
}
func TestContainer_ComposeFilesMultiple(t *testing.T) {
c := Container{Labels: map[string]string{LabelComposeConfigFiles: "compose.yaml, ./override.yaml"}}
want := []string{"compose.yaml", "override.yaml"}
if got := c.ComposeFiles(); !reflect.DeepEqual(got, want) {
t.Errorf("ComposeFiles() = %v, want %v", got, want)
}
}
func TestParseDockerPS_Labels(t *testing.T) {
out := []byte(`{"ID":"abc","Names":"web","Image":"nginx","Command":"nginx","State":"running","Labels":"com.docker.compose.project=shop,com.docker.compose.project.config_files=/s/a.yml,/s/b.yml,com.docker.compose.service=web"}`)
cs := parseDockerPS(out)
if len(cs) != 1 {
t.Fatalf("expected 1 container, got %d", len(cs))
}
l := cs[0].Labels
if l["com.docker.compose.project"] != "shop" {
t.Errorf("project label = %q", l["com.docker.compose.project"])
}
if l["com.docker.compose.project.config_files"] != "/s/a.yml,/s/b.yml" {
t.Errorf("config_files label = %q", l["com.docker.compose.project.config_files"])
}
if l["com.docker.compose.service"] != "web" {
t.Errorf("service label = %q", l["com.docker.compose.service"])
}
// Docker compose stacks are not grouped yet (podman-compose only)
if cs[0].Project() != "" {
t.Errorf("docker compose container should not report a podman-compose project")
}
}
func TestPodName(t *testing.T) {
if PodName("shop") != "pod_shop" {
t.Errorf("PodName() = %q", PodName("shop"))
}
}
+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 }
+66 -34
View File
@@ -4,24 +4,41 @@ import (
"fmt"
"strings"
"text/template"
"time"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/service"
)
const systemUnitTemplate = `# /etc/systemd/system/{{.ServiceName}}
# Generated by unitdore — do not edit manually
[Unit]
Description=Unitdore: {{.Unit.Name}} ({{.Unit.Runtime}})
After=network.target{{with .RuntimeService}} {{.}}
Description=Unitdore: {{.Unit.Name}} ({{.Label}})
After=network.target{{with .RuntimeService}} {{.}}{{end}}{{range .After}} {{.}}{{end}}{{with .RuntimeService}}
Requires={{.}}{{end}}
[Service]
Type=simple
Type={{.ServiceType}}
{{- if eq .ServiceType "oneshot"}}
RemainAfterExit=yes
{{- end}}
{{- with .WorkingDir}}
WorkingDirectory={{.}}
{{- end}}
{{- with .DelaySec}}
ExecStartPre=/usr/bin/env sleep {{.}}
TimeoutStartSec={{$.StartTimeout}}
{{- end}}
ExecStart={{.ExecStart}}
ExecStop={{.ExecStop}}
ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}}
{{- if .Unit.Restart}}
Restart=on-failure
RestartSec=5
RestartSec={{.Unit.RestartSec}}
{{- if gt .Unit.RestartRetries 0}}
StartLimitBurst={{.Unit.RestartRetries}}
{{- end}}
{{- end}}
[Install]
WantedBy=multi-user.target
@@ -31,15 +48,30 @@ const userUnitTemplate = `# ~/.config/systemd/user/{{.ServiceName}}
# Generated by unitdore — do not edit manually
[Unit]
Description=Unitdore: {{.Unit.Name}} ({{.Unit.Runtime}})
After=default.target
Description=Unitdore: {{.Unit.Name}} ({{.Label}})
After=default.target{{range .After}} {{.}}{{end}}
[Service]
Type=simple
Type={{.ServiceType}}
{{- if eq .ServiceType "oneshot"}}
RemainAfterExit=yes
{{- end}}
{{- with .WorkingDir}}
WorkingDirectory={{.}}
{{- end}}
{{- with .DelaySec}}
ExecStartPre=/usr/bin/env sleep {{.}}
TimeoutStartSec={{$.StartTimeout}}
{{- end}}
ExecStart={{.ExecStart}}
ExecStop={{.ExecStop}}
ExecStop={{if .ExecStop}}-{{.ExecStop}}{{end}}
{{- if .Unit.Restart}}
Restart=on-failure
RestartSec=5
RestartSec={{.Unit.RestartSec}}
{{- if gt .Unit.RestartRetries 0}}
StartLimitBurst={{.Unit.RestartRetries}}
{{- end}}
{{- end}}
[Install]
WantedBy=default.target
@@ -51,23 +83,43 @@ type templateData struct {
ExecStart string
ExecStop string
RuntimeService string
Label string // shown in Description
ServiceType string // simple | oneshot
WorkingDir string
After []string // service names of the previous order group
DelaySec int // ExecStartPre sleep, from unit delay
StartTimeout int // TimeoutStartSec when a delay is set
}
// ServiceName returns the systemd service name for a unit, with optional prefix/suffix.
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.
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{
ServiceName: ServiceName(u, prefix, suffix),
Unit: u,
ExecStart: execStart,
ExecStop: execStop,
ExecStart: spec.Start,
ExecStop: spec.Stop,
RuntimeService: runtimeService(u.Runtime),
Label: spec.Label,
ServiceType: spec.Type,
WorkingDir: spec.WorkingDir,
}
for _, n := range u.After {
data.After = append(data.After, ServiceName(config.Unit{Name: n}, prefix, suffix))
}
if u.StartDelay > 0 {
data.DelaySec = int((u.StartDelay + time.Second - 1) / time.Second)
data.StartTimeout = data.DelaySec + 90
}
tmplStr := systemUnitTemplate
@@ -95,23 +147,3 @@ func runtimeService(runtime string) string {
}
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 %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
}
+304 -41
View File
@@ -1,8 +1,11 @@
package systemd
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/warkanum/unitdore/config"
)
@@ -49,8 +52,7 @@ func TestGenerate_SystemUnit(t *testing.T) {
"After=network.target",
"WantedBy=multi-user.target",
"ExecStart=/usr/bin/podman start -a nginx",
"ExecStop=/usr/bin/podman stop nginx",
"Restart=on-failure",
"ExecStop=-/usr/bin/podman stop nginx",
"Generated by unitdore",
}
@@ -66,6 +68,9 @@ func TestGenerate_SystemUnit(t *testing.T) {
if strings.Contains(content, "Requires=") {
t.Errorf("Generate() podman system unit should not contain Requires= (podman is daemonless):\n%s", content)
}
if strings.Contains(content, "Restart=") {
t.Errorf("Generate() restart should be disabled by default:\n%s", content)
}
}
func TestGenerate_UserUnit(t *testing.T) {
@@ -85,8 +90,8 @@ func TestGenerate_UserUnit(t *testing.T) {
checks := []string{
"After=default.target",
"WantedBy=default.target",
"ExecStart=/usr/bin/docker start myapp",
"ExecStop=/usr/bin/docker stop myapp",
"ExecStart=/usr/bin/docker start -a myapp",
"ExecStop=-/usr/bin/docker stop myapp",
}
for _, check := range checks {
@@ -100,6 +105,51 @@ func TestGenerate_UserUnit(t *testing.T) {
}
}
func TestGenerate_WithRestart(t *testing.T) {
t.Run("restart with retries", func(t *testing.T) {
u := config.Unit{
Name: "nginx",
Runtime: "podman",
Enabled: true,
Restart: true,
RestartSec: 5,
RestartRetries: 3,
}
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
for _, want := range []string{"Restart=on-failure", "RestartSec=5", "StartLimitBurst=3"} {
if !strings.Contains(content, want) {
t.Errorf("Generate() missing %q in output:\n%s", want, content)
}
}
})
t.Run("restart without retries", func(t *testing.T) {
u := config.Unit{
Name: "nginx",
Runtime: "podman",
Enabled: true,
Restart: true,
RestartSec: 10,
}
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
if !strings.Contains(content, "Restart=on-failure") {
t.Errorf("Generate() missing Restart=on-failure:\n%s", content)
}
if !strings.Contains(content, "RestartSec=10") {
t.Errorf("Generate() missing RestartSec=10:\n%s", content)
}
if strings.Contains(content, "StartLimitBurst") {
t.Errorf("Generate() should not contain StartLimitBurst when retries=0:\n%s", content)
}
})
}
func TestGenerate_CustomCommand(t *testing.T) {
u := config.Unit{
Name: "custom",
@@ -131,8 +181,8 @@ func TestGenerate_DockerRuntime(t *testing.T) {
}
checks := []string{
"ExecStart=/usr/bin/docker start redis",
"ExecStop=/usr/bin/docker stop redis",
"ExecStart=/usr/bin/docker start -a redis",
"ExecStop=-/usr/bin/docker stop redis",
"After=network.target docker.service",
"Requires=docker.service",
}
@@ -177,42 +227,255 @@ func TestGenerate_WithPrefixSuffix(t *testing.T) {
}
}
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 app",
wantStop: "/usr/bin/docker stop app",
},
{
name: "custom command",
unit: config.Unit{Name: "app", Runtime: "podman", Command: "/bin/custom"},
wantStart: "/bin/custom",
wantStop: "",
},
// 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,
}
}
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 TestGenerate_StackCompose(t *testing.T) {
u := newComposeStack(t)
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
base := u.ComposeBin + " -p shop -f compose.yaml"
for _, want := range []string{
"Description=Unitdore: shop (podman-compose stack)",
"Type=simple",
"WorkingDirectory=" + u.WorkingDir,
"ExecStart=" + base + " up",
"ExecStop=-" + base + " stop",
"WantedBy=multi-user.target",
} {
if !strings.Contains(content, want) {
t.Errorf("Generate() missing %q in output:\n%s", want, content)
}
}
if strings.Contains(content, "RemainAfterExit") || strings.Contains(content, "Requires=") {
t.Errorf("compose stack unit has unexpected directives:\n%s", content)
}
}
func TestGenerate_StackPodFallback(t *testing.T) {
u := newComposeStack(t)
u.ComposeBin = ""
u.User = "hein"
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
for _, want := range []string{
"Description=Unitdore: shop (podman pod)",
"Type=oneshot",
"RemainAfterExit=yes",
"ExecStart=/usr/bin/podman pod start pod_shop",
"ExecStop=-/usr/bin/podman pod stop pod_shop",
"WantedBy=default.target",
} {
if !strings.Contains(content, want) {
t.Errorf("Generate() missing %q in output:\n%s", want, content)
}
}
if strings.Contains(content, "WorkingDirectory=") {
t.Errorf("pod fallback should not set WorkingDirectory:\n%s", content)
}
}
func TestGenerate_StackCustomCommand(t *testing.T) {
u := newComposeStack(t)
u.Command = "/usr/local/bin/stack.sh"
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
if !strings.Contains(content, "ExecStart=/usr/local/bin/stack.sh") {
t.Errorf("custom command should override stack commands:\n%s", content)
}
}
func 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")
}
}
func TestGenerate_OrderAndDelay(t *testing.T) {
u := config.Unit{Name: "web", Runtime: "podman", Enabled: true, After: []string{"db", "cache"}, StartDelay: 1500 * time.Millisecond}
content, err := Generate(u, "p-", "")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"After=network.target unitdore-p-db.service unitdore-p-cache.service\n",
"ExecStartPre=/usr/bin/env sleep 2\n",
"TimeoutStartSec=92\n",
} {
if !strings.Contains(content, want) {
t.Errorf("missing %q in:\n%s", want, content)
}
}
}
func TestGenerate_UserUnitOrder(t *testing.T) {
u := config.Unit{Name: "web", Runtime: "podman", User: "hein", Enabled: true, After: []string{"db"}}
content, err := Generate(u, "", "")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(content, "After=default.target unitdore-db.service\n") {
t.Errorf("user unit missing ordering:\n%s", content)
}
if strings.Contains(content, "ExecStartPre") {
t.Errorf("no delay expected:\n%s", content)
}
}
func TestGenerate_NoOrderNoExtras(t *testing.T) {
content, err := Generate(config.Unit{Name: "a", Runtime: "podman", Enabled: true}, "", "")
if err != nil {
t.Fatal(err)
}
if strings.Contains(content, "ExecStartPre") || strings.Contains(content, "TimeoutStartSec") || !strings.Contains(content, "After=network.target\n") {
t.Errorf("unexpected ordering output:\n%s", content)
}
}
+11 -18
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/sysuser"
)
const systemUnitDir = "/etc/systemd/system"
@@ -19,7 +20,7 @@ func UnitPath(u config.Unit, prefix, suffix string) (string, error) {
return filepath.Join(systemUnitDir, svcName), nil
}
// Rootless: write to the user's systemd config dir
home, err := userHomeDir(u.User)
home, err := sysuser.HomeDir(u.User)
if err != nil {
return "", err
}
@@ -130,27 +131,19 @@ func systemctl(user string, args ...string) error {
}
func runAsUser(user string, bin string, args ...string) ([]byte, error) {
full := append([]string{"-u", user, "--", bin}, args...)
return exec.Command("runuser", full...).Output()
cmd, err := sysuser.Command(user, bin, args...)
if err != nil {
return nil, err
}
return cmd.Output()
}
func runAsUserCmd(user string, bin string, args ...string) error {
full := append([]string{"-u", user, "--", bin}, args...)
cmd := exec.Command("runuser", full...)
cmd, err := sysuser.Command(user, bin, args...)
if err != nil {
return err
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func userHomeDir(user string) (string, error) {
out, err := exec.Command("getent", "passwd", user).Output()
if err != nil {
return "", fmt.Errorf("looking up user %s: %w", user, err)
}
// passwd format: name:pass:uid:gid:gecos:home:shell
parts := strings.Split(strings.TrimSpace(string(out)), ":")
if len(parts) < 6 {
return "", fmt.Errorf("unexpected passwd entry for %s", user)
}
return parts[5], nil
}
+98
View File
@@ -0,0 +1,98 @@
package systemd
import (
"sort"
"testing"
"github.com/warkanum/unitdore/config"
)
// TestStartAll_SortsUnitsInStartupOrder verifies that startall sorts by Order asc + Name.
func TestStartAll_SortsUnitsInStartupOrder(t *testing.T) {
u1 := config.Unit{Name: "app2", Runtime: "podman", Order: 5, Enabled: true}
u2 := config.Unit{Name: "app1", Runtime: "podman", Order: 1, Enabled: true}
u3 := config.Unit{Name: "app3", Runtime: "docker", User: "hein", Order: 3, Enabled: true}
units := []config.Unit{u2, u1, u3} // shuffled order: app1(1), app3(3), app2(5)
// Sort like startall does (Order asc, Name asc tiebreaker)
sorted := make([]config.Unit, len(units))
copy(sorted, units)
sort.Slice(sorted, func(i, j int) bool {
if sorted[i].Order != sorted[j].Order {
return sorted[i].Order < sorted[j].Order
}
return sorted[i].Name < sorted[j].Name
})
// Expected order: app1(1), app3(3), app2(5) by Order asc
if sorted[0].Name != "app1" {
t.Errorf("expected first unit to be app1 (order 1), got %s", sorted[0].Name)
}
if sorted[1].Name != "app3" {
t.Errorf("expected second unit to be app3 (order 3), got %s", sorted[1].Name)
}
if sorted[2].Name != "app2" {
t.Errorf("expected third unit to be app2 (order 5), got %s", sorted[2].Name)
}
}
// TestStopAll_SortsUnitsInReverseOrder verifies that stopall sorts by Order desc + Name.
func TestStopAll_SortsUnitsInReverseOrder(t *testing.T) {
u1 := config.Unit{Name: "app2", Runtime: "podman", Order: 5, Enabled: true}
u2 := config.Unit{Name: "app1", Runtime: "podman", Order: 1, Enabled: true}
u3 := config.Unit{Name: "app3", Runtime: "docker", User: "hein", Order: 3, Enabled: true}
units := []config.Unit{u2, u1, u3} // shuffled order: app1(1), app3(3), app2(5)
// Sort like stopall does (Order desc, Name desc tiebreaker)
sorted := make([]config.Unit, len(units))
copy(sorted, units)
sort.Slice(sorted, func(i, j int) bool {
if sorted[i].Order != sorted[j].Order {
return sorted[i].Order > sorted[j].Order
}
return sorted[i].Name > sorted[j].Name
})
// Expected order: app2(5), app3(3), app1(1) by Order desc
if sorted[0].Name != "app2" {
t.Errorf("expected first unit to be app2 (order 5), got %s", sorted[0].Name)
}
if sorted[1].Name != "app3" {
t.Errorf("expected second unit to be app3 (order 3), got %s", sorted[1].Name)
}
if sorted[2].Name != "app1" {
t.Errorf("expected third unit to be app1 (order 1), got %s", sorted[2].Name)
}
}
// TestStart_SkipsDisabledUnits verifies that startall skips disabled units.
func TestStart_SkipsDisabledUnits(t *testing.T) {
u1 := config.Unit{Name: "app1", Runtime: "podman", Order: 1, Enabled: true}
u2 := config.Unit{Name: "app2", Runtime: "podman", Order: 2, Enabled: false}
units := []config.Unit{u1, u2}
enabledCount := 0
for _, u := range units {
if !u.Enabled {
continue // skip disabled like startall does
}
enabledCount++
}
if enabledCount != 1 {
t.Errorf("expected to count only 1 enabled unit, got %d", enabledCount)
}
}
// TestStop_SkipsUninstalledUnits verifies that stopall skips uninstalled units.
func TestStop_SkipsUninstalledUnits(t *testing.T) {
u := config.Unit{Name: "app1", Runtime: "podman", Order: 1, Enabled: true}
units := []config.Unit{u}
for _, unit := range units {
if !IsInstalled(unit, "", "") {
continue // skip uninstalled like stopall does
}
}
t.Log("uninstalled units are skipped (no service files exist in test env)")
}
+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