Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
173b4b2523 | ||
|
|
cb4413b840 | ||
|
|
f0827fcdd7 | ||
|
|
cc4829046d | ||
|
|
13817f8d34 | ||
|
|
5026ae4d16 | ||
|
|
917480670f | ||
|
|
029b9cee2e | ||
|
|
e6ef6e11d6 | ||
|
|
f9dcb0b561 | ||
|
|
69069a2196 | ||
|
|
2efccc5d4f | ||
|
|
b58373ad2f | ||
|
|
a273232303 | ||
|
|
fae49a258c | ||
|
|
00bc6ed893 | ||
|
|
b44546dc24 | ||
|
|
c6198ea6b7 | ||
|
|
9ef31866f1 | ||
|
|
77b86dc3fc | ||
|
|
0999303cd3 | ||
|
|
384c4592d1 | ||
|
|
815bdfed80 | ||
|
|
243da39fe3 | ||
|
|
0a1e768dfe | ||
|
|
e66f869752 | ||
|
|
2c0f51422e |
+136
-56
@@ -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,2 +1,3 @@
|
||||
unitdore
|
||||
*.exe
|
||||
.codex
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,35 @@
|
||||
# AI Usage Declaration
|
||||
|
||||
This Go project utilizes AI tools for the following purposes:
|
||||
|
||||
- Generating and improving documentation
|
||||
- Writing and enhancing tests
|
||||
- Refactoring and optimizing existing code
|
||||
|
||||
AI is **not** used for core design or architecture decisions.
|
||||
All design decisions are deferred to human discussion.
|
||||
AI is employed only for enhancements to human-written code.
|
||||
|
||||
We are aware of significant AI hallucinations; all AI-generated content is to be reviewed and verified by humans.
|
||||
|
||||
|
||||
.-""""""-.
|
||||
.' '.
|
||||
/ O O \
|
||||
: ` :
|
||||
| |
|
||||
: .------. :
|
||||
\ ' ' /
|
||||
'. .'
|
||||
'-......-'
|
||||
MEGAMIND AI
|
||||
[============]
|
||||
|
||||
___________
|
||||
/___________\
|
||||
/_____________\
|
||||
| ASSIMILATE |
|
||||
| RESISTANCE |
|
||||
| IS FUTILE |
|
||||
\_____________/
|
||||
\___________/
|
||||
@@ -0,0 +1,5 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
You must now read the./AGENTS.md
|
||||
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -27,7 +27,8 @@ Think of it as: **config → systemd units → running services.**
|
||||
## Non-Goals
|
||||
|
||||
- Not a container runtime (doesn't replace Podman/Docker)
|
||||
- Not Kubernetes / Compose / Quadlet
|
||||
- Not Kubernetes / Quadlet
|
||||
- Compose: only wraps existing podman-compose stacks (one unit per project); docker compose planned
|
||||
- Not a daemon — runs on demand
|
||||
|
||||
---
|
||||
@@ -172,6 +173,8 @@ unitdore/
|
||||
| 5 | Reconcile (auto-disable missing units) in syncup |
|
||||
| 6 | User unit support (rootless containers) |
|
||||
| 7 | Polish: `edit`, error handling, --dry-run flag |
|
||||
| 8 | podman-compose stacks (compose + pod fallback), root/user scopes |
|
||||
| 9 | docker compose stacks (`com.docker.compose.*` labels) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,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
|
||||
|
||||
@@ -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
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/warkanum/unitdore/sysuser"
|
||||
)
|
||||
|
||||
type Docker struct{}
|
||||
@@ -17,21 +19,44 @@ type dockerContainer struct {
|
||||
Image string `json:"Image"`
|
||||
Command string `json:"Command"`
|
||||
State string `json:"State"`
|
||||
Labels string `json:"Labels"`
|
||||
}
|
||||
|
||||
func (d *Docker) ListRunning() ([]Container, error) {
|
||||
// dockerCmd builds a docker command for the given user scope.
|
||||
// Returns nil if docker is not installed.
|
||||
func dockerCmd(user string, args ...string) (*exec.Cmd, error) {
|
||||
bin, err := exec.LookPath("docker")
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return sysuser.Command(user, bin, args...)
|
||||
}
|
||||
|
||||
func (d *Docker) ListRunning(user string) ([]Container, error) {
|
||||
cmd, err := dockerCmd(user, "ps", "--format", "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cmd == nil {
|
||||
return nil, nil // docker not installed — not an error
|
||||
}
|
||||
|
||||
out, err := exec.Command(bin, "ps", "--format", "json").Output()
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Docker installed but daemon not running — treat as no containers
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Docker outputs one JSON object per line (not a JSON array)
|
||||
containers := parseDockerPS(out)
|
||||
for i := range containers {
|
||||
containers[i].User = user
|
||||
}
|
||||
return containers, nil
|
||||
}
|
||||
|
||||
// parseDockerPS parses `docker ps --format json` output
|
||||
// (one JSON object per line, not a JSON array).
|
||||
func parseDockerPS(out []byte) []Container {
|
||||
var containers []Container
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if line == "" {
|
||||
@@ -57,18 +82,45 @@ func (d *Docker) ListRunning() ([]Container, error) {
|
||||
Image: c.Image,
|
||||
Command: c.Command,
|
||||
Runtime: "docker",
|
||||
Labels: parseDockerLabels(c.Labels),
|
||||
})
|
||||
}
|
||||
return containers, nil
|
||||
return containers
|
||||
}
|
||||
|
||||
func (d *Docker) Exists(name string) (bool, error) {
|
||||
bin, err := exec.LookPath("docker")
|
||||
// parseDockerLabels parses docker's "k=v,k=v" label string. A segment without
|
||||
// "=" is a continuation of the previous value (values may contain commas,
|
||||
// e.g. multiple compose config files).
|
||||
func parseDockerLabels(s string) map[string]string {
|
||||
labels := map[string]string{}
|
||||
last := ""
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(part, "=")
|
||||
if !ok {
|
||||
if last != "" {
|
||||
labels[last] += "," + part
|
||||
}
|
||||
continue
|
||||
}
|
||||
labels[k] = v
|
||||
last = k
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
func (d *Docker) Exists(user, name string) (bool, error) {
|
||||
cmd, err := dockerCmd(user, "ps", "--format", "{{.Names}}")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if cmd == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
out, err := exec.Command(bin, "ps", "--format", "{{.Names}}").Output()
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("docker ps: %w", err)
|
||||
}
|
||||
|
||||
+65
-11
@@ -5,6 +5,8 @@ import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/warkanum/unitdore/sysuser"
|
||||
)
|
||||
|
||||
type Podman struct{}
|
||||
@@ -12,28 +14,55 @@ type Podman struct{}
|
||||
func (p *Podman) Name() string { return "podman" }
|
||||
|
||||
type podmanContainer struct {
|
||||
ID string `json:"Id"`
|
||||
Names []string `json:"Names"`
|
||||
Image string `json:"Image"`
|
||||
Command []string `json:"Command"`
|
||||
State string `json:"State"`
|
||||
ID string `json:"Id"`
|
||||
Names []string `json:"Names"`
|
||||
Image string `json:"Image"`
|
||||
Command []string `json:"Command"`
|
||||
State string `json:"State"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
}
|
||||
|
||||
func (p *Podman) ListRunning() ([]Container, error) {
|
||||
// podmanCmd builds a podman command for the given user scope.
|
||||
// Returns nil if podman is not installed.
|
||||
func podmanCmd(user string, args ...string) (*exec.Cmd, error) {
|
||||
bin, err := exec.LookPath("podman")
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return sysuser.Command(user, bin, args...)
|
||||
}
|
||||
|
||||
func (p *Podman) ListRunning(user string) ([]Container, error) {
|
||||
cmd, err := podmanCmd(user, "ps", "--format", "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cmd == nil {
|
||||
return nil, nil // podman not installed — not an error
|
||||
}
|
||||
|
||||
out, err := exec.Command(bin, "ps", "--format", "json").Output()
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Podman installed but not usable — treat as no containers
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
containers, err := parsePodmanPS(out)
|
||||
if err != nil {
|
||||
// Podman installed but output is not valid JSON (e.g. OCI runtime misconfigured)
|
||||
return nil, nil
|
||||
}
|
||||
for i := range containers {
|
||||
containers[i].User = user
|
||||
}
|
||||
return containers, nil
|
||||
}
|
||||
|
||||
// parsePodmanPS parses `podman ps --format json` output (a JSON array).
|
||||
func parsePodmanPS(out []byte) ([]Container, error) {
|
||||
var raw []podmanContainer
|
||||
if err := json.Unmarshal(out, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parsing podman output: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var containers []Container
|
||||
@@ -57,18 +86,22 @@ func (p *Podman) ListRunning() ([]Container, error) {
|
||||
Image: c.Image,
|
||||
Command: strings.Join(c.Command, " "),
|
||||
Runtime: "podman",
|
||||
Labels: c.Labels,
|
||||
})
|
||||
}
|
||||
return containers, nil
|
||||
}
|
||||
|
||||
func (p *Podman) Exists(name string) (bool, error) {
|
||||
bin, err := exec.LookPath("podman")
|
||||
func (p *Podman) Exists(user, name string) (bool, error) {
|
||||
cmd, err := podmanCmd(user, "ps", "--format", "{{.Names}}")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if cmd == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
out, err := exec.Command(bin, "ps", "--format", "{{.Names}}").Output()
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("podman ps: %w", err)
|
||||
}
|
||||
@@ -80,3 +113,24 @@ func (p *Podman) Exists(name string) (bool, error) {
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// PodExists reports whether a pod with the given name exists (any state).
|
||||
func (p *Podman) PodExists(user, pod string) (bool, error) {
|
||||
cmd, err := podmanCmd(user, "pod", "exists", pod)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if cmd == nil {
|
||||
return false, nil
|
||||
}
|
||||
if err := cmd.Run(); err != nil {
|
||||
if _, ok := err.(*exec.ExitError); ok {
|
||||
return false, nil // exit 1 = pod does not exist
|
||||
}
|
||||
return false, fmt.Errorf("podman pod exists: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// PodName returns the pod name podman-compose uses for a project.
|
||||
func PodName(project string) string { return "pod_" + project }
|
||||
|
||||
+45
-3
@@ -1,21 +1,63 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Compose labels set by podman-compose on every container it creates.
|
||||
const (
|
||||
LabelPodmanComposeProject = "io.podman.compose.project"
|
||||
LabelComposeWorkingDir = "com.docker.compose.project.working_dir"
|
||||
LabelComposeConfigFiles = "com.docker.compose.project.config_files"
|
||||
)
|
||||
|
||||
// Container represents a discovered running container.
|
||||
type Container struct {
|
||||
Name string
|
||||
Image string
|
||||
Command string
|
||||
Runtime string // "podman" or "docker"
|
||||
User string // empty = root/system scope
|
||||
Labels map[string]string
|
||||
}
|
||||
|
||||
// Project returns the podman-compose project the container belongs to, or "".
|
||||
func (c Container) Project() string {
|
||||
return c.Labels[LabelPodmanComposeProject]
|
||||
}
|
||||
|
||||
// WorkingDir returns the compose project working directory, or "".
|
||||
func (c Container) WorkingDir() string {
|
||||
return c.Labels[LabelComposeWorkingDir]
|
||||
}
|
||||
|
||||
// ComposeFiles returns the compose files used to create the container.
|
||||
// Relative paths are relative to WorkingDir.
|
||||
func (c Container) ComposeFiles() []string {
|
||||
raw := c.Labels[LabelComposeConfigFiles]
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var files []string
|
||||
for _, f := range strings.Split(raw, ",") {
|
||||
if f = strings.TrimSpace(f); f != "" {
|
||||
files = append(files, filepath.Clean(f))
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// Runtime is the interface all container runtimes must implement.
|
||||
// user selects the scope: "" = root/system, otherwise a rootless user.
|
||||
type Runtime interface {
|
||||
Name() string
|
||||
ListRunning() ([]Container, error)
|
||||
Exists(name string) (bool, error)
|
||||
ListRunning(user string) ([]Container, error)
|
||||
Exists(user, name string) (bool, error)
|
||||
}
|
||||
|
||||
// Available returns all supported runtimes (callers skip those with no binary).
|
||||
// Available returns all supported runtimes in priority order: podman first,
|
||||
// docker as fallback (callers skip those with no binary).
|
||||
func Available() []Runtime {
|
||||
return []Runtime{&Podman{}, &Docker{}}
|
||||
}
|
||||
|
||||
+80
-2
@@ -1,6 +1,8 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -53,7 +55,7 @@ func TestAvailable_ReturnsRuntimes(t *testing.T) {
|
||||
func TestPodman_ListRunning_NoBinary(t *testing.T) {
|
||||
// This test is meaningful if podman isn't installed; if it is, we just check no error occurs
|
||||
p := &Podman{}
|
||||
containers, err := p.ListRunning()
|
||||
containers, err := p.ListRunning("")
|
||||
if err != nil {
|
||||
t.Errorf("ListRunning() should not error when podman is absent: %v", err)
|
||||
}
|
||||
@@ -63,9 +65,85 @@ func TestPodman_ListRunning_NoBinary(t *testing.T) {
|
||||
|
||||
func TestDocker_ListRunning_NoBinary(t *testing.T) {
|
||||
d := &Docker{}
|
||||
containers, err := d.ListRunning()
|
||||
containers, err := d.ListRunning("")
|
||||
if err != nil {
|
||||
t.Errorf("ListRunning() should not error when docker is absent: %v", err)
|
||||
}
|
||||
_ = containers
|
||||
}
|
||||
|
||||
func TestAvailable_PodmanFirst(t *testing.T) {
|
||||
runtimes := Available()
|
||||
if len(runtimes) < 2 || runtimes[0].Name() != "podman" || runtimes[1].Name() != "docker" {
|
||||
t.Errorf("Available() order must be podman, docker; got %v", runtimes)
|
||||
}
|
||||
}
|
||||
|
||||
// Fixture captured from `podman-compose up -d` of testdata/stack/compose.yaml.
|
||||
func TestParsePodmanPS_ComposeStack(t *testing.T) {
|
||||
data, err := os.ReadFile("../testdata/stack/podman_ps.json")
|
||||
if err != nil {
|
||||
t.Fatalf("reading fixture: %v", err)
|
||||
}
|
||||
containers, err := parsePodmanPS(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePodmanPS() error: %v", err)
|
||||
}
|
||||
if len(containers) != 2 {
|
||||
t.Fatalf("expected 2 containers, got %d", len(containers))
|
||||
}
|
||||
for _, c := range containers {
|
||||
if c.Project() != "unitdore-test" {
|
||||
t.Errorf("%s: Project() = %q, want unitdore-test", c.Name, c.Project())
|
||||
}
|
||||
if c.WorkingDir() == "" {
|
||||
t.Errorf("%s: WorkingDir() empty", c.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(c.ComposeFiles(), []string{"compose.yaml"}) {
|
||||
t.Errorf("%s: ComposeFiles() = %v", c.Name, c.ComposeFiles())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainer_NoProject(t *testing.T) {
|
||||
c := Container{Name: "plain"}
|
||||
if c.Project() != "" || c.WorkingDir() != "" || c.ComposeFiles() != nil {
|
||||
t.Errorf("container without labels should have no stack info: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainer_ComposeFilesMultiple(t *testing.T) {
|
||||
c := Container{Labels: map[string]string{LabelComposeConfigFiles: "compose.yaml, ./override.yaml"}}
|
||||
want := []string{"compose.yaml", "override.yaml"}
|
||||
if got := c.ComposeFiles(); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("ComposeFiles() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDockerPS_Labels(t *testing.T) {
|
||||
out := []byte(`{"ID":"abc","Names":"web","Image":"nginx","Command":"nginx","State":"running","Labels":"com.docker.compose.project=shop,com.docker.compose.project.config_files=/s/a.yml,/s/b.yml,com.docker.compose.service=web"}`)
|
||||
cs := parseDockerPS(out)
|
||||
if len(cs) != 1 {
|
||||
t.Fatalf("expected 1 container, got %d", len(cs))
|
||||
}
|
||||
l := cs[0].Labels
|
||||
if l["com.docker.compose.project"] != "shop" {
|
||||
t.Errorf("project label = %q", l["com.docker.compose.project"])
|
||||
}
|
||||
if l["com.docker.compose.project.config_files"] != "/s/a.yml,/s/b.yml" {
|
||||
t.Errorf("config_files label = %q", l["com.docker.compose.project.config_files"])
|
||||
}
|
||||
if l["com.docker.compose.service"] != "web" {
|
||||
t.Errorf("service label = %q", l["com.docker.compose.service"])
|
||||
}
|
||||
// Docker compose stacks are not grouped yet (podman-compose only)
|
||||
if cs[0].Project() != "" {
|
||||
t.Errorf("docker compose container should not report a podman-compose project")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPodName(t *testing.T) {
|
||||
if PodName("shop") != "pod_shop" {
|
||||
t.Errorf("PodName() = %q", PodName("shop"))
|
||||
}
|
||||
}
|
||||
|
||||
+134
-24
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package sysuser runs commands as the current process or as another user,
|
||||
// and answers questions about local user accounts.
|
||||
package sysuser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// IsRoot reports whether the process runs with euid 0.
|
||||
func IsRoot() bool { return os.Geteuid() == 0 }
|
||||
|
||||
// Current returns the username of the effective user.
|
||||
func Current() string {
|
||||
u, err := user.LookupId(fmt.Sprint(os.Geteuid()))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return u.Username
|
||||
}
|
||||
|
||||
// IsSelf reports whether name is the effective user (or empty).
|
||||
func IsSelf(name string) bool {
|
||||
return name == "" || name == Current()
|
||||
}
|
||||
|
||||
// HomeDir returns the home directory of the given user.
|
||||
func HomeDir(name string) (string, error) {
|
||||
u, err := user.Lookup(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("looking up user %s: %w", name, err)
|
||||
}
|
||||
return u.HomeDir, nil
|
||||
}
|
||||
|
||||
// UID returns the numeric uid of the given user as a string.
|
||||
func UID(name string) (string, error) {
|
||||
u, err := user.Lookup(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("looking up user %s: %w", name, err)
|
||||
}
|
||||
return u.Uid, nil
|
||||
}
|
||||
|
||||
// Command builds a command that runs bin as the given user.
|
||||
// An empty user, or the current user, runs it directly. Otherwise runuser is
|
||||
// used (requires root) with XDG_RUNTIME_DIR pointed at the user's runtime dir,
|
||||
// which rootless podman and systemctl --user need.
|
||||
func Command(name string, bin string, args ...string) (*exec.Cmd, error) {
|
||||
if IsSelf(name) {
|
||||
return exec.Command(bin, args...), nil
|
||||
}
|
||||
if !IsRoot() {
|
||||
return nil, fmt.Errorf("running as user %s requires root", name)
|
||||
}
|
||||
uid, err := UID(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
full := append([]string{"-u", name, "--", "env",
|
||||
"XDG_RUNTIME_DIR=/run/user/" + uid,
|
||||
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/" + uid + "/bus",
|
||||
bin}, args...)
|
||||
return exec.Command("runuser", full...), nil
|
||||
}
|
||||
|
||||
// LookPath finds bin in PATH, then in the user's ~/.local/bin.
|
||||
// Returns "" if not found.
|
||||
func LookPath(name string, bin string) string {
|
||||
if p, err := exec.LookPath(bin); err == nil {
|
||||
return p
|
||||
}
|
||||
if name == "" {
|
||||
name = Current()
|
||||
}
|
||||
home, err := HomeDir(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
p := filepath.Join(home, ".local", "bin", bin)
|
||||
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// LingerEnabled reports whether systemd lingering is enabled for the user,
|
||||
// so user units start at boot without a login session.
|
||||
func LingerEnabled(name string) bool {
|
||||
_, err := os.Stat(filepath.Join("/var/lib/systemd/linger", name))
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package sysuser
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsSelf(t *testing.T) {
|
||||
if !IsSelf("") {
|
||||
t.Error("empty user must be self")
|
||||
}
|
||||
if cur := Current(); cur != "" && !IsSelf(cur) {
|
||||
t.Errorf("current user %q must be self", cur)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommand_Self(t *testing.T) {
|
||||
cmd, err := Command(Current(), "true")
|
||||
if err != nil {
|
||||
t.Fatalf("Command() error: %v", err)
|
||||
}
|
||||
if cmd.Args[0] != "true" {
|
||||
t.Errorf("self command should run directly, got %v", cmd.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommand_OtherUserRequiresRoot(t *testing.T) {
|
||||
if IsRoot() {
|
||||
t.Skip("running as root")
|
||||
}
|
||||
if _, err := Command("root", "true"); err == nil {
|
||||
t.Error("running as another user without root should error")
|
||||
}
|
||||
}
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
# Test stack for unitdore compose-stack detection.
|
||||
# Run: podman-compose -p unitdore-test up -d
|
||||
name: unitdore-test
|
||||
|
||||
services:
|
||||
web:
|
||||
image: docker.io/library/alpine:latest
|
||||
init: true
|
||||
command: ["sleep", "infinity"]
|
||||
depends_on:
|
||||
- db
|
||||
db:
|
||||
image: docker.io/library/alpine:latest
|
||||
init: true
|
||||
command: ["sleep", "infinity"]
|
||||
Vendored
+100
@@ -0,0 +1,100 @@
|
||||
[
|
||||
{
|
||||
"AutoRemove": false,
|
||||
"Command": [
|
||||
"sleep",
|
||||
"infinity"
|
||||
],
|
||||
"CreatedAt": "6 seconds ago",
|
||||
"CIDFile": "",
|
||||
"Exited": false,
|
||||
"ExitedAt": -62135596800,
|
||||
"ExitCode": 0,
|
||||
"ExposedPorts": null,
|
||||
"Id": "6d5a1e4d8ab39d64b26842a0e50b5943cfa62d71c70d63ce5c2ad92f598811b9",
|
||||
"Image": "docker.io/library/alpine:latest",
|
||||
"ImageID": "a40c03cbb81c59bfb0e0887ab0b1859727075da7b9cc576a1cec2c771f38c5fb",
|
||||
"IsInfra": false,
|
||||
"Labels": {
|
||||
"PODMAN_SYSTEMD_UNIT": "podman-compose@unitdore-test.service",
|
||||
"com.docker.compose.container-number": "1",
|
||||
"com.docker.compose.project": "unitdore-test",
|
||||
"com.docker.compose.project.config_files": "compose.yaml",
|
||||
"com.docker.compose.project.working_dir": "/home/warkanum/dev/unitdore/testdata/stack",
|
||||
"com.docker.compose.service": "db",
|
||||
"io.podman.compose.config-hash": "1a54723a4ead4531f2ddce5aa7e85019b4ebd1612ca8973b85cd9026f4f87949",
|
||||
"io.podman.compose.project": "unitdore-test",
|
||||
"io.podman.compose.service": "db",
|
||||
"io.podman.compose.version": "1.6.0"
|
||||
},
|
||||
"Mounts": [],
|
||||
"Names": [
|
||||
"unitdore-test_db_1"
|
||||
],
|
||||
"Namespaces": {
|
||||
|
||||
},
|
||||
"Networks": [
|
||||
"unitdore-test_default"
|
||||
],
|
||||
"Pid": 75799,
|
||||
"Pod": "235775e179ed8413b84b823ff63a812a053d929b8399b2d48cc19f9a4eeef410",
|
||||
"PodName": "",
|
||||
"Ports": null,
|
||||
"Restarts": 0,
|
||||
"Size": null,
|
||||
"StartedAt": 1791823934,
|
||||
"State": "running",
|
||||
"Status": "Up 6 seconds",
|
||||
"Created": 1791823933
|
||||
},
|
||||
{
|
||||
"AutoRemove": false,
|
||||
"Command": [
|
||||
"sleep",
|
||||
"infinity"
|
||||
],
|
||||
"CreatedAt": "6 seconds ago",
|
||||
"CIDFile": "",
|
||||
"Exited": false,
|
||||
"ExitedAt": -62135596800,
|
||||
"ExitCode": 0,
|
||||
"ExposedPorts": null,
|
||||
"Id": "b6b364f2a885baa583b7112cd7d37832e7f11f9091698ef76ca4c44259e501cc",
|
||||
"Image": "docker.io/library/alpine:latest",
|
||||
"ImageID": "a40c03cbb81c59bfb0e0887ab0b1859727075da7b9cc576a1cec2c771f38c5fb",
|
||||
"IsInfra": false,
|
||||
"Labels": {
|
||||
"PODMAN_SYSTEMD_UNIT": "podman-compose@unitdore-test.service",
|
||||
"com.docker.compose.container-number": "1",
|
||||
"com.docker.compose.project": "unitdore-test",
|
||||
"com.docker.compose.project.config_files": "compose.yaml",
|
||||
"com.docker.compose.project.working_dir": "/home/warkanum/dev/unitdore/testdata/stack",
|
||||
"com.docker.compose.service": "web",
|
||||
"io.podman.compose.config-hash": "f38934df837f52ead7f2848b58acef10380a7929125a7c4f61199ad5b557302f",
|
||||
"io.podman.compose.project": "unitdore-test",
|
||||
"io.podman.compose.service": "web",
|
||||
"io.podman.compose.version": "1.6.0"
|
||||
},
|
||||
"Mounts": [],
|
||||
"Names": [
|
||||
"unitdore-test_web_1"
|
||||
],
|
||||
"Namespaces": {
|
||||
|
||||
},
|
||||
"Networks": [
|
||||
"unitdore-test_default"
|
||||
],
|
||||
"Pid": 75919,
|
||||
"Pod": "235775e179ed8413b84b823ff63a812a053d929b8399b2d48cc19f9a4eeef410",
|
||||
"PodName": "",
|
||||
"Ports": null,
|
||||
"Restarts": 0,
|
||||
"Size": null,
|
||||
"StartedAt": 1791823934,
|
||||
"State": "running",
|
||||
"Status": "Up 6 seconds",
|
||||
"Created": 1791823933
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user