Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f51386e2c | ||
|
|
acd7c2f699 | ||
|
|
9a3ab1b91b | ||
|
|
91811b4fa1 | ||
|
|
d1d0889eb3 | ||
|
|
bc85901287 | ||
|
|
df9b226c7e | ||
|
|
95143dd998 | ||
|
|
173b4b2523 | ||
|
|
cb4413b840 | ||
|
|
f0827fcdd7 | ||
|
|
cc4829046d | ||
|
|
13817f8d34 | ||
|
|
5026ae4d16 | ||
|
|
917480670f | ||
|
|
029b9cee2e | ||
|
|
e6ef6e11d6 | ||
|
|
f9dcb0b561 | ||
|
|
69069a2196 | ||
|
|
2efccc5d4f | ||
|
|
b58373ad2f | ||
|
|
a273232303 | ||
|
|
fae49a258c |
@@ -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
@@ -1,3 +1,3 @@
|
||||
unitdore
|
||||
/unitdore
|
||||
*.exe
|
||||
.codex
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
\_____________/
|
||||
\___________/
|
||||
@@ -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
|
||||
|
||||
@@ -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/## / /'
|
||||
|
||||
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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,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}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+23
@@ -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
|
||||
Executable
+13
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Vendored
+15
@@ -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"]
|
||||
Vendored
+100
@@ -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
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user