27 Commits
Author SHA1 Message Date
warkanum 173b4b2523 chore(release): update package version to 0.0.11
Release / test (push) Successful in 30s
Release / release (push) Successful in 47s
Release / pkg-aur (push) Successful in 37s
Release / pkg-rpm (push) Successful in 45s
Release / pkg-deb (push) Failing after 21s
2026-10-12 19:08:06 +02:00
warkanum cb4413b840 Merge branch 'main' of git.warky.dev:wdevs/unitdore 2026-10-12 19:07:38 +02:00
warkanum f0827fcdd7 feat: podman-compose stack units and root/user execution scopes
- syncup: podman first, docker only as fallback (podman wins on name clash;
  existing docker units found in podman switch to podman)
- syncup: group podman-compose containers into one `kind: stack` unit per
  project (project, working_dir, compose_files, compose_bin from labels);
  disable existing member units ("part of stack <project>")
- generator: compose mode (podman-compose up/stop, WorkingDirectory) with
  pod start/stop fallback when compose binary/dir/files are missing
- reconcile: stacks disabled with "stack not found" when neither compose
  files nor pod exist
- root: scan root + rootless users listed in `users:` (podman only)
- non-root: config ~/.config/unitdore/units.yaml, own user units only
- sysuser package: runuser with XDG_RUNTIME_DIR/DBUS env, home/linger lookup
- install: warn when linger is not enabled for user units
- testdata/stack: podman-compose test stack and captured podman ps fixture
- docs: README, generated-units, man page, AGENTS, PLAN
2026-10-12 19:05:51 +02:00
warkanum cc4829046d ci(release): attach source archives to release and use them for AUR package
Gitea's /archive/ endpoint is disabled, which broke the AUR source download.
Build source tar.gz/zip with git archive, upload as release assets, and point
the PKGBUILD and checksum step at the release asset.
2026-10-12 18:27:53 +02:00
warkanum 13817f8d34 Merge pull request 'issue-5: regression tests for start/stop/startall/stopall CLI semantics' (#6) from issue-5-targeted-start-stop into main
Reviewed-on: #6
2026-08-20 08:39:13 +00:00
Hermes Agent 5026ae4d16 feat(cmd,systemd): add focused regression tests for start/stop/startall/stopall
Add cmd/start_test.go and systemd/start_stop_test.go with regression
tests covering:

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

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

Run: make test
2026-07-16 22:26:11 +02:00
warkanum 917480670f chore(release): update package version to 0.0.10
Release / test (push) Successful in 36s
Release / release (push) Successful in 44s
Release / pkg-deb (push) Successful in 38s
Release / pkg-rpm (push) Successful in 48s
Release / pkg-aur (push) Successful in 56s
2026-07-05 15:12:36 +02:00
warkanum 029b9cee2e feat(cmd): add installall/uninstallall; fix startall/stopall and service lifecycle
- startall now uses systemctl start (not enable --now)
- stopall now uses systemctl stop (not disable --now)
- add installall command (bulk install with --dry-run support)
- add uninstallall command (bulk disable+remove in reverse order)
- fix docker ExecStart to use -a flag so the process stays attached
- prefix ExecStop with - so a stop-on-already-dead container does not fail the unit
2026-07-05 15:12:18 +02:00
warkanum e6ef6e11d6 chore(release): update package version to 0.0.9
Release / test (push) Successful in -33m2s
Release / release (push) Successful in -32m47s
Release / pkg-aur (push) Successful in -33m46s
Release / pkg-deb (push) Successful in -33m19s
Release / pkg-rpm (push) Successful in -32m33s
2026-04-12 11:08:47 +02:00
warkanum f9dcb0b561 Merge branch 'main' of git.warky.dev:wdevs/unitdore 2026-04-12 11:08:38 +02:00
warkanum 69069a2196 fix(podman): handle invalid JSON output gracefully 2026-04-12 11:08:20 +02:00
Hein 2efccc5d4f chore(release): update package version to 0.0.8
Release / test (push) Successful in -30m10s
Release / release (push) Successful in -30m3s
Release / pkg-aur (push) Successful in -29m57s
Release / pkg-rpm (push) Failing after 14m44s
Release / pkg-deb (push) Failing after 14m51s
2026-04-10 09:38:59 +02:00
Hein b58373ad2f feat(config): add restart options for unit configuration
* include Restart, RestartSec, and RestartRetries fields
* update service file generation to support restart settings
* add tests for restart behavior in unit generation
2026-04-10 09:38:45 +02:00
warkanum a273232303 chore(release): update package version to 0.0.7
Release / release (push) Successful in -30m29s
Release / pkg-aur (push) Successful in -30m19s
Release / test (push) Successful in -30m43s
Release / pkg-deb (push) Successful in -30m29s
Release / pkg-rpm (push) Successful in -29m17s
2026-04-08 19:59:11 +02:00
warkanum fae49a258c ci(release): improve RPM upload logic in release workflow 2026-04-08 19:59:00 +02:00
warkanum 00bc6ed893 chore(release): update package version to 0.0.6
Release / test (push) Successful in -30m39s
Release / release (push) Successful in -30m31s
Release / pkg-deb (push) Successful in -30m33s
Release / pkg-aur (push) Successful in -29m41s
Release / pkg-rpm (push) Failing after -29m26s
2026-04-08 19:48:22 +02:00
warkanum b44546dc24 docs(README): update installation instructions for various distributions 2026-04-08 19:48:07 +02:00
Hein c6198ea6b7 chore(release): update package version to 0.0.4 and fix build paths
Release / pkg-deb (push) Successful in -30m35s
Release / pkg-aur (push) Successful in -30m17s
Release / pkg-rpm (push) Failing after -29m39s
Release / test (push) Successful in -30m39s
Release / release (push) Successful in -30m33s
* Adjust pkgver in PKGBUILD to 0.0.4
* Update build paths in PKGBUILD for consistency
* Enhance AUR package versioning logic in release workflow
2026-04-08 17:50:15 +02:00
Hein 9ef31866f1 chore(release): update curl installation in Rocky 9 image 2026-04-08 17:35:12 +02:00
Hein 77b86dc3fc chore(aur): improve AUR SSH key handling logic
Release / test (push) Successful in -30m41s
Release / pkg-deb (push) Successful in -30m32s
Release / release (push) Successful in -30m33s
Release / pkg-rpm (push) Failing after -30m32s
Release / pkg-aur (push) Successful in -30m34s
2026-04-08 17:19:13 +02:00
Hein 0999303cd3 chore(aur): enhance AUR SSH setup for key handling
* Improve SSH key handling with support for raw, escaped, and base64-encoded keys
* Add validation for AUR_SSH_KEY to ensure it's a valid private key
* Update SSH command options for better security and reliability
2026-04-08 17:02:10 +02:00
Hein 384c4592d1 chore(release): remove key content diagnostics from AUR SSH setup 2026-04-08 16:39:06 +02:00
Hein 815bdfed80 chore(release): enhance AUR SSH setup for key handling
* Improve SSH key setup by auto-detecting key format
* Add diagnostics for key validation and size
2026-04-08 15:41:35 +02:00
Hein 243da39fe3 chore(release): update AUR SSH setup to use base64 decoding 2026-04-08 15:29:21 +02:00
Hein 0a1e768dfe chore(release): remove Arch package build steps from workflow 2026-04-08 15:23:10 +02:00
Hein e66f869752 chore(release): update source archive format and URLs
Release / test (push) Successful in -30m28s
Release / release (push) Successful in -29m36s
Release / pkg-aur (push) Failing after -31m0s
Release / pkg-arch (push) Failing after -30m36s
Release / pkg-deb (push) Successful in -30m6s
Release / pkg-rpm (push) Failing after -30m27s
* Change source archive from tar.gz to zip for Arch packaging
* Update URLs in PKGBUILD and spec files to point to the correct repository
2026-04-08 15:09:33 +02:00
Hein 2c0f51422e chore(release): update Go version and build requirements
* Change Go version to 1.23.0 in go.mod
* Update golang build requirement to >= 1.23 in unitdore.spec
* Adjust packaging commands for Arch and RPM
2026-04-08 14:51:57 +02:00
42 changed files with 2193 additions and 261 deletions
+136 -56
View File
@@ -48,11 +48,18 @@ jobs:
[ "$GOOS" = "windows" ] && EXT=".exe"
NAME="unitdore-${GOOS}-${GOARCH}${EXT}"
GOOS="$GOOS" GOARCH="$GOARCH" go build \
-ldflags "-X main.version=${VERSION}" \
-ldflags "-X github.com/warkanum/unitdore/cmd.version=${VERSION}" \
-o "$NAME" .
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 }}"
@@ -92,61 +99,108 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
pkg-arch:
pkg-aur:
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build Arch package
- name: Publish to AUR
env:
AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }}
run: |
set -euo pipefail
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
PKGVER="${VERSION#v}"
AUR_KEY_PATH="$HOME/.ssh/aur"
AUR_KNOWN_HOSTS="$HOME/.ssh/known_hosts"
# Source tarball — prefix=unitdore/ matches `cd "$pkgname"` in PKGBUILD
git archive --format=tar.gz --prefix=unitdore/ HEAD \
> pkg/arch/unitdore-${PKGVER}.tar.gz
SHA=$(sha256sum pkg/arch/unitdore-${PKGVER}.tar.gz | cut -d' ' -f1)
# Setup SSH for AUR
mkdir -p ~/.ssh
chmod 700 ~/.ssh
# Patch PKGBUILD for local build
sed -i \
-e "s/^pkgver=.*/pkgver=${PKGVER}/" \
-e "s/^sha256sums=.*/sha256sums=('${SHA}')/" \
-e "s|source=.*|source=(\"unitdore-\${pkgver}.tar.gz\")|" \
pkg/arch/PKGBUILD
if [ -z "${AUR_SSH_KEY:-}" ]; then
echo "AUR_SSH_KEY is empty"
exit 1
fi
mkdir -p pkg/arch/out
docker run --rm \
-v "$PWD/pkg/arch:/build" \
-v "$PWD/pkg/arch/out:/out" \
-w /build \
archlinux:latest \
bash -c "
pacman -Syu --noconfirm base-devel go &&
useradd -m builder &&
chown -R builder:builder /build &&
runuser -u builder -- makepkg --noconfirm --noprogressbar &&
cp /build/*.pkg.tar.zst /out/
"
# Support raw multiline keys, escaped \\n secrets, or base64-encoded keys.
CLEAN_AUR_SSH_KEY="$(printf '%s' "$AUR_SSH_KEY" | tr -d '\r')"
if printf '%s' "$CLEAN_AUR_SSH_KEY" | grep -q "^-----BEGIN .*PRIVATE KEY-----$"; then
printf '%s\n' "$CLEAN_AUR_SSH_KEY" > "$AUR_KEY_PATH"
elif printf '%s' "$CLEAN_AUR_SSH_KEY" | grep -q '\\n'; then
printf '%b\n' "$CLEAN_AUR_SSH_KEY" > "$AUR_KEY_PATH"
else
if printf '%s' "$CLEAN_AUR_SSH_KEY" | tr -d '[:space:]' | base64 --decode > "$AUR_KEY_PATH" 2>/dev/null; then
:
else
printf '%s\n' "$CLEAN_AUR_SSH_KEY" > "$AUR_KEY_PATH"
fi
fi
chmod 600 "$AUR_KEY_PATH"
- name: Upload to release
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
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/arch/out/*.pkg.tar.zst; 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
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
if ! ssh-keygen -y -f "$AUR_KEY_PATH" >/dev/null 2>&1; then
echo "AUR_SSH_KEY is not a valid private key."
echo "Store it as a raw private key, an escaped private key with \\n, or a base64-encoded private key."
exit 1
fi
ssh-keyscan -t rsa,ed25519 aur.archlinux.org >> "$AUR_KNOWN_HOSTS"
chmod 644 "$AUR_KNOWN_HOSTS"
# Clone AUR repo
GIT_SSH_COMMAND="ssh -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$AUR_KNOWN_HOSTS -i $AUR_KEY_PATH" \
git clone ssh://aur@aur.archlinux.org/unitdore.git aur-repo
CURRENT_PKGVER=$(awk -F= '/^pkgver=/ {print $2; exit}' aur-repo/PKGBUILD | tr -d "[:space:]")
CURRENT_PKGREL=$(awk -F= '/^pkgrel=/ {print $2; exit}' aur-repo/PKGBUILD | tr -d "[:space:]")
if [ "$CURRENT_PKGVER" = "$PKGVER" ]; then
case "$CURRENT_PKGREL" in
''|*[!0-9]*)
echo "Unsupported pkgrel in AUR repo: ${CURRENT_PKGREL}"
exit 1
;;
*)
PKGREL=$((CURRENT_PKGREL + 1))
;;
esac
else
PKGREL=1
fi
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/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}/" \
-e "s/^pkgrel=.*/pkgrel=${PKGREL}/" \
-e "s/^sha256sums=.*/sha256sums=('${SHA}')/" \
pkg/arch/PKGBUILD > aur-repo/PKGBUILD
# Generate .SRCINFO inside an Arch container (docker cp avoids DinD volume mount issues)
CID=$(docker run -d archlinux:latest sleep infinity)
docker cp aur-repo/PKGBUILD $CID:/build/PKGBUILD || (docker exec $CID mkdir -p /build && docker cp aur-repo/PKGBUILD $CID:/build/PKGBUILD)
docker exec $CID bash -c "
pacman -Sy --noconfirm base-devel &&
useradd -m builder &&
chown -R builder:builder /build &&
runuser -u builder -- bash -c 'cd /build && makepkg --printsrcinfo > .SRCINFO'
"
docker cp $CID:/build/.SRCINFO aur-repo/.SRCINFO
docker rm -f $CID
# Commit and push to AUR master
cd aur-repo
git config user.email "hein@warky.dev"
git config user.name "Hein"
git add PKGBUILD .SRCINFO
git commit -m "Update to v${PKGVER}-${PKGREL}"
GIT_SSH_COMMAND="ssh -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$AUR_KNOWN_HOSTS -i $AUR_KEY_PATH" \
git push origin HEAD:master
pkg-deb:
needs: release
@@ -168,7 +222,7 @@ jobs:
for GOARCH in amd64 arm64; do
GOOS=linux GOARCH=$GOARCH go build \
-trimpath \
-ldflags "-X main.version=${PKGVER}" \
-ldflags "-X github.com/warkanum/unitdore/cmd.version=${PKGVER}" \
-o unitdore .
PKGDIR="unitdore_${PKGVER}_${GOARCH}"
@@ -215,8 +269,16 @@ jobs:
- name: Build RPM
run: |
set -euo pipefail
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
PKGVER="${VERSION#v}"
GO_VER="$(awk '/^go / { print $2; exit }' go.mod)"
if [ -z "${GO_VER}" ]; then
echo "Failed to determine Go version from go.mod"
exit 1
fi
# Source tarball — prefix=unitdore-VERSION/ matches RPM %autosetup convention
git archive --format=tar.gz --prefix=unitdore-${PKGVER}/ HEAD \
@@ -226,19 +288,37 @@ jobs:
sed -i "s/^Version:.*/Version: ${PKGVER}/" pkg/centos/unitdore.spec
mkdir -p pkg/centos/out
docker run --rm \
-v "$PWD:/workspace" \
-v "$PWD/pkg/centos/out:/out" \
-w /workspace \
CID=$(docker create \
-e GO_VER="${GO_VER}" \
-e PKGVER="${PKGVER}" \
-w /build \
rockylinux:9 \
bash -c "
dnf install -y rpm-build golang git &&
bash -lc "
set -euo pipefail
# Rocky 9 images already ship curl-minimal, which is enough for the Go tarball download.
dnf install -y rpm-build git &&
curl -fsSL https://go.dev/dl/go\${GO_VER}.linux-amd64.tar.gz | tar -C /usr/local -xz &&
export PATH=\$PATH:/usr/local/go/bin &&
mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} &&
cp unitdore-${PKGVER}.tar.gz ~/rpmbuild/SOURCES/ &&
cp pkg/centos/unitdore.spec ~/rpmbuild/SPECS/ &&
rpmbuild -ba ~/rpmbuild/SPECS/unitdore.spec &&
find ~/rpmbuild/RPMS -name '*.rpm' -exec cp {} /out/ \;
"
rpmbuild --nodeps -ba ~/rpmbuild/SPECS/unitdore.spec
")
cleanup() {
docker rm -f "$CID" >/dev/null 2>&1 || true
}
trap cleanup EXIT
# Avoid bind mounts here because DinD runners may not expose the checkout path to the Docker daemon.
docker cp unitdore-${PKGVER}.tar.gz "$CID:/build/unitdore-${PKGVER}.tar.gz"
docker cp pkg "$CID:/build/pkg"
docker start -a "$CID"
docker cp "$CID:/root/rpmbuild/RPMS/." pkg/centos/out/
trap - EXIT
cleanup
- name: Upload to release
run: |
@@ -246,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
View File
@@ -1,2 +1,3 @@
unitdore
*.exe
.codex
+104
View File
@@ -0,0 +1,104 @@
## Project Overview
**Unitdore** is a Go CLI tool that bridges container runtimes (Podman, Docker) and systemd. It discovers running containers, stores them in a YAML config, and generates + manages systemd `.service` unit files for each one.
# 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, list, root, start, startall,
# status, stop, stopall, syncup, uninstall, update)
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
systemd/ # .service file generator and systemd manager
docs/ # generated-units.md (unit file examples), unitdore.1 (man page)
```
### Key packages
- **`config`** — `Unit` and `Config` structs; `Load`, `Save`, `FindUnit`, `AddUnit`
- **`runtime`** — `ContainerRuntime` interface; `docker.go`, `podman.go` implementations
- **`systemd`** — `generator.go` builds `.service` file content; `manager.go` calls `systemctl`
### Config file
**Location:** `/etc/unitdore/units.yaml`
```yaml
prefix: "" # prepended to service file name (e.g. "prod-")
suffix: "" # appended to service file name (e.g. "-svc")
users: [] # root only: rootless users scanned by syncup
units:
- name: nginx
runtime: podman # podman | docker
user: "" # empty = system unit; set for rootless user unit
command: "" # override ExecStart entirely
order: 1 # startup order, lower = earlier; same = parallel
delay: 0s # delay after previous order 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
```
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`
- `sysuser/sysuser_test.go`
- `systemd/generator_test.go`
Always run `make test` before marking a task complete. Add tests for new behaviour in the same package as the code under test.
## Additional documentation
- `docs/generated-units.md` — full annotated examples of every generated unit file combination
- `docs/unitdore.1` — man page
+35
View File
@@ -0,0 +1,35 @@
# AI Usage Declaration
This Go project utilizes AI tools for the following purposes:
- Generating and improving documentation
- Writing and enhancing tests
- Refactoring and optimizing existing code
AI is **not** used for core design or architecture decisions.
All design decisions are deferred to human discussion.
AI is employed only for enhancements to human-written code.
We are aware of significant AI hallucinations; all AI-generated content is to be reviewed and verified by humans.
.-""""""-.
.' '.
/ O O \
: ` :
| |
: .------. :
\ ' ' /
'. .'
'-......-'
MEGAMIND AI
[============]
___________
/___________\
/_____________\
| ASSIMILATE |
| RESISTANCE |
| IS FUTILE |
\_____________/
\___________/
+5
View File
@@ -0,0 +1,5 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
You must now read the./AGENTS.md
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Hein (Warky Devs)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11 -5
View File
@@ -1,8 +1,8 @@
BINARY := unitdore
INSTALL_DIR := /usr/local/bin
INSTALL_DIR := /usr/bin
CONFIG_DIR := /etc/unitdore
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -ldflags "-X main.version=$(VERSION)"
LDFLAGS := -ldflags "-X github.com/warkanum/unitdore/cmd.version=$(VERSION)"
.PHONY: all build install uninstall test lint clean release-version
@@ -42,17 +42,23 @@ lint:
clean:
rm -f $(BINARY)
## release-version: bump patch version, tag, and push to trigger release workflow
## release-version: bump patch version, update source 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; \
git commit -m "chore(release): update package version to $$PKGVER"; \
git tag -a "$$NEXT" -m "Release $$NEXT"; \
git push origin "$$NEXT"; \
echo "Pushed tag $$NEXT — release workflow triggered"
git push origin HEAD "$$NEXT"; \
echo "Pushed $$NEXT — release workflow triggered"
## help: show this help
help:
+4 -1
View File
@@ -27,7 +27,8 @@ Think of it as: **config → systemd units → running services.**
## Non-Goals
- Not a container runtime (doesn't replace Podman/Docker)
- Not Kubernetes / Compose / Quadlet
- Not Kubernetes / Quadlet
- Compose: only wraps existing podman-compose stacks (one unit per project); docker compose planned
- Not a daemon — runs on demand
---
@@ -172,6 +173,8 @@ unitdore/
| 5 | Reconcile (auto-disable missing units) in syncup |
| 6 | User unit support (rootless containers) |
| 7 | Polish: `edit`, error handling, --dry-run flag |
| 8 | podman-compose stacks (compose + pod fallback), root/user scopes |
| 9 | docker compose stacks (`com.docker.compose.*` labels) |
---
+64 -2
View File
@@ -2,10 +2,36 @@
> *A door you open and close for container units.*
Unitdore bridges your container runtime (Podman, Docker) and systemd. It discovers running containers, stores them in a config file, and generates + manages systemd `.service` units for each one.
Unitdore bridges your container runtime (Podman, Docker) and systemd. It discovers running containers and podman-compose stacks, stores them in a config file, and generates + manages systemd `.service` units for each one.
## Install
**Arch Linux (AUR)**
```bash
yay -S unitdore
```
**Debian / Ubuntu**
Download the `.deb` from the [releases page](https://git.warky.dev/wdevs/unitdore/releases) and install:
```bash
sudo dpkg -i unitdore_*.deb
```
**CentOS / AlmaLinux / Rocky Linux**
Download the `.rpm` from the [releases page](https://git.warky.dev/wdevs/unitdore/releases) and install:
```bash
sudo rpm -i unitdore-*.rpm
# or with dnf:
sudo dnf install unitdore-*.rpm
```
**From source**
```bash
make build
sudo make install
@@ -28,6 +54,11 @@ sudo unitdore syncup
Queries Podman and Docker for running containers, adds new ones to the config, and reconciles any that have disappeared (marking them disabled).
- Podman first; Docker only for names Podman doesn't have
- podman-compose containers → one `kind: stack` unit per project; existing member units disabled (`part of stack <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
@@ -78,6 +109,7 @@ oldthing podman hein no no — container
```yaml
prefix: "" # optional: prepended to all service names, e.g. "prod-"
suffix: "" # optional: appended to all service names, e.g. "-svc"
users: [hein] # root only: rootless users scanned by syncup
units:
- name: nginx
@@ -95,8 +127,36 @@ units:
order: 2
delay: 5s
enabled: true
- name: shop # = compose project name
kind: stack # container (default) | stack
runtime: podman
user: hein
project: shop
working_dir: /srv/shop # from compose labels (refreshed by syncup)
compose_files: [compose.yaml]
compose_bin: /usr/bin/podman-compose
order: 3
enabled: true
```
### Stacks (podman-compose)
| Condition | Unit |
|---|---|
| `compose_bin`, `working_dir`, `compose_files` exist | `podman-compose -p <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:
@@ -121,6 +181,7 @@ See **[docs/generated-units.md](docs/generated-units.md)** for full examples cov
- User units (rootless)
- Docker vs Podman
- Custom commands
- Compose stacks (compose + pod fallback)
- Prefix/suffix naming
- Startup ordering
- Unnamed containers (short ID fallback)
@@ -149,7 +210,7 @@ WantedBy=multi-user.target
## Flags
```
--config string path to units config file (default "/etc/unitdore/units.yaml")
--config string path to units config file (default "/etc/unitdore/units.yaml"; non-root: ~/.config/unitdore/units.yaml)
```
## Requirements
@@ -157,4 +218,5 @@ WantedBy=multi-user.target
- Go 1.21+
- systemd
- Podman and/or Docker (only what you have installed is used)
- podman-compose for compose-mode stacks (pod fallback without it)
- Root for system units; the relevant user for rootless units
+25
View File
@@ -0,0 +1,25 @@
package cmd
import (
"fmt"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/sysuser"
)
// runtimeLabel is the RUNTIME column value for list/status.
func runtimeLabel(u config.Unit) string {
if u.IsStack() {
return u.Runtime + " (stack)"
}
return u.Runtime
}
// lingerHint prints, once per user, how to make user units start at boot.
func lingerHint(seen map[string]bool, u config.Unit) {
if u.User == "" || seen[u.User] || sysuser.LingerEnabled(u.User) {
return
}
seen[u.User] = true
fmt.Printf(" ! linger not enabled for %s — user units won't start at boot: sudo loginctl enable-linger %s\n", u.User, u.User)
}
+5 -3
View File
@@ -31,10 +31,11 @@ func runInstall(cmd *cobra.Command, args []string) error {
return err
}
prefix, suffix, serviceUser := cfg.Prefix, cfg.Suffix, cfg.EffectiveServiceUser()
prefix, suffix := cfg.Prefix, cfg.Suffix
installed := 0
skipped := 0
removed := 0
lingerSeen := map[string]bool{}
for _, u := range cfg.Units {
if !u.Enabled {
@@ -57,7 +58,7 @@ func runInstall(cmd *cobra.Command, args []string) error {
}
if dryRun {
content, err := systemd.Generate(u, prefix, suffix, serviceUser)
content, err := systemd.Generate(u, prefix, suffix)
if err != nil {
fmt.Printf(" ✗ %s: %v\n", u.Name, err)
continue
@@ -68,11 +69,12 @@ func runInstall(cmd *cobra.Command, args []string) error {
continue
}
if err := systemd.Install(u, prefix, suffix, serviceUser); err != nil {
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)
lingerHint(lingerSeen, u)
installed++
}
}
+85
View File
@@ -0,0 +1,85 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
)
var installallDryRun bool
var installallCmd = &cobra.Command{
Use: "installall",
Short: "Generate and install systemd unit files for all enabled units",
Long: `Installall generates .service files for all enabled units and writes them
to the appropriate systemd directory. It does not enable or start them.
Use --dry-run to preview the generated unit files without writing anything.`,
RunE: runInstallall,
}
func init() {
installallCmd.Flags().BoolVar(&installallDryRun, "dry-run", false, "preview unit files without writing")
rootCmd.AddCommand(installallCmd)
}
func runInstallall(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath)
if err != nil {
return err
}
prefix, suffix := cfg.Prefix, cfg.Suffix
installed := 0
skipped := 0
removed := 0
lingerSeen := map[string]bool{}
for _, u := range cfg.Units {
if !u.Enabled {
if systemd.IsInstalled(u, prefix, suffix) {
if installallDryRun {
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 installallDryRun {
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)
lingerHint(lingerSeen, u)
installed++
}
}
if !installallDryRun {
fmt.Printf("\nDone. Installed: %d Removed: %d Skipped: %d\n", installed, removed, skipped)
}
return nil
}
+1 -1
View File
@@ -60,7 +60,7 @@ func runList(cmd *cobra.Command, args []string) error {
}
}
rows = append(rows, row{u.Name, u.Runtime, enabled, installed, unitPath})
rows = append(rows, row{u.Name, runtimeLabel(u), enabled, installed, unitPath})
}
cols := []string{"NAME", "RUNTIME", "ENABLED", "INSTALLED", "SERVICE FILE"}
+22 -4
View File
@@ -5,8 +5,12 @@ import (
"os"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/sysuser"
)
var version = "0.0.11"
var configPath string
var rootCmd = &cobra.Command{
@@ -14,9 +18,23 @@ var rootCmd = &cobra.Command{
Short: "A door you open and close for container units",
Long: `Unitdore manages container units via systemd.
It discovers running containers, stores them in a config file,
and generates + manages systemd .service units for each one.`,
Version: "0.1.0",
It discovers running containers and podman-compose stacks, stores them in a
config file, and generates + manages systemd .service units for each one.
As root: system units, plus user units for users listed in 'users:'.
As a normal user: own config and user units only.`,
Version: version,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Normal users get their own config and manage only their user units
if !cmd.Flags().Changed("config") && !sysuser.IsRoot() {
home, err := sysuser.HomeDir(sysuser.Current())
if err != nil {
return err
}
configPath = config.UserConfigPath(home)
}
return nil
},
}
func Execute() {
@@ -27,5 +45,5 @@ func Execute() {
}
func init() {
rootCmd.PersistentFlags().StringVar(&configPath, "config", "/etc/unitdore/units.yaml", "path to units config file")
rootCmd.PersistentFlags().StringVar(&configPath, "config", config.DefaultConfigPath, "path to units config file (default for non-root: ~/.config/unitdore/units.yaml)")
}
+46
View File
@@ -0,0 +1,46 @@
package cmd
import (
"fmt"
"testing"
"github.com/spf13/cobra"
)
// TestStartArgCount verifies cobra.ExactArgs(1) rejects wrong arg counts for start.
func TestStartArgCount(t *testing.T) {
cmd := &cobra.Command{Use: "start", Short: "fake"}
cmd.Args = exactArgsOne
if err := cmd.ValidateArgs([]string{}); err == nil {
t.Error("expected error when called with no args")
}
if err := cmd.ValidateArgs([]string{"a", "b"}); err == nil {
t.Error("expected error when called with too many args")
}
if err := cmd.ValidateArgs([]string{"myunit"}); err != nil {
t.Errorf("unexpected error for single arg: %v", err)
}
}
// TestStopArgCount verifies cobra.ExactArgs(1) rejects wrong arg counts for stop.
func TestStopArgCount(t *testing.T) {
cmd := &cobra.Command{Use: "stop", Short: "fake"}
cmd.Args = exactArgsOne
if err := cmd.ValidateArgs([]string{}); err == nil {
t.Error("expected error when called with no args")
}
if err := cmd.ValidateArgs([]string{"a", "b"}); err == nil {
t.Error("expected error when called with too many args")
}
if err := cmd.ValidateArgs([]string{"myunit"}); err != nil {
t.Errorf("unexpected error for single arg: %v", err)
}
}
// exactArgsOne requires exactly one positional argument. Mirrors cobra.ExactArgs(1).
func exactArgsOne(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return fmt.Errorf("start/stop requires exactly 1 argument")
}
return nil
}
+4 -4
View File
@@ -11,9 +11,9 @@ import (
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'.`,
Short: "Start all installed, enabled units",
Long: `Startall runs 'systemctl start' for all enabled units that have been installed.
Units must be installed first via 'unitdore installall'.`,
RunE: runStartall,
}
@@ -50,7 +50,7 @@ func runStartall(cmd *cobra.Command, args []string) error {
continue
}
fmt.Printf(" ▶ starting: %s...\n", systemd.ServiceName(u, prefix, suffix))
if err := systemd.Enable(u, prefix, suffix); err != nil {
if err := systemd.Start(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
failed++
} else {
+1 -1
View File
@@ -76,7 +76,7 @@ func runStatus(cmd *cobra.Command, args []string) error {
rows = append(rows, statusRow{
Name: u.Name,
Runtime: u.Runtime,
Runtime: runtimeLabel(u),
User: user,
Enabled: enabled,
Installed: installed,
+3 -3
View File
@@ -11,8 +11,8 @@ import (
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.`,
Short: "Stop all installed units",
Long: `Stopall runs 'systemctl stop' for all installed units in reverse startup order.`,
RunE: runStopall,
}
@@ -46,7 +46,7 @@ func runStopall(cmd *cobra.Command, args []string) error {
continue
}
fmt.Printf(" ■ stopping: %s...\n", systemd.ServiceName(u, prefix, suffix))
if err := systemd.Disable(u, prefix, suffix); err != nil {
if err := systemd.Stop(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
failed++
} else {
+233 -56
View File
@@ -2,11 +2,15 @@ package cmd
import (
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/runtime"
"github.com/warkanum/unitdore/systemd"
"github.com/warkanum/unitdore/sysuser"
)
var syncupCmd = &cobra.Command{
@@ -15,8 +19,17 @@ var syncupCmd = &cobra.Command{
Long: `Syncup queries all available runtimes for running containers and adds
any new ones to the config file. Existing entries are never removed.
It also reconciles: if a configured unit's container no longer exists,
it is marked disabled with a reason.`,
Podman is queried first. Docker is only a fallback: a container name found
in podman is never registered as docker.
Containers created by podman-compose are grouped into one stack unit per
project. Existing per-container units of stack members are disabled.
Scopes: as root, root's containers plus rootless containers of each user in
'users:' (podman only). As a normal user, the user's own podman containers.
It also reconciles: if a configured unit's container or stack no longer
exists, it is marked disabled with a reason.`,
RunE: runSyncup,
}
@@ -24,71 +37,235 @@ func init() {
rootCmd.AddCommand(syncupCmd)
}
const (
reasonNotFound = "container not found"
reasonStackNotFound = "stack not found"
reasonMemberPrefix = "part of stack "
)
type syncStats struct {
added, unchanged, disabled, reenabled int
}
func runSyncup(cmd *cobra.Command, args []string) error {
cfg, err := config.LoadOrEmpty(configPath)
if err != nil {
return err
}
added := 0
unchanged := 0
discovered := map[string]bool{}
containers := discover(syncScopes(cfg))
// Discover running containers across all runtimes
for _, rt := range runtime.Available() {
containers, err := rt.ListRunning()
if err != nil {
fmt.Fprintf(os.Stderr, "warning: %s discovery failed: %v\n", rt.Name(), err)
continue
}
for _, c := range containers {
discovered[c.Name] = true
unit := config.Unit{
Name: c.Name,
Runtime: c.Runtime,
Order: len(cfg.Units) + 1,
Enabled: true,
}
if cfg.AddUnit(unit) {
fmt.Printf(" + added: %s (%s)\n", c.Name, c.Runtime)
added++
} else {
unchanged++
}
}
}
// Reconcile: check if existing units' containers still exist
disabled := 0
reenabled := 0
for i := range cfg.Units {
u := &cfg.Units[i]
rt := runtime.Get(u.Runtime)
if rt == nil {
continue
}
exists, err := rt.Exists(u.Name)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: checking %s: %v\n", u.Name, err)
continue
}
if !exists && u.Enabled {
u.Enabled = false
u.DisabledReason = "container not found"
fmt.Printf(" ! disabled: %s (container not found)\n", u.Name)
disabled++
} else if exists && !u.Enabled && u.DisabledReason == "container not found" {
u.Enabled = true
u.DisabledReason = ""
fmt.Printf(" ✓ re-enabled: %s (container found)\n", u.Name)
reenabled++
}
}
var st syncStats
running := mergeDiscovered(cfg, containers, composeBinFor, os.Stdout, &st)
reconcile(cfg, running, unitExists, os.Stdout, &st)
if err := cfg.Save(configPath); err != nil {
return err
}
fmt.Printf("\nDone. Added: %d Unchanged: %d Disabled: %d Re-enabled: %d\n", added, unchanged, disabled, reenabled)
fmt.Printf("\nDone. Added: %d Unchanged: %d Disabled: %d Re-enabled: %d\n", st.added, st.unchanged, st.disabled, st.reenabled)
return nil
}
// syncScopes returns the user scopes to scan ("" = root/system).
func syncScopes(cfg *config.Config) []string {
if sysuser.IsRoot() {
return append([]string{""}, cfg.Users...)
}
return []string{sysuser.Current()}
}
// discover lists running containers in every scope, podman first.
func discover(scopes []string) []runtime.Container {
var all []runtime.Container
for _, user := range scopes {
for _, rt := range runtime.Available() {
// Docker is a system daemon; user scopes only see rootless podman
if user != "" && rt.Name() == "docker" {
continue
}
containers, err := rt.ListRunning(user)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: %s discovery failed (%s): %v\n", rt.Name(), scopeName(user), err)
continue
}
all = append(all, containers...)
}
}
return all
}
func composeBinFor(user string) string {
return sysuser.LookPath(user, "podman-compose")
}
func scopeName(user string) string {
if user == "" {
return "root"
}
return user
}
func scopeKey(user, name string) string { return user + "/" + name }
// mergeDiscovered adds discovered containers and stacks to cfg. Returns the
// set of running scopeKey(user, name) for containers and stack projects.
func mergeDiscovered(cfg *config.Config, containers []runtime.Container, composeBin func(user string) string, w io.Writer, st *syncStats) map[string]bool {
running := map[string]bool{}
podmanNames := map[string]bool{}
members := map[string]string{} // scopeKey(user, container) -> project
for _, c := range containers {
if c.Runtime == "podman" {
podmanNames[scopeKey(c.User, c.Name)] = true
}
}
// Group podman-compose containers into one stack unit per (user, project)
for _, c := range containers {
project := c.Project()
if c.Runtime != "podman" || project == "" {
continue
}
members[scopeKey(c.User, c.Name)] = project
key := scopeKey(c.User, project)
if running[key] {
continue
}
running[key] = true
stack := config.Unit{
Name: project,
Runtime: "podman",
User: c.User,
Kind: config.KindStack,
Project: project,
WorkingDir: c.WorkingDir(),
ComposeFiles: c.ComposeFiles(),
ComposeBin: composeBin(c.User),
Order: len(cfg.Units) + 1,
Enabled: true,
}
existing := cfg.FindUnit(project)
switch {
case existing == nil:
cfg.AddUnit(stack)
fmt.Fprintf(w, " + added stack: %s (%s, %s)\n", project, scopeName(c.User), strings.Join(stack.ComposeFiles, ","))
st.added++
case existing.IsStack() && existing.Project == project && existing.User == c.User:
// Labels are the source of truth for where the stack lives
existing.WorkingDir = stack.WorkingDir
existing.ComposeFiles = stack.ComposeFiles
if stack.ComposeBin != "" {
existing.ComposeBin = stack.ComposeBin
}
st.unchanged++
default:
fmt.Fprintf(os.Stderr, "warning: stack %s (%s) skipped: unit name already used\n", project, scopeName(c.User))
}
}
for _, c := range containers {
key := scopeKey(c.User, c.Name)
// Podman is queried first and wins on name clash (e.g. podman-docker shim)
if c.Runtime != "podman" && podmanNames[key] {
continue
}
running[key] = true
if _, ok := members[key]; ok {
continue
}
unit := config.Unit{
Name: c.Name,
Runtime: c.Runtime,
User: c.User,
Order: len(cfg.Units) + 1,
Enabled: true,
}
if cfg.AddUnit(unit) {
fmt.Fprintf(w, " + added: %s (%s, %s)\n", c.Name, c.Runtime, scopeName(c.User))
st.added++
continue
}
if existing := cfg.FindUnit(c.Name); existing.User != c.User {
fmt.Fprintf(os.Stderr, "warning: %s (%s) skipped: unit name already used by %s\n", c.Name, scopeName(c.User), scopeName(existing.User))
continue
}
st.unchanged++
}
for i := range cfg.Units {
u := &cfg.Units[i]
if u.IsStack() {
continue
}
key := scopeKey(u.User, u.Name)
if u.Runtime == "docker" && podmanNames[key] {
u.Runtime = "podman"
fmt.Fprintf(w, " ~ runtime: %s docker -> podman\n", u.Name)
}
if project, ok := members[key]; ok && u.DisabledReason != reasonMemberPrefix+project {
wasEnabled := u.Enabled
u.Enabled = false
u.DisabledReason = reasonMemberPrefix + project
if wasEnabled {
fmt.Fprintf(w, " ! disabled: %s (%s)\n", u.Name, u.DisabledReason)
st.disabled++
}
}
}
return running
}
// unitExists checks whether a unit's container or stack still exists.
func unitExists(u config.Unit) (bool, error) {
if u.IsStack() {
if systemd.ComposeAvailable(u) {
return true, nil
}
return (&runtime.Podman{}).PodExists(u.User, runtime.PodName(u.Project))
}
rt := runtime.Get(u.Runtime)
if rt == nil {
return true, nil // unknown runtime — leave alone
}
return rt.Exists(u.User, u.Name)
}
// reconcile disables units whose container/stack is gone and re-enables
// units that were auto-disabled once it is back.
func reconcile(cfg *config.Config, running map[string]bool, exists func(config.Unit) (bool, error), w io.Writer, st *syncStats) {
for i := range cfg.Units {
u := &cfg.Units[i]
if strings.HasPrefix(u.DisabledReason, reasonMemberPrefix) {
continue
}
reason := reasonNotFound
if u.IsStack() {
reason = reasonStackNotFound
}
found := running[scopeKey(u.User, u.Name)]
if !found {
var err error
found, err = exists(*u)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: checking %s: %v\n", u.Name, err)
continue
}
}
if !found && u.Enabled {
u.Enabled = false
u.DisabledReason = reason
fmt.Fprintf(w, " ! disabled: %s (%s)\n", u.Name, reason)
st.disabled++
} else if found && !u.Enabled && u.DisabledReason == reason {
u.Enabled = true
u.DisabledReason = ""
fmt.Fprintf(w, " ✓ re-enabled: %s (found)\n", u.Name)
st.reenabled++
}
}
}
+185
View File
@@ -0,0 +1,185 @@
package cmd
import (
"io"
"testing"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/runtime"
)
func stackContainer(user, name, project string) runtime.Container {
return runtime.Container{
Name: name,
Runtime: "podman",
User: user,
Labels: map[string]string{
runtime.LabelPodmanComposeProject: project,
runtime.LabelComposeWorkingDir: "/srv/" + project,
runtime.LabelComposeConfigFiles: "compose.yaml",
},
}
}
func noBin(string) string { return "" }
func merge(cfg *config.Config, cs []runtime.Container) (map[string]bool, syncStats) {
var st syncStats
running := mergeDiscovered(cfg, cs, noBin, io.Discard, &st)
return running, st
}
func TestMerge_GroupsStack(t *testing.T) {
cfg := &config.Config{}
_, st := merge(cfg, []runtime.Container{
stackContainer("", "shop_web_1", "shop"),
stackContainer("", "shop_db_1", "shop"),
{Name: "redis", Runtime: "podman"},
})
if len(cfg.Units) != 2 || st.added != 2 {
t.Fatalf("expected 2 units (1 stack + 1 container), got %d: %+v", len(cfg.Units), cfg.Units)
}
s := cfg.FindUnit("shop")
if s == nil || !s.IsStack() {
t.Fatalf("stack unit shop missing: %+v", cfg.Units)
}
if s.Project != "shop" || s.WorkingDir != "/srv/shop" || len(s.ComposeFiles) != 1 || s.ComposeFiles[0] != "compose.yaml" {
t.Errorf("stack fields wrong: %+v", s)
}
if cfg.FindUnit("shop_web_1") != nil || cfg.FindUnit("shop_db_1") != nil {
t.Error("stack members must not get their own units")
}
if cfg.FindUnit("redis") == nil {
t.Error("plain container should be added")
}
}
func TestMerge_DisablesExistingMemberUnits(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{
{Name: "shop_web_1", Runtime: "podman", Enabled: true},
{Name: "shop_db_1", Runtime: "podman", Enabled: false, DisabledReason: reasonNotFound},
}}
running, st := merge(cfg, []runtime.Container{
stackContainer("", "shop_web_1", "shop"),
stackContainer("", "shop_db_1", "shop"),
})
for _, n := range []string{"shop_web_1", "shop_db_1"} {
u := cfg.FindUnit(n)
if u.Enabled || u.DisabledReason != "part of stack shop" {
t.Errorf("%s: enabled=%v reason=%q, want disabled as stack member", n, u.Enabled, u.DisabledReason)
}
}
if st.disabled != 1 {
t.Errorf("disabled = %d, want 1 (only the previously enabled unit)", st.disabled)
}
// Reconcile must not re-enable member units
reconcile(cfg, running, func(config.Unit) (bool, error) { return true, nil }, io.Discard, &st)
if cfg.FindUnit("shop_db_1").Enabled {
t.Error("reconcile re-enabled a stack member unit")
}
}
func TestMerge_PodmanWinsOverDocker(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{
{Name: "old", Runtime: "docker", Enabled: true},
}}
merge(cfg, []runtime.Container{
{Name: "web", Runtime: "podman"},
{Name: "web", Runtime: "docker"},
{Name: "old", Runtime: "podman"},
{Name: "only-docker", Runtime: "docker"},
})
if u := cfg.FindUnit("web"); u == nil || u.Runtime != "podman" {
t.Errorf("web should be podman: %+v", u)
}
if u := cfg.FindUnit("old"); u.Runtime != "podman" {
t.Errorf("existing docker unit found in podman should switch to podman, got %s", u.Runtime)
}
if u := cfg.FindUnit("only-docker"); u == nil || u.Runtime != "docker" {
t.Errorf("docker-only container should be added as docker: %+v", u)
}
}
func TestMerge_UserScope(t *testing.T) {
cfg := &config.Config{}
merge(cfg, []runtime.Container{
{Name: "app", Runtime: "podman", User: "hein"},
stackContainer("hein", "blog_web_1", "blog"),
})
if u := cfg.FindUnit("app"); u == nil || u.User != "hein" {
t.Errorf("user container should keep its user: %+v", u)
}
if u := cfg.FindUnit("blog"); u == nil || u.User != "hein" || !u.IsStack() {
t.Errorf("user stack should keep its user: %+v", u)
}
}
func TestMerge_NameClashAcrossUsers(t *testing.T) {
cfg := &config.Config{}
_, st := merge(cfg, []runtime.Container{
{Name: "app", Runtime: "podman"},
{Name: "app", Runtime: "podman", User: "hein"},
})
if len(cfg.Units) != 1 || cfg.Units[0].User != "" {
t.Errorf("first unit should win on name clash: %+v", cfg.Units)
}
if st.unchanged != 0 {
t.Errorf("clash should not count as unchanged, got %d", st.unchanged)
}
}
func TestMerge_RefreshesExistingStack(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{
{Name: "shop", Runtime: "podman", Kind: config.KindStack, Project: "shop", WorkingDir: "/old", Enabled: true},
}}
_, st := merge(cfg, []runtime.Container{stackContainer("", "shop_web_1", "shop")})
if u := cfg.FindUnit("shop"); u.WorkingDir != "/srv/shop" {
t.Errorf("working_dir not refreshed from labels: %q", u.WorkingDir)
}
if st.added != 0 || st.unchanged != 1 {
t.Errorf("added=%d unchanged=%d, want 0/1", st.added, st.unchanged)
}
}
func TestReconcile_Stack(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{
{Name: "gone", Kind: config.KindStack, Project: "gone", Runtime: "podman", Enabled: true},
{Name: "back", Kind: config.KindStack, Project: "back", Runtime: "podman", Enabled: false, DisabledReason: reasonStackNotFound},
{Name: "manual", Kind: config.KindStack, Project: "manual", Runtime: "podman", Enabled: false, DisabledReason: "by hand"},
}}
exists := func(u config.Unit) (bool, error) { return u.Name != "gone", nil }
var st syncStats
reconcile(cfg, map[string]bool{}, exists, io.Discard, &st)
if u := cfg.FindUnit("gone"); u.Enabled || u.DisabledReason != reasonStackNotFound {
t.Errorf("gone: enabled=%v reason=%q", u.Enabled, u.DisabledReason)
}
if u := cfg.FindUnit("back"); !u.Enabled {
t.Error("back should be re-enabled")
}
if u := cfg.FindUnit("manual"); u.Enabled {
t.Error("manually disabled unit must stay disabled")
}
if st.disabled != 1 || st.reenabled != 1 {
t.Errorf("disabled=%d reenabled=%d, want 1/1", st.disabled, st.reenabled)
}
}
func TestReconcile_RunningSkipsExistsCheck(t *testing.T) {
cfg := &config.Config{Units: []config.Unit{
{Name: "app", Runtime: "podman", User: "hein", Enabled: true},
}}
called := false
exists := func(config.Unit) (bool, error) { called = true; return false, nil }
var st syncStats
reconcile(cfg, map[string]bool{scopeKey("hein", "app"): true}, exists, io.Discard, &st)
if called || !cfg.Units[0].Enabled {
t.Errorf("running unit should not be checked or disabled (called=%v)", called)
}
}
+67
View File
@@ -0,0 +1,67 @@
package cmd
import (
"fmt"
"sort"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
)
var uninstallallCmd = &cobra.Command{
Use: "uninstallall",
Short: "Stop, disable, and remove service files for all installed units",
Long: `Uninstallall runs 'systemctl disable --now' and removes the .service file for every installed unit, in reverse startup order.`,
RunE: runUninstallall,
}
func init() {
rootCmd.AddCommand(uninstallallCmd)
}
func runUninstallall(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
})
removed := 0
failed := 0
for _, u := range units {
if !systemd.IsInstalled(u, prefix, suffix) {
continue
}
svcName := systemd.ServiceName(u, prefix, suffix)
fmt.Printf(" ■ disabling %s...\n", svcName)
if err := systemd.Disable(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed to disable %s: %v\n", u.Name, err)
failed++
continue
}
path, _ := systemd.UnitPath(u, prefix, suffix)
fmt.Printf(" ✗ removing %s...\n", path)
if err := systemd.Uninstall(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err)
failed++
continue
}
fmt.Printf(" ✓ uninstalled: %s\n", u.Name)
removed++
}
fmt.Printf("\nDone. Uninstalled: %d Failed: %d\n", removed, failed)
return nil
}
+2 -2
View File
@@ -63,10 +63,10 @@ func runUpdate(cmd *cobra.Command, args []string) error {
return err
}
prefix, suffix, serviceUser := cfg.Prefix, cfg.Suffix, cfg.EffectiveServiceUser()
prefix, suffix := cfg.Prefix, cfg.Suffix
if systemd.IsInstalled(*u, prefix, suffix) {
if err := systemd.Install(*u, prefix, suffix, serviceUser); err != nil {
if err := systemd.Install(*u, prefix, suffix); err != nil {
return fmt.Errorf("recreating service file: %w", err)
}
path, _ := systemd.UnitPath(*u, prefix, suffix)
+30 -18
View File
@@ -3,7 +3,6 @@ package config
import (
"fmt"
"os"
"os/user"
"path/filepath"
"gopkg.in/yaml.v3"
@@ -11,35 +10,48 @@ import (
const DefaultConfigPath = "/etc/unitdore/units.yaml"
// UserConfigPath returns the config path used when running as a normal user.
func UserConfigPath(home string) string {
return filepath.Join(home, ".config", "unitdore", "units.yaml")
}
// Unit kinds.
const (
KindContainer = "container"
KindStack = "stack"
)
// Unit represents a single managed container unit.
type Unit struct {
Name string `yaml:"name"`
Runtime string `yaml:"runtime"` // podman | docker | exec
User string `yaml:"user,omitempty"` // empty = root/system unit
Runtime string `yaml:"runtime"` // podman | docker | exec
User string `yaml:"user,omitempty"` // empty = root/system unit
Command string `yaml:"command,omitempty"` // override ExecStart
Order int `yaml:"order"`
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)
// Stack fields (kind: stack) — one unit for a whole podman-compose project.
Kind string `yaml:"kind,omitempty"` // container (default) | stack
Project string `yaml:"project,omitempty"` // compose project name
WorkingDir string `yaml:"working_dir,omitempty"` // compose project directory
ComposeFiles []string `yaml:"compose_files,omitempty"` // relative to working_dir unless absolute
ComposeBin string `yaml:"compose_bin,omitempty"` // path to podman-compose
}
// IsStack reports whether the unit manages a compose stack.
func (u Unit) IsStack() bool { return u.Kind == KindStack }
// Config is the root config structure.
type Config struct {
Units []Unit `yaml:"units"`
Prefix string `yaml:"prefix,omitempty"` // prepended to generated service name, e.g. "prod-"
Suffix string `yaml:"suffix,omitempty"` // appended to generated service name, e.g. "-svc"
ServiceUser string `yaml:"service_user,omitempty"` // User= in [Service] section; defaults to "unitdore"
}
// EffectiveServiceUser returns the configured service user, or the current OS user if unset.
func (c *Config) EffectiveServiceUser() string {
if c.ServiceUser != "" {
return c.ServiceUser
}
if u, err := user.Current(); err == nil {
return u.Username
}
return "root"
Units []Unit `yaml:"units"`
Prefix string `yaml:"prefix,omitempty"` // prepended to generated service name, e.g. "prod-"
Suffix string `yaml:"suffix,omitempty"` // appended to generated service name, e.g. "-svc"
Users []string `yaml:"users,omitempty"` // rootless users scanned by syncup when run as root
}
// Load reads and parses the config file at path.
+39
View File
@@ -144,3 +144,42 @@ func TestDisabledReason_Roundtrip(t *testing.T) {
t.Errorf("expected disabled_reason to survive roundtrip, got: %q", loaded.Units[0].DisabledReason)
}
}
func TestSaveAndLoad_Stack(t *testing.T) {
path := filepath.Join(t.TempDir(), "units.yaml")
cfg := &Config{
Users: []string{"hein"},
Units: []Unit{{
Name: "shop", Runtime: "podman", Kind: KindStack, Project: "shop",
WorkingDir: "/srv/shop", ComposeFiles: []string{"compose.yaml"},
ComposeBin: "/usr/bin/podman-compose", User: "hein", Enabled: true,
}},
}
if err := cfg.Save(path); err != nil {
t.Fatalf("Save failed: %v", err)
}
loaded, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
u := loaded.Units[0]
if !u.IsStack() || u.Project != "shop" || u.WorkingDir != "/srv/shop" ||
len(u.ComposeFiles) != 1 || u.ComposeBin != "/usr/bin/podman-compose" {
t.Errorf("stack fields not round-tripped: %+v", u)
}
if len(loaded.Users) != 1 || loaded.Users[0] != "hein" {
t.Errorf("users not round-tripped: %v", loaded.Users)
}
}
func TestIsStack_DefaultKind(t *testing.T) {
if (Unit{Name: "x"}).IsStack() {
t.Error("unit without kind must be a container")
}
}
func TestUserConfigPath(t *testing.T) {
if got := UserConfigPath("/home/hein"); got != "/home/hein/.config/unitdore/units.yaml" {
t.Errorf("UserConfigPath() = %q", got)
}
}
+53
View File
@@ -277,6 +277,59 @@ units:
---
## Stack Unit (podman-compose, compose mode)
Used when `compose_bin`, `working_dir` and all `compose_files` exist.
```yaml
- name: shop
kind: stack
runtime: podman
project: shop
working_dir: /srv/shop
compose_files: [compose.yaml]
compose_bin: /usr/bin/podman-compose
enabled: true
```
```ini
# /etc/systemd/system/unitdore-shop.service
# Generated by unitdore — do not edit manually
[Unit]
Description=Unitdore: shop (podman-compose stack)
After=network.target
[Service]
Type=simple
WorkingDirectory=/srv/shop
ExecStart=/usr/bin/podman-compose -p shop -f compose.yaml up
ExecStop=-/usr/bin/podman-compose -p shop -f compose.yaml stop
[Install]
WantedBy=multi-user.target
```
---
## Stack Unit (pod fallback)
Used when the compose binary, working dir or a compose file is missing. Requires pod `pod_<project>`.
```ini
[Unit]
Description=Unitdore: shop (podman pod)
After=network.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/podman pod start pod_shop
ExecStop=-/usr/bin/podman pod stop pod_shop
```
---
## Quick Reference: Service File Locations
| Scenario | Path |
+29 -4
View File
@@ -9,18 +9,26 @@ unitdore \- manage container units via systemd
.SH DESCRIPTION
.B unitdore
bridges your container runtime (Podman, Docker) and systemd.
It discovers running containers, stores them in a config file, and generates
and manages systemd \fI.service\fR units for each one.
It discovers running containers and podman\-compose stacks, stores them in a
config file, and generates and manages systemd \fI.service\fR units for each one.
.PP
As root: system units, plus user units for users listed in \fBusers:\fR.
As a normal user: own config (\fI~/.config/unitdore/units.yaml\fR) and user units only.
.SH OPTIONS
.TP
.BR \-\-config " " \fIpath\fR
Path to the units config file. Default: \fI/etc/unitdore/units.yaml\fR
(non\-root: \fI~/.config/unitdore/units.yaml\fR)
.SH COMMANDS
.TP
.B syncup
Query all available runtimes for running containers and add any new ones to
the config. Reconciles existing entries: if a configured unit's container no
longer exists it is marked disabled with a reason.
Podman is queried first; Docker only for names Podman does not have.
podman\-compose containers become one \fBkind: stack\fR unit per project;
existing member units are disabled. As root, rootless Podman containers of
each user in \fBusers:\fR are also discovered.
.TP
.B install
Generate \fI.service\fR files for all enabled units and write them to the
@@ -74,7 +82,7 @@ Open the units config file in \fB$EDITOR\fR. Falls back to \fBnvim\fR,
.nf
prefix: "" # prepended to all service names, e.g. "prod-"
suffix: "" # appended to all service names, e.g. "-svc"
service_user: "" # User= in [Service]; defaults to current OS user
users: [] # root only: rootless users scanned by syncup
units:
- name: nginx
@@ -85,7 +93,19 @@ units:
delay: 0s # delay after previous order group
enabled: true
disabled_reason: "" # auto-set by syncup reconciliation
- name: shop # compose project name
kind: stack # container (default) | stack
runtime: podman
project: shop
working_dir: /srv/shop
compose_files: [compose.yaml]
compose_bin: /usr/bin/podman-compose
.fi
.PP
Stack units run \fBpodman\-compose up\fR/\fBstop\fR when compose_bin,
working_dir and compose_files exist; otherwise \fBpodman pod start\fR/\fBstop
pod_\fR\fIproject\fR.
.SH GENERATED SERVICE FILES
System units are written to \fI/etc/systemd/system/\fR and user units to
\fI~/.config/systemd/user/\fR.
@@ -127,13 +147,18 @@ sudo unitdore update nginx \-\-order 2
.I /etc/unitdore/units.yaml
Default configuration file.
.TP
.I ~/.config/unitdore/units.yaml
Configuration file when run as a normal user.
.TP
.I /etc/systemd/system/unitdore-*.service
Generated system unit files.
.TP
.I ~/.config/systemd/user/unitdore-*.service
Generated rootless user unit files.
.SH REQUIREMENTS
systemd; Podman and/or Docker (only installed runtimes are used).
systemd; Podman and/or Docker (only installed runtimes are used);
podman\-compose for compose\-mode stacks.
User units need linger to start at boot: \fBloginctl enable\-linger\fR \fIuser\fR.
Root is required for system units; the relevant user for rootless units.
.SH SEE ALSO
.BR systemctl (1),
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/warkanum/unitdore
go 1.26.1
go 1.23.0
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+7 -8
View File
@@ -1,10 +1,10 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=unitdore
pkgver=0.1.0
pkgver=0.0.11
pkgrel=1
pkgdesc="A door you open and close for container units — manage containers via systemd"
arch=('x86_64' 'aarch64')
url="https://warky.dev"
url="https://git.warky.dev/wdevs/unitdore"
license=('MIT')
depends=('systemd')
optdepends=(
@@ -12,26 +12,25 @@ optdepends=(
'docker: Docker container runtime support'
)
makedepends=('go')
backup=('etc/unitdore/units.yaml')
source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz")
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 \
-ldflags "-X main.version=$pkgver" \
-ldflags "-X github.com/warkanum/unitdore/cmd.version=$pkgver" \
-o "$pkgname" .
}
check() {
cd "$pkgname"
cd "$pkgname-$pkgver"
go test ./...
}
package() {
cd "$pkgname"
cd "$pkgname-$pkgver"
# Binary
install -Dm755 "$pkgname" "$pkgdir/usr/bin/$pkgname"
+9 -10
View File
@@ -1,15 +1,17 @@
Name: unitdore
Version: 0.1.0
Version: 0.0.11
Release: 1%{?dist}
Summary: Manage container units via systemd
License: MIT
URL: https://warky.dev
URL: https://git.warky.dev/wdevs/unitdore
Source0: %{name}-%{version}.tar.gz
BuildRequires: golang >= 1.21
BuildRequires: golang >= 1.23
Requires: systemd
%global debug_package %{nil}
%description
Unitdore bridges your container runtime (Podman, Docker) and systemd.
It discovers running containers, stores them in a config file, and generates
@@ -22,24 +24,21 @@ and manages systemd .service units for each one.
export CGO_ENABLED=0
go build \
-trimpath \
-ldflags "-X main.version=%{version}" \
-ldflags "-X github.com/warkanum/unitdore/cmd.version=%{version}" \
-o %{name} .
%install
# Binary
install -Dm755 %{name} %{buildroot}%{_bindir}/%{name}
# Man page
install -Dm644 docs/unitdore.1 %{buildroot}%{_mandir}/man1/unitdore.1
# Config dir
install -Dm644 LICENSE %{buildroot}%{_licensedir}/%{name}/LICENSE
install -dm755 %{buildroot}%{_sysconfdir}/unitdore
%files
%license LICENSE
%{_bindir}/%{name}
%{_mandir}/man1/unitdore.1*
%dir %{_sysconfdir}/unitdore
%changelog
* Tue Apr 08 2026 Hein (Warky Devs) <hein@warky.dev> - 0.1.0-1
* Wed Apr 08 2026 Hein (Warky Devs) <hein@warky.dev> - 0.0.5-1
- Initial package
+3
View File
@@ -2,6 +2,9 @@ Package: unitdore
Version: VERSION
Architecture: ARCH
Maintainer: Hein (Warky Devs) <hein@warky.dev>
Section: admin
Priority: optional
Homepage: https://git.warky.dev/wdevs/unitdore
Depends: systemd
Recommends: podman | docker.io
Description: A door you open and close for container units
+59 -7
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"os/exec"
"strings"
"github.com/warkanum/unitdore/sysuser"
)
type Docker struct{}
@@ -17,21 +19,44 @@ type dockerContainer struct {
Image string `json:"Image"`
Command string `json:"Command"`
State string `json:"State"`
Labels string `json:"Labels"`
}
func (d *Docker) ListRunning() ([]Container, error) {
// dockerCmd builds a docker command for the given user scope.
// Returns nil if docker is not installed.
func dockerCmd(user string, args ...string) (*exec.Cmd, error) {
bin, err := exec.LookPath("docker")
if err != nil {
return nil, nil
}
return sysuser.Command(user, bin, args...)
}
func (d *Docker) ListRunning(user string) ([]Container, error) {
cmd, err := dockerCmd(user, "ps", "--format", "json")
if err != nil {
return nil, err
}
if cmd == nil {
return nil, nil // docker not installed — not an error
}
out, err := exec.Command(bin, "ps", "--format", "json").Output()
out, err := cmd.Output()
if err != nil {
// Docker installed but daemon not running — treat as no containers
return nil, nil
}
// Docker outputs one JSON object per line (not a JSON array)
containers := parseDockerPS(out)
for i := range containers {
containers[i].User = user
}
return containers, nil
}
// parseDockerPS parses `docker ps --format json` output
// (one JSON object per line, not a JSON array).
func parseDockerPS(out []byte) []Container {
var containers []Container
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
@@ -57,18 +82,45 @@ func (d *Docker) ListRunning() ([]Container, error) {
Image: c.Image,
Command: c.Command,
Runtime: "docker",
Labels: parseDockerLabels(c.Labels),
})
}
return containers, nil
return containers
}
func (d *Docker) Exists(name string) (bool, error) {
bin, err := exec.LookPath("docker")
// parseDockerLabels parses docker's "k=v,k=v" label string. A segment without
// "=" is a continuation of the previous value (values may contain commas,
// e.g. multiple compose config files).
func parseDockerLabels(s string) map[string]string {
labels := map[string]string{}
last := ""
for _, part := range strings.Split(s, ",") {
if part == "" {
continue
}
k, v, ok := strings.Cut(part, "=")
if !ok {
if last != "" {
labels[last] += "," + part
}
continue
}
labels[k] = v
last = k
}
return labels
}
func (d *Docker) Exists(user, name string) (bool, error) {
cmd, err := dockerCmd(user, "ps", "--format", "{{.Names}}")
if err != nil {
return false, err
}
if cmd == nil {
return false, nil
}
out, err := exec.Command(bin, "ps", "--format", "{{.Names}}").Output()
out, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("docker ps: %w", err)
}
+65 -11
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"os/exec"
"strings"
"github.com/warkanum/unitdore/sysuser"
)
type Podman struct{}
@@ -12,28 +14,55 @@ type Podman struct{}
func (p *Podman) Name() string { return "podman" }
type podmanContainer struct {
ID string `json:"Id"`
Names []string `json:"Names"`
Image string `json:"Image"`
Command []string `json:"Command"`
State string `json:"State"`
ID string `json:"Id"`
Names []string `json:"Names"`
Image string `json:"Image"`
Command []string `json:"Command"`
State string `json:"State"`
Labels map[string]string `json:"Labels"`
}
func (p *Podman) ListRunning() ([]Container, error) {
// podmanCmd builds a podman command for the given user scope.
// Returns nil if podman is not installed.
func podmanCmd(user string, args ...string) (*exec.Cmd, error) {
bin, err := exec.LookPath("podman")
if err != nil {
return nil, nil
}
return sysuser.Command(user, bin, args...)
}
func (p *Podman) ListRunning(user string) ([]Container, error) {
cmd, err := podmanCmd(user, "ps", "--format", "json")
if err != nil {
return nil, err
}
if cmd == nil {
return nil, nil // podman not installed — not an error
}
out, err := exec.Command(bin, "ps", "--format", "json").Output()
out, err := cmd.Output()
if err != nil {
// Podman installed but not usable — treat as no containers
return nil, nil
}
containers, err := parsePodmanPS(out)
if err != nil {
// Podman installed but output is not valid JSON (e.g. OCI runtime misconfigured)
return nil, nil
}
for i := range containers {
containers[i].User = user
}
return containers, nil
}
// parsePodmanPS parses `podman ps --format json` output (a JSON array).
func parsePodmanPS(out []byte) ([]Container, error) {
var raw []podmanContainer
if err := json.Unmarshal(out, &raw); err != nil {
return nil, fmt.Errorf("parsing podman output: %w", err)
return nil, err
}
var containers []Container
@@ -57,18 +86,22 @@ func (p *Podman) ListRunning() ([]Container, error) {
Image: c.Image,
Command: strings.Join(c.Command, " "),
Runtime: "podman",
Labels: c.Labels,
})
}
return containers, nil
}
func (p *Podman) Exists(name string) (bool, error) {
bin, err := exec.LookPath("podman")
func (p *Podman) Exists(user, name string) (bool, error) {
cmd, err := podmanCmd(user, "ps", "--format", "{{.Names}}")
if err != nil {
return false, err
}
if cmd == nil {
return false, nil
}
out, err := exec.Command(bin, "ps", "--format", "{{.Names}}").Output()
out, err := cmd.Output()
if err != nil {
return false, fmt.Errorf("podman ps: %w", err)
}
@@ -80,3 +113,24 @@ func (p *Podman) Exists(name string) (bool, error) {
}
return false, nil
}
// PodExists reports whether a pod with the given name exists (any state).
func (p *Podman) PodExists(user, pod string) (bool, error) {
cmd, err := podmanCmd(user, "pod", "exists", pod)
if err != nil {
return false, err
}
if cmd == nil {
return false, nil
}
if err := cmd.Run(); err != nil {
if _, ok := err.(*exec.ExitError); ok {
return false, nil // exit 1 = pod does not exist
}
return false, fmt.Errorf("podman pod exists: %w", err)
}
return true, nil
}
// PodName returns the pod name podman-compose uses for a project.
func PodName(project string) string { return "pod_" + project }
+45 -3
View File
@@ -1,21 +1,63 @@
package runtime
import (
"path/filepath"
"strings"
)
// Compose labels set by podman-compose on every container it creates.
const (
LabelPodmanComposeProject = "io.podman.compose.project"
LabelComposeWorkingDir = "com.docker.compose.project.working_dir"
LabelComposeConfigFiles = "com.docker.compose.project.config_files"
)
// Container represents a discovered running container.
type Container struct {
Name string
Image string
Command string
Runtime string // "podman" or "docker"
User string // empty = root/system scope
Labels map[string]string
}
// Project returns the podman-compose project the container belongs to, or "".
func (c Container) Project() string {
return c.Labels[LabelPodmanComposeProject]
}
// WorkingDir returns the compose project working directory, or "".
func (c Container) WorkingDir() string {
return c.Labels[LabelComposeWorkingDir]
}
// ComposeFiles returns the compose files used to create the container.
// Relative paths are relative to WorkingDir.
func (c Container) ComposeFiles() []string {
raw := c.Labels[LabelComposeConfigFiles]
if raw == "" {
return nil
}
var files []string
for _, f := range strings.Split(raw, ",") {
if f = strings.TrimSpace(f); f != "" {
files = append(files, filepath.Clean(f))
}
}
return files
}
// Runtime is the interface all container runtimes must implement.
// user selects the scope: "" = root/system, otherwise a rootless user.
type Runtime interface {
Name() string
ListRunning() ([]Container, error)
Exists(name string) (bool, error)
ListRunning(user string) ([]Container, error)
Exists(user, name string) (bool, error)
}
// Available returns all supported runtimes (callers skip those with no binary).
// Available returns all supported runtimes in priority order: podman first,
// docker as fallback (callers skip those with no binary).
func Available() []Runtime {
return []Runtime{&Podman{}, &Docker{}}
}
+80 -2
View File
@@ -1,6 +1,8 @@
package runtime
import (
"os"
"reflect"
"testing"
)
@@ -53,7 +55,7 @@ func TestAvailable_ReturnsRuntimes(t *testing.T) {
func TestPodman_ListRunning_NoBinary(t *testing.T) {
// This test is meaningful if podman isn't installed; if it is, we just check no error occurs
p := &Podman{}
containers, err := p.ListRunning()
containers, err := p.ListRunning("")
if err != nil {
t.Errorf("ListRunning() should not error when podman is absent: %v", err)
}
@@ -63,9 +65,85 @@ func TestPodman_ListRunning_NoBinary(t *testing.T) {
func TestDocker_ListRunning_NoBinary(t *testing.T) {
d := &Docker{}
containers, err := d.ListRunning()
containers, err := d.ListRunning("")
if err != nil {
t.Errorf("ListRunning() should not error when docker is absent: %v", err)
}
_ = containers
}
func TestAvailable_PodmanFirst(t *testing.T) {
runtimes := Available()
if len(runtimes) < 2 || runtimes[0].Name() != "podman" || runtimes[1].Name() != "docker" {
t.Errorf("Available() order must be podman, docker; got %v", runtimes)
}
}
// Fixture captured from `podman-compose up -d` of testdata/stack/compose.yaml.
func TestParsePodmanPS_ComposeStack(t *testing.T) {
data, err := os.ReadFile("../testdata/stack/podman_ps.json")
if err != nil {
t.Fatalf("reading fixture: %v", err)
}
containers, err := parsePodmanPS(data)
if err != nil {
t.Fatalf("parsePodmanPS() error: %v", err)
}
if len(containers) != 2 {
t.Fatalf("expected 2 containers, got %d", len(containers))
}
for _, c := range containers {
if c.Project() != "unitdore-test" {
t.Errorf("%s: Project() = %q, want unitdore-test", c.Name, c.Project())
}
if c.WorkingDir() == "" {
t.Errorf("%s: WorkingDir() empty", c.Name)
}
if !reflect.DeepEqual(c.ComposeFiles(), []string{"compose.yaml"}) {
t.Errorf("%s: ComposeFiles() = %v", c.Name, c.ComposeFiles())
}
}
}
func TestContainer_NoProject(t *testing.T) {
c := Container{Name: "plain"}
if c.Project() != "" || c.WorkingDir() != "" || c.ComposeFiles() != nil {
t.Errorf("container without labels should have no stack info: %+v", c)
}
}
func TestContainer_ComposeFilesMultiple(t *testing.T) {
c := Container{Labels: map[string]string{LabelComposeConfigFiles: "compose.yaml, ./override.yaml"}}
want := []string{"compose.yaml", "override.yaml"}
if got := c.ComposeFiles(); !reflect.DeepEqual(got, want) {
t.Errorf("ComposeFiles() = %v, want %v", got, want)
}
}
func TestParseDockerPS_Labels(t *testing.T) {
out := []byte(`{"ID":"abc","Names":"web","Image":"nginx","Command":"nginx","State":"running","Labels":"com.docker.compose.project=shop,com.docker.compose.project.config_files=/s/a.yml,/s/b.yml,com.docker.compose.service=web"}`)
cs := parseDockerPS(out)
if len(cs) != 1 {
t.Fatalf("expected 1 container, got %d", len(cs))
}
l := cs[0].Labels
if l["com.docker.compose.project"] != "shop" {
t.Errorf("project label = %q", l["com.docker.compose.project"])
}
if l["com.docker.compose.project.config_files"] != "/s/a.yml,/s/b.yml" {
t.Errorf("config_files label = %q", l["com.docker.compose.project.config_files"])
}
if l["com.docker.compose.service"] != "web" {
t.Errorf("service label = %q", l["com.docker.compose.service"])
}
// Docker compose stacks are not grouped yet (podman-compose only)
if cs[0].Project() != "" {
t.Errorf("docker compose container should not report a podman-compose project")
}
}
func TestPodName(t *testing.T) {
if PodName("shop") != "pod_shop" {
t.Errorf("PodName() = %q", PodName("shop"))
}
}
+134 -24
View File
@@ -2,6 +2,8 @@ package systemd
import (
"fmt"
"os"
"path/filepath"
"strings"
"text/template"
@@ -12,16 +14,27 @@ const systemUnitTemplate = `# /etc/systemd/system/{{.ServiceName}}
# Generated by unitdore — do not edit manually
[Unit]
Description=Unitdore: {{.Unit.Name}} ({{.Unit.Runtime}})
After=network.target
Description=Unitdore: {{.Unit.Name}} ({{.Label}})
After=network.target{{with .RuntimeService}} {{.}}
Requires={{.}}{{end}}
[Service]
Type=simple
User={{.ServiceUser}}
Type={{.ServiceType}}
{{- if eq .ServiceType "oneshot"}}
RemainAfterExit=yes
{{- end}}
{{- with .WorkingDir}}
WorkingDirectory={{.}}
{{- 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,26 +44,40 @@ const userUnitTemplate = `# ~/.config/systemd/user/{{.ServiceName}}
# Generated by unitdore — do not edit manually
[Unit]
Description=Unitdore: {{.Unit.Name}} ({{.Unit.Runtime}})
Description=Unitdore: {{.Unit.Name}} ({{.Label}})
After=default.target
[Service]
Type=simple
Type={{.ServiceType}}
{{- if eq .ServiceType "oneshot"}}
RemainAfterExit=yes
{{- end}}
{{- with .WorkingDir}}
WorkingDirectory={{.}}
{{- end}}
ExecStart={{.ExecStart}}
ExecStop={{.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
`
type templateData struct {
ServiceName string
Unit config.Unit
ExecStart string
ExecStop string
ServiceUser string
ServiceName string
Unit config.Unit
ExecStart string
ExecStop string
RuntimeService string
Label string // shown in Description
ServiceType string // simple | oneshot
WorkingDir string
}
// ServiceName returns the systemd service name for a unit, with optional prefix/suffix.
@@ -59,15 +86,26 @@ func ServiceName(u config.Unit, prefix, suffix string) string {
}
// Generate produces the .service file content for a unit.
func Generate(u config.Unit, prefix, suffix, serviceUser string) (string, error) {
execStart, execStop := buildExecCommands(u)
func Generate(u config.Unit, prefix, suffix string) (string, error) {
data := templateData{
ServiceName: ServiceName(u, prefix, suffix),
Unit: u,
ExecStart: execStart,
ExecStop: execStop,
ServiceUser: serviceUser,
ServiceName: ServiceName(u, prefix, suffix),
Unit: u,
RuntimeService: runtimeService(u.Runtime),
Label: u.Runtime,
ServiceType: "simple",
}
switch {
case u.IsStack() && u.Command == "" && StackMode(u) == StackModeCompose:
data.ExecStart, data.ExecStop = buildComposeCommands(u)
data.Label = "podman-compose stack"
data.WorkingDir = u.WorkingDir
case u.IsStack() && u.Command == "":
data.ExecStart, data.ExecStop = buildPodCommands(u)
data.Label = "podman pod"
data.ServiceType = "oneshot"
default:
data.ExecStart, data.ExecStop = buildExecCommands(u)
}
tmplStr := systemUnitTemplate
@@ -87,6 +125,15 @@ func Generate(u config.Unit, prefix, suffix, serviceUser string) (string, error)
return buf.String(), nil
}
// runtimeService returns the systemd service name that must be running for the
// given container runtime, or "" if none is required (e.g. daemonless podman).
func runtimeService(runtime string) string {
if runtime == "docker" {
return "docker.service"
}
return ""
}
func buildExecCommands(u config.Unit) (start, stop string) {
// If a custom command is provided, use it directly
if u.Command != "" {
@@ -98,7 +145,7 @@ func buildExecCommands(u config.Unit) (start, stop string) {
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)
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)
@@ -106,3 +153,66 @@ func buildExecCommands(u config.Unit) (start, stop string) {
}
return start, stop
}
// Stack modes: compose (podman-compose up/stop) with pod start/stop as fallback.
const (
StackModeCompose = "compose"
StackModePod = "pod"
)
// StackMode picks how a stack unit is started: compose when the compose binary,
// working dir and compose files all exist; otherwise pod start/stop.
func StackMode(u config.Unit) string {
if ComposeAvailable(u) {
return StackModeCompose
}
return StackModePod
}
// ComposeAvailable reports whether the stack can be driven by podman-compose.
func ComposeAvailable(u config.Unit) bool {
if u.ComposeBin == "" || u.WorkingDir == "" {
return false
}
if !exists(u.ComposeBin) || !exists(u.WorkingDir) {
return false
}
for _, f := range u.ComposeFiles {
if !filepath.IsAbs(f) {
f = filepath.Join(u.WorkingDir, f)
}
if !exists(f) {
return false
}
}
return true
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func buildComposeCommands(u config.Unit) (start, stop string) {
args := []string{quoteArg(u.ComposeBin), "-p", quoteArg(u.Project)}
for _, f := range u.ComposeFiles {
args = append(args, "-f", quoteArg(f))
}
base := strings.Join(args, " ")
return base + " up", base + " stop"
}
func buildPodCommands(u config.Unit) (start, stop string) {
pod := quoteArg("pod_" + u.Project)
return "/usr/bin/podman pod start " + pod, "/usr/bin/podman pod stop " + pod
}
// quoteArg double-quotes an argument for systemd command lines if it
// contains whitespace or quotes.
func quoteArg(s string) string {
if !strings.ContainsAny(s, " \t\"'\\") {
return s
}
r := strings.NewReplacer(`\`, `\\`, `"`, `\"`)
return `"` + r.Replace(s) + `"`
}
+202 -15
View File
@@ -1,6 +1,8 @@
package systemd
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -35,7 +37,7 @@ func TestGenerate_SystemUnit(t *testing.T) {
Enabled: true,
}
content, err := Generate(u, "", "", "unitdore")
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
@@ -49,8 +51,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",
}
@@ -59,6 +60,16 @@ func TestGenerate_SystemUnit(t *testing.T) {
t.Errorf("Generate() missing %q in output:\n%s", check, content)
}
}
if strings.Contains(content, "User=") {
t.Errorf("Generate() system unit should not contain User= directive:\n%s", content)
}
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) {
@@ -70,7 +81,7 @@ func TestGenerate_UserUnit(t *testing.T) {
Enabled: true,
}
content, err := Generate(u, "", "", "unitdore")
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
@@ -78,8 +89,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 {
@@ -93,6 +104,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",
@@ -101,7 +157,7 @@ func TestGenerate_CustomCommand(t *testing.T) {
Enabled: true,
}
content, err := Generate(u, "", "", "unitdore")
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
@@ -118,16 +174,21 @@ func TestGenerate_DockerRuntime(t *testing.T) {
Enabled: true,
}
content, err := Generate(u, "", "", "unitdore")
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
if !strings.Contains(content, "ExecStart=/usr/bin/docker start redis") {
t.Errorf("Generate() wrong ExecStart for docker:\n%s", content)
checks := []string{
"ExecStart=/usr/bin/docker start -a redis",
"ExecStop=-/usr/bin/docker stop redis",
"After=network.target docker.service",
"Requires=docker.service",
}
if !strings.Contains(content, "ExecStop=/usr/bin/docker stop redis") {
t.Errorf("Generate() wrong ExecStop for docker:\n%s", content)
for _, check := range checks {
if !strings.Contains(content, check) {
t.Errorf("Generate() docker runtime missing %q in output:\n%s", check, content)
}
}
}
@@ -138,7 +199,7 @@ func TestGenerate_UnknownRuntime(t *testing.T) {
Enabled: true,
}
content, err := Generate(u, "", "", "unitdore")
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() should not error on unknown runtime: %v", err)
}
@@ -155,7 +216,7 @@ func TestGenerate_WithPrefixSuffix(t *testing.T) {
Enabled: true,
}
content, err := Generate(u, "prod-", "-web", "unitdore")
content, err := Generate(u, "prod-", "-web")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
@@ -181,7 +242,7 @@ func TestBuildExecCommands(t *testing.T) {
{
name: "docker",
unit: config.Unit{Name: "app", Runtime: "docker"},
wantStart: "/usr/bin/docker start app",
wantStart: "/usr/bin/docker start -a app",
wantStop: "/usr/bin/docker stop app",
},
{
@@ -204,3 +265,129 @@ func TestBuildExecCommands(t *testing.T) {
})
}
}
// newComposeStack creates a stack unit whose compose binary, working dir and
// compose file exist on disk (compose mode).
func newComposeStack(t *testing.T) config.Unit {
t.Helper()
dir := t.TempDir()
bin := filepath.Join(dir, "podman-compose")
if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte("services: {}\n"), 0644); err != nil {
t.Fatal(err)
}
return config.Unit{
Name: "shop",
Runtime: "podman",
Kind: config.KindStack,
Project: "shop",
WorkingDir: dir,
ComposeFiles: []string{"compose.yaml"},
ComposeBin: bin,
Enabled: true,
}
}
func TestStackMode(t *testing.T) {
u := newComposeStack(t)
if StackMode(u) != StackModeCompose {
t.Errorf("StackMode() = %q, want compose", StackMode(u))
}
missingFile := u
missingFile.ComposeFiles = []string{"gone.yaml"}
if StackMode(missingFile) != StackModePod {
t.Error("missing compose file should fall back to pod mode")
}
missingBin := u
missingBin.ComposeBin = ""
if StackMode(missingBin) != StackModePod {
t.Error("missing compose binary should fall back to pod mode")
}
missingDir := u
missingDir.WorkingDir = "/nonexistent/unitdore-test"
if StackMode(missingDir) != StackModePod {
t.Error("missing working dir should fall back to pod mode")
}
}
func TestGenerate_StackCompose(t *testing.T) {
u := newComposeStack(t)
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
base := u.ComposeBin + " -p shop -f compose.yaml"
for _, want := range []string{
"Description=Unitdore: shop (podman-compose stack)",
"Type=simple",
"WorkingDirectory=" + u.WorkingDir,
"ExecStart=" + base + " up",
"ExecStop=-" + base + " stop",
"WantedBy=multi-user.target",
} {
if !strings.Contains(content, want) {
t.Errorf("Generate() missing %q in output:\n%s", want, content)
}
}
if strings.Contains(content, "RemainAfterExit") || strings.Contains(content, "Requires=") {
t.Errorf("compose stack unit has unexpected directives:\n%s", content)
}
}
func TestGenerate_StackPodFallback(t *testing.T) {
u := newComposeStack(t)
u.ComposeBin = ""
u.User = "hein"
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
for _, want := range []string{
"Description=Unitdore: shop (podman pod)",
"Type=oneshot",
"RemainAfterExit=yes",
"ExecStart=/usr/bin/podman pod start pod_shop",
"ExecStop=-/usr/bin/podman pod stop pod_shop",
"WantedBy=default.target",
} {
if !strings.Contains(content, want) {
t.Errorf("Generate() missing %q in output:\n%s", want, content)
}
}
if strings.Contains(content, "WorkingDirectory=") {
t.Errorf("pod fallback should not set WorkingDirectory:\n%s", content)
}
}
func TestGenerate_StackCustomCommand(t *testing.T) {
u := newComposeStack(t)
u.Command = "/usr/local/bin/stack.sh"
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
if !strings.Contains(content, "ExecStart=/usr/local/bin/stack.sh") {
t.Errorf("custom command should override stack commands:\n%s", content)
}
}
func TestBuildComposeCommands_Quoting(t *testing.T) {
u := config.Unit{
Project: "shop",
ComposeBin: "/usr/bin/podman-compose",
ComposeFiles: []string{"compose.yaml", "my override.yaml"},
}
start, stop := buildComposeCommands(u)
wantBase := `/usr/bin/podman-compose -p shop -f compose.yaml -f "my override.yaml"`
if start != wantBase+" up" {
t.Errorf("start = %q", start)
}
if stop != wantBase+" stop" {
t.Errorf("stop = %q", stop)
}
}
+13 -20
View File
@@ -8,6 +8,7 @@ import (
"strings"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/sysuser"
)
const systemUnitDir = "/etc/systemd/system"
@@ -19,7 +20,7 @@ func UnitPath(u config.Unit, prefix, suffix string) (string, error) {
return filepath.Join(systemUnitDir, svcName), nil
}
// Rootless: write to the user's systemd config dir
home, err := userHomeDir(u.User)
home, err := sysuser.HomeDir(u.User)
if err != nil {
return "", err
}
@@ -27,8 +28,8 @@ func UnitPath(u config.Unit, prefix, suffix string) (string, error) {
}
// Install writes the .service file for a unit and reloads systemd.
func Install(u config.Unit, prefix, suffix, serviceUser string) error {
content, err := Generate(u, prefix, suffix, serviceUser)
func Install(u config.Unit, prefix, suffix string) error {
content, err := Generate(u, prefix, suffix)
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
}
+123
View File
@@ -0,0 +1,123 @@
package systemd
import (
"sort"
"strings"
"testing"
"github.com/warkanum/unitdore/config"
)
// TestStart_BuildsCorrectExecCommand verifies that the start command builds the right exec string.
func TestStart_BuildsCorrectExecCommand(t *testing.T) {
u := config.Unit{Name: "nginx", Runtime: "podman"}
start, stop := buildExecCommands(u)
if !strings.Contains(start, "/usr/bin/podman start -a nginx") {
t.Errorf("expected podman start command in %q", start)
}
if !strings.Contains(stop, "/usr/bin/podman stop nginx") {
t.Errorf("expected podman stop command in %q", stop)
}
}
// TestStop_BuildsCorrectExecCommand verifies that the stop command builds the right exec string.
func TestStop_BuildsCorrectExecCommand(t *testing.T) {
u := config.Unit{Name: "nginx", Runtime: "podman"}
start, stop := buildExecCommands(u)
if !strings.Contains(start, "/usr/bin/podman start -a nginx") {
t.Errorf("expected podman start command in %q", start)
}
if !strings.Contains(stop, "/usr/bin/podman stop nginx") {
t.Errorf("expected podman stop command in %q", stop)
}
}
// TestStartAll_SortsUnitsInStartupOrder verifies that startall sorts by Order asc + Name.
func TestStartAll_SortsUnitsInStartupOrder(t *testing.T) {
u1 := config.Unit{Name: "app2", Runtime: "podman", Order: 5, Enabled: true}
u2 := config.Unit{Name: "app1", Runtime: "podman", Order: 1, Enabled: true}
u3 := config.Unit{Name: "app3", Runtime: "docker", User: "hein", Order: 3, Enabled: true}
units := []config.Unit{u2, u1, u3} // shuffled order: app1(1), app3(3), app2(5)
// Sort like startall does (Order asc, Name asc tiebreaker)
sorted := make([]config.Unit, len(units))
copy(sorted, units)
sort.Slice(sorted, func(i, j int) bool {
if sorted[i].Order != sorted[j].Order {
return sorted[i].Order < sorted[j].Order
}
return sorted[i].Name < sorted[j].Name
})
// Expected order: app1(1), app3(3), app2(5) by Order asc
if sorted[0].Name != "app1" {
t.Errorf("expected first unit to be app1 (order 1), got %s", sorted[0].Name)
}
if sorted[1].Name != "app3" {
t.Errorf("expected second unit to be app3 (order 3), got %s", sorted[1].Name)
}
if sorted[2].Name != "app2" {
t.Errorf("expected third unit to be app2 (order 5), got %s", sorted[2].Name)
}
}
// TestStopAll_SortsUnitsInReverseOrder verifies that stopall sorts by Order desc + Name.
func TestStopAll_SortsUnitsInReverseOrder(t *testing.T) {
u1 := config.Unit{Name: "app2", Runtime: "podman", Order: 5, Enabled: true}
u2 := config.Unit{Name: "app1", Runtime: "podman", Order: 1, Enabled: true}
u3 := config.Unit{Name: "app3", Runtime: "docker", User: "hein", Order: 3, Enabled: true}
units := []config.Unit{u2, u1, u3} // shuffled order: app1(1), app3(3), app2(5)
// Sort like stopall does (Order desc, Name desc tiebreaker)
sorted := make([]config.Unit, len(units))
copy(sorted, units)
sort.Slice(sorted, func(i, j int) bool {
if sorted[i].Order != sorted[j].Order {
return sorted[i].Order > sorted[j].Order
}
return sorted[i].Name > sorted[j].Name
})
// Expected order: app2(5), app3(3), app1(1) by Order desc
if sorted[0].Name != "app2" {
t.Errorf("expected first unit to be app2 (order 5), got %s", sorted[0].Name)
}
if sorted[1].Name != "app3" {
t.Errorf("expected second unit to be app3 (order 3), got %s", sorted[1].Name)
}
if sorted[2].Name != "app1" {
t.Errorf("expected third unit to be app1 (order 1), got %s", sorted[2].Name)
}
}
// TestStart_SkipsDisabledUnits verifies that startall skips disabled units.
func TestStart_SkipsDisabledUnits(t *testing.T) {
u1 := config.Unit{Name: "app1", Runtime: "podman", Order: 1, Enabled: true}
u2 := config.Unit{Name: "app2", Runtime: "podman", Order: 2, Enabled: false}
units := []config.Unit{u1, u2}
enabledCount := 0
for _, u := range units {
if !u.Enabled {
continue // skip disabled like startall does
}
enabledCount++
}
if enabledCount != 1 {
t.Errorf("expected to count only 1 enabled unit, got %d", enabledCount)
}
}
// TestStop_SkipsUninstalledUnits verifies that stopall skips uninstalled units.
func TestStop_SkipsUninstalledUnits(t *testing.T) {
u := config.Unit{Name: "app1", Runtime: "podman", Order: 1, Enabled: true}
units := []config.Unit{u}
for _, unit := range units {
if !IsInstalled(unit, "", "") {
continue // skip uninstalled like stopall does
}
}
t.Log("uninstalled units are skipped (no service files exist in test env)")
}
+95
View File
@@ -0,0 +1,95 @@
// Package sysuser runs commands as the current process or as another user,
// and answers questions about local user accounts.
package sysuser
import (
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
)
// IsRoot reports whether the process runs with euid 0.
func IsRoot() bool { return os.Geteuid() == 0 }
// Current returns the username of the effective user.
func Current() string {
u, err := user.LookupId(fmt.Sprint(os.Geteuid()))
if err != nil {
return ""
}
return u.Username
}
// IsSelf reports whether name is the effective user (or empty).
func IsSelf(name string) bool {
return name == "" || name == Current()
}
// HomeDir returns the home directory of the given user.
func HomeDir(name string) (string, error) {
u, err := user.Lookup(name)
if err != nil {
return "", fmt.Errorf("looking up user %s: %w", name, err)
}
return u.HomeDir, nil
}
// UID returns the numeric uid of the given user as a string.
func UID(name string) (string, error) {
u, err := user.Lookup(name)
if err != nil {
return "", fmt.Errorf("looking up user %s: %w", name, err)
}
return u.Uid, nil
}
// Command builds a command that runs bin as the given user.
// An empty user, or the current user, runs it directly. Otherwise runuser is
// used (requires root) with XDG_RUNTIME_DIR pointed at the user's runtime dir,
// which rootless podman and systemctl --user need.
func Command(name string, bin string, args ...string) (*exec.Cmd, error) {
if IsSelf(name) {
return exec.Command(bin, args...), nil
}
if !IsRoot() {
return nil, fmt.Errorf("running as user %s requires root", name)
}
uid, err := UID(name)
if err != nil {
return nil, err
}
full := append([]string{"-u", name, "--", "env",
"XDG_RUNTIME_DIR=/run/user/" + uid,
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/" + uid + "/bus",
bin}, args...)
return exec.Command("runuser", full...), nil
}
// LookPath finds bin in PATH, then in the user's ~/.local/bin.
// Returns "" if not found.
func LookPath(name string, bin string) string {
if p, err := exec.LookPath(bin); err == nil {
return p
}
if name == "" {
name = Current()
}
home, err := HomeDir(name)
if err != nil {
return ""
}
p := filepath.Join(home, ".local", "bin", bin)
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
return p
}
return ""
}
// LingerEnabled reports whether systemd lingering is enabled for the user,
// so user units start at boot without a login session.
func LingerEnabled(name string) bool {
_, err := os.Stat(filepath.Join("/var/lib/systemd/linger", name))
return err == nil
}
+31
View File
@@ -0,0 +1,31 @@
package sysuser
import "testing"
func TestIsSelf(t *testing.T) {
if !IsSelf("") {
t.Error("empty user must be self")
}
if cur := Current(); cur != "" && !IsSelf(cur) {
t.Errorf("current user %q must be self", cur)
}
}
func TestCommand_Self(t *testing.T) {
cmd, err := Command(Current(), "true")
if err != nil {
t.Fatalf("Command() error: %v", err)
}
if cmd.Args[0] != "true" {
t.Errorf("self command should run directly, got %v", cmd.Args)
}
}
func TestCommand_OtherUserRequiresRoot(t *testing.T) {
if IsRoot() {
t.Skip("running as root")
}
if _, err := Command("root", "true"); err == nil {
t.Error("running as another user without root should error")
}
}
+15
View File
@@ -0,0 +1,15 @@
# Test stack for unitdore compose-stack detection.
# Run: podman-compose -p unitdore-test up -d
name: unitdore-test
services:
web:
image: docker.io/library/alpine:latest
init: true
command: ["sleep", "infinity"]
depends_on:
- db
db:
image: docker.io/library/alpine:latest
init: true
command: ["sleep", "infinity"]
+100
View File
@@ -0,0 +1,100 @@
[
{
"AutoRemove": false,
"Command": [
"sleep",
"infinity"
],
"CreatedAt": "6 seconds ago",
"CIDFile": "",
"Exited": false,
"ExitedAt": -62135596800,
"ExitCode": 0,
"ExposedPorts": null,
"Id": "6d5a1e4d8ab39d64b26842a0e50b5943cfa62d71c70d63ce5c2ad92f598811b9",
"Image": "docker.io/library/alpine:latest",
"ImageID": "a40c03cbb81c59bfb0e0887ab0b1859727075da7b9cc576a1cec2c771f38c5fb",
"IsInfra": false,
"Labels": {
"PODMAN_SYSTEMD_UNIT": "podman-compose@unitdore-test.service",
"com.docker.compose.container-number": "1",
"com.docker.compose.project": "unitdore-test",
"com.docker.compose.project.config_files": "compose.yaml",
"com.docker.compose.project.working_dir": "/home/warkanum/dev/unitdore/testdata/stack",
"com.docker.compose.service": "db",
"io.podman.compose.config-hash": "1a54723a4ead4531f2ddce5aa7e85019b4ebd1612ca8973b85cd9026f4f87949",
"io.podman.compose.project": "unitdore-test",
"io.podman.compose.service": "db",
"io.podman.compose.version": "1.6.0"
},
"Mounts": [],
"Names": [
"unitdore-test_db_1"
],
"Namespaces": {
},
"Networks": [
"unitdore-test_default"
],
"Pid": 75799,
"Pod": "235775e179ed8413b84b823ff63a812a053d929b8399b2d48cc19f9a4eeef410",
"PodName": "",
"Ports": null,
"Restarts": 0,
"Size": null,
"StartedAt": 1791823934,
"State": "running",
"Status": "Up 6 seconds",
"Created": 1791823933
},
{
"AutoRemove": false,
"Command": [
"sleep",
"infinity"
],
"CreatedAt": "6 seconds ago",
"CIDFile": "",
"Exited": false,
"ExitedAt": -62135596800,
"ExitCode": 0,
"ExposedPorts": null,
"Id": "b6b364f2a885baa583b7112cd7d37832e7f11f9091698ef76ca4c44259e501cc",
"Image": "docker.io/library/alpine:latest",
"ImageID": "a40c03cbb81c59bfb0e0887ab0b1859727075da7b9cc576a1cec2c771f38c5fb",
"IsInfra": false,
"Labels": {
"PODMAN_SYSTEMD_UNIT": "podman-compose@unitdore-test.service",
"com.docker.compose.container-number": "1",
"com.docker.compose.project": "unitdore-test",
"com.docker.compose.project.config_files": "compose.yaml",
"com.docker.compose.project.working_dir": "/home/warkanum/dev/unitdore/testdata/stack",
"com.docker.compose.service": "web",
"io.podman.compose.config-hash": "f38934df837f52ead7f2848b58acef10380a7929125a7c4f61199ad5b557302f",
"io.podman.compose.project": "unitdore-test",
"io.podman.compose.service": "web",
"io.podman.compose.version": "1.6.0"
},
"Mounts": [],
"Names": [
"unitdore-test_web_1"
],
"Namespaces": {
},
"Networks": [
"unitdore-test_default"
],
"Pid": 75919,
"Pod": "235775e179ed8413b84b823ff63a812a053d929b8399b2d48cc19f9a4eeef410",
"PodName": "",
"Ports": null,
"Restarts": 0,
"Size": null,
"StartedAt": 1791823934,
"State": "running",
"Status": "Up 6 seconds",
"Created": 1791823933
}
]