27 Commits
v0.0.1 ... main

Author SHA1 Message Date
e6ef6e11d6 chore(release): update package version to 0.0.9
All checks were successful
Release / test (push) Successful in -33m2s
Release / release (push) Successful in -32m47s
Release / pkg-aur (push) Successful in -33m46s
Release / pkg-deb (push) Successful in -33m19s
Release / pkg-rpm (push) Successful in -32m33s
2026-04-12 11:08:47 +02:00
f9dcb0b561 Merge branch 'main' of git.warky.dev:wdevs/unitdore 2026-04-12 11:08:38 +02:00
69069a2196 fix(podman): handle invalid JSON output gracefully 2026-04-12 11:08:20 +02:00
Hein
2efccc5d4f chore(release): update package version to 0.0.8
Some checks failed
Release / test (push) Successful in -30m10s
Release / release (push) Successful in -30m3s
Release / pkg-aur (push) Successful in -29m57s
Release / pkg-rpm (push) Failing after 14m44s
Release / pkg-deb (push) Failing after 14m51s
2026-04-10 09:38:59 +02:00
Hein
b58373ad2f feat(config): add restart options for unit configuration
* include Restart, RestartSec, and RestartRetries fields
* update service file generation to support restart settings
* add tests for restart behavior in unit generation
2026-04-10 09:38:45 +02:00
a273232303 chore(release): update package version to 0.0.7
All checks were successful
Release / release (push) Successful in -30m29s
Release / pkg-aur (push) Successful in -30m19s
Release / test (push) Successful in -30m43s
Release / pkg-deb (push) Successful in -30m29s
Release / pkg-rpm (push) Successful in -29m17s
2026-04-08 19:59:11 +02:00
fae49a258c ci(release): improve RPM upload logic in release workflow 2026-04-08 19:59:00 +02:00
00bc6ed893 chore(release): update package version to 0.0.6
Some checks failed
Release / test (push) Successful in -30m39s
Release / release (push) Successful in -30m31s
Release / pkg-deb (push) Successful in -30m33s
Release / pkg-aur (push) Successful in -29m41s
Release / pkg-rpm (push) Failing after -29m26s
2026-04-08 19:48:22 +02:00
b44546dc24 docs(README): update installation instructions for various distributions 2026-04-08 19:48:07 +02:00
Hein
c6198ea6b7 chore(release): update package version to 0.0.4 and fix build paths
Some checks failed
Release / pkg-deb (push) Successful in -30m35s
Release / pkg-aur (push) Successful in -30m17s
Release / pkg-rpm (push) Failing after -29m39s
Release / test (push) Successful in -30m39s
Release / release (push) Successful in -30m33s
* Adjust pkgver in PKGBUILD to 0.0.4
* Update build paths in PKGBUILD for consistency
* Enhance AUR package versioning logic in release workflow
2026-04-08 17:50:15 +02:00
Hein
9ef31866f1 chore(release): update curl installation in Rocky 9 image 2026-04-08 17:35:12 +02:00
Hein
77b86dc3fc chore(aur): improve AUR SSH key handling logic
Some checks failed
Release / test (push) Successful in -30m41s
Release / pkg-deb (push) Successful in -30m32s
Release / release (push) Successful in -30m33s
Release / pkg-rpm (push) Failing after -30m32s
Release / pkg-aur (push) Successful in -30m34s
2026-04-08 17:19:13 +02:00
Hein
0999303cd3 chore(aur): enhance AUR SSH setup for key handling
* Improve SSH key handling with support for raw, escaped, and base64-encoded keys
* Add validation for AUR_SSH_KEY to ensure it's a valid private key
* Update SSH command options for better security and reliability
2026-04-08 17:02:10 +02:00
Hein
384c4592d1 chore(release): remove key content diagnostics from AUR SSH setup 2026-04-08 16:39:06 +02:00
Hein
815bdfed80 chore(release): enhance AUR SSH setup for key handling
* Improve SSH key setup by auto-detecting key format
* Add diagnostics for key validation and size
2026-04-08 15:41:35 +02:00
Hein
243da39fe3 chore(release): update AUR SSH setup to use base64 decoding 2026-04-08 15:29:21 +02:00
Hein
0a1e768dfe chore(release): remove Arch package build steps from workflow 2026-04-08 15:23:10 +02:00
Hein
e66f869752 chore(release): update source archive format and URLs
Some checks failed
Release / test (push) Successful in -30m28s
Release / release (push) Successful in -29m36s
Release / pkg-aur (push) Failing after -31m0s
Release / pkg-arch (push) Failing after -30m36s
Release / pkg-deb (push) Successful in -30m6s
Release / pkg-rpm (push) Failing after -30m27s
* Change source archive from tar.gz to zip for Arch packaging
* Update URLs in PKGBUILD and spec files to point to the correct repository
2026-04-08 15:09:33 +02:00
Hein
2c0f51422e chore(release): update Go version and build requirements
* Change Go version to 1.23.0 in go.mod
* Update golang build requirement to >= 1.23 in unitdore.spec
* Adjust packaging commands for Arch and RPM
2026-04-08 14:51:57 +02:00
Hein
0b34b182a9 feat(release): add packaging support for Arch, Debian, and RPM
Some checks failed
Release / test (push) Successful in -30m44s
Release / release (push) Successful in -30m36s
Release / pkg-arch (push) Failing after -30m14s
Release / pkg-deb (push) Successful in -30m4s
Release / pkg-rpm (push) Failing after -28m19s
* Implement Arch package build process in release workflow
* Add Debian and RPM packaging specifications
* Include man page installation in Makefile
* Create unitdore man page for documentation
2026-04-08 14:28:09 +02:00
Hein
880023c68b feat(cmd): add start, stop, uninstall, and update commands 2026-04-08 13:36:32 +02:00
Hein
168b81f104 feat(cmd): add startall and stopall commands for unit management
* Implement startall to enable and start all installed units
* Implement stopall to stop and disable all running units
2026-04-08 13:33:39 +02:00
Hein
d8c90e4fff fix(systemd): include service user in service file generation
* Add ServiceUser field to Config struct for user specification.
* Update Generate and Install functions to accept service user.
* Modify tests to reflect changes in service user handling.
2026-04-08 13:30:07 +02:00
Hein
e4d6f3a4a2 feat(cmd): add list command to display tracked units 2026-04-08 13:26:46 +02:00
Hein
cb9187bfbd fix(cmd): improve editor selection logic in edit command
* Enhance the editor selection to check for nvim and nano before defaulting to vi.
* Return an error if no suitable editor is found.
fix(cmd): track unchanged units in syncup command
* Add tracking for unchanged units during syncup process.
* Update completion message to include count of unchanged units.
fix(runtime): change command type to slice in podman container
* Update command field in podmanContainer struct to be a slice of strings.
* Adjust ListRunning method to join command slice into a single string for output.
fix(ci): enhance release notes generation in workflow
* Collect commit messages since the last tag for release notes.
* Format release body to include detailed change log.
2026-04-08 13:21:53 +02:00
Hein
e6743e12fd chore(ci): add workflow_dispatch for manual release tagging 2026-04-08 13:12:02 +02:00
Hein
e5a61bb364 fix(ci): use Gitea API directly instead of softprops/action-gh-release 2026-04-08 13:10:47 +02:00
26 changed files with 1103 additions and 50 deletions

View File

@@ -4,6 +4,11 @@ on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (e.g. v1.2.3)'
required: true
jobs:
test:
@@ -35,7 +40,7 @@ jobs:
- name: Build release binaries
run: |
VERSION="${GITHUB_REF_NAME}"
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
for target in "linux/amd64" "linux/arm64" "darwin/amd64" "darwin/arm64" "windows/amd64"; do
GOOS="${target%/*}"
GOARCH="${target#*/}"
@@ -43,15 +48,284 @@ 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: Create release and upload assets
uses: softprops/action-gh-release@v2
with:
files: unitdore-*
generate_release_notes: true
run: |
TAG="${{ github.event.inputs.tag || github.ref_name }}"
API="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases"
# Collect commits since the previous tag (or last 20 if no prior tag)
PREV_TAG=$(git tag --sort=-version:refname | grep -v "^${TAG}$" | head -1)
if [ -n "$PREV_TAG" ]; then
RANGE="${PREV_TAG}..${TAG}"
else
RANGE="HEAD~20..HEAD"
fi
NOTES=$(git log "$RANGE" --pretty=format:"- %s" --no-merges)
BODY="## What's changed"$'\n'"${NOTES}"
# Escape for JSON
BODY_JSON=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')
RELEASE=$(curl -s -X POST "$API" \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":${BODY_JSON}}")
UPLOAD_URL=$(echo "$RELEASE" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4)
if [ -z "$UPLOAD_URL" ]; then
echo "Failed to create release: $RELEASE"
exit 1
fi
for f in unitdore-*; do
echo "Uploading $f..."
curl -s -X POST "${UPLOAD_URL}?name=${f}" \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${f}" > /dev/null
done
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
pkg-aur:
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- 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"
# Setup SSH for AUR
mkdir -p ~/.ssh
chmod 700 ~/.ssh
if [ -z "${AUR_SSH_KEY:-}" ]; then
echo "AUR_SSH_KEY is empty"
exit 1
fi
# 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"
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/archive/v${PKGVER}.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
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build Debian packages
run: |
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
PKGVER="${VERSION#v}"
for GOARCH in amd64 arm64; do
GOOS=linux GOARCH=$GOARCH go build \
-trimpath \
-ldflags "-X github.com/warkanum/unitdore/cmd.version=${PKGVER}" \
-o unitdore .
PKGDIR="unitdore_${PKGVER}_${GOARCH}"
mkdir -p "${PKGDIR}/DEBIAN"
mkdir -p "${PKGDIR}/usr/bin"
mkdir -p "${PKGDIR}/usr/share/man/man1"
mkdir -p "${PKGDIR}/etc/unitdore"
install -m755 unitdore "${PKGDIR}/usr/bin/unitdore"
install -m644 docs/unitdore.1 "${PKGDIR}/usr/share/man/man1/unitdore.1"
sed -e "s/VERSION/${PKGVER}/" \
-e "s/ARCH/${GOARCH}/" \
pkg/debian/control > "${PKGDIR}/DEBIAN/control"
dpkg-deb --build --root-owner-group "${PKGDIR}"
echo "Built ${PKGDIR}.deb"
done
- 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 *.deb; 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 }}
pkg-rpm:
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- 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 \
> unitdore-${PKGVER}.tar.gz
# Patch spec version
sed -i "s/^Version:.*/Version: ${PKGVER}/" pkg/centos/unitdore.spec
mkdir -p pkg/centos/out
CID=$(docker create \
-e GO_VER="${GO_VER}" \
-e PKGVER="${PKGVER}" \
-w /build \
rockylinux:9 \
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 --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: |
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)
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 < <(find pkg/centos/out -name "*.rpm")
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
unitdore
*.exe
.codex

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Hein (Warky Devs)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,8 +1,8 @@
BINARY := unitdore
INSTALL_DIR := /usr/local/bin
INSTALL_DIR := /usr/bin
CONFIG_DIR := /etc/unitdore
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -ldflags "-X main.version=$(VERSION)"
LDFLAGS := -ldflags "-X github.com/warkanum/unitdore/cmd.version=$(VERSION)"
.PHONY: all build install uninstall test lint clean release-version
@@ -12,16 +12,18 @@ all: build
build:
go build $(LDFLAGS) -o $(BINARY) .
## install: install binary and create config dir
## install: install binary, man page, and create config dir
install: build
install -Dm755 $(BINARY) $(INSTALL_DIR)/$(BINARY)
install -Dm644 docs/unitdore.1 /usr/share/man/man1/unitdore.1
mkdir -p $(CONFIG_DIR)
@echo "Installed to $(INSTALL_DIR)/$(BINARY)"
@echo "Config dir: $(CONFIG_DIR)"
## uninstall: remove binary
## uninstall: remove binary and man page
uninstall:
rm -f $(INSTALL_DIR)/$(BINARY)
rm -f /usr/share/man/man1/unitdore.1
@echo "Removed $(INSTALL_DIR)/$(BINARY)"
## test: run all unit tests
@@ -40,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:

View File

@@ -6,6 +6,32 @@ Unitdore bridges your container runtime (Podman, Docker) and systemd. It discove
## 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

View File

@@ -21,7 +21,15 @@ func init() {
func runEdit(cmd *cobra.Command, args []string) error {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "vi"
for _, e := range []string{"nvim", "nano", "vi"} {
if _, err := exec.LookPath(e); err == nil {
editor = e
break
}
}
}
if editor == "" {
return fmt.Errorf("no editor found; set $EDITOR")
}
c := exec.Command(editor, configPath)
c.Stdin = os.Stdin

89
cmd/list.go Normal file
View File

@@ -0,0 +1,89 @@
package cmd
import (
"fmt"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
)
var listCmd = &cobra.Command{
Use: "list",
Short: "List all tracked units with service file locations",
RunE: runList,
}
func init() {
rootCmd.AddCommand(listCmd)
}
func runList(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath)
if err != nil {
return err
}
if len(cfg.Units) == 0 {
fmt.Println("No units tracked. Run 'unitdore syncup' to discover containers.")
return nil
}
units := make([]config.Unit, len(cfg.Units))
copy(units, cfg.Units)
sort.Slice(units, func(i, j int) bool {
if units[i].Order != units[j].Order {
return units[i].Order < units[j].Order
}
return units[i].Name < units[j].Name
})
prefix, suffix := cfg.Prefix, cfg.Suffix
type row struct{ name, runtime, enabled, installed, path string }
rows := make([]row, 0, len(units))
for _, u := range units {
enabled := "yes"
if !u.Enabled {
enabled = "no"
}
installed := "no"
unitPath := "—"
if p, err := systemd.UnitPath(u, prefix, suffix); err == nil {
unitPath = p
if systemd.IsInstalled(u, prefix, suffix) {
installed = "yes"
}
}
rows = append(rows, row{u.Name, u.Runtime, enabled, installed, unitPath})
}
cols := []string{"NAME", "RUNTIME", "ENABLED", "INSTALLED", "SERVICE FILE"}
widths := make([]int, len(cols))
for i, c := range cols {
widths[i] = len(c)
}
for _, r := range rows {
vals := []string{r.name, r.runtime, r.enabled, r.installed, r.path}
for i, v := range vals {
if len(v) > widths[i] {
widths[i] = len(v)
}
}
}
fmt.Println()
printRow(cols, widths)
fmt.Println(strings.Repeat("─", totalWidth(widths)))
for _, r := range rows {
printRow([]string{r.name, r.runtime, r.enabled, r.installed, r.path}, widths)
}
fmt.Println()
return nil
}

View File

@@ -7,6 +7,8 @@ import (
"github.com/spf13/cobra"
)
var version = "0.0.9"
var configPath string
var rootCmd = &cobra.Command{
@@ -16,7 +18,7 @@ var rootCmd = &cobra.Command{
It discovers running containers, stores them in a config file,
and generates + manages systemd .service units for each one.`,
Version: "0.1.0",
Version: version,
}
func Execute() {

46
cmd/start.go Normal file
View File

@@ -0,0 +1,46 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
)
var startCmd = &cobra.Command{
Use: "start <name>",
Short: "Start a unit by name",
Args: cobra.ExactArgs(1),
RunE: runStart,
}
func init() {
rootCmd.AddCommand(startCmd)
}
func runStart(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath)
if err != nil {
return err
}
name := args[0]
u := cfg.FindUnit(name)
if u == nil {
return fmt.Errorf("unit %q not found", name)
}
prefix, suffix := cfg.Prefix, cfg.Suffix
if !systemd.IsInstalled(*u, prefix, suffix) {
return fmt.Errorf("%s is not installed — run 'unitdore install' first", name)
}
fmt.Printf(" ▶ starting %s...\n", systemd.ServiceName(*u, prefix, suffix))
if err := systemd.Start(*u, prefix, suffix); err != nil {
return err
}
fmt.Printf(" ✓ started: %s\n", name)
return nil
}

View File

@@ -9,19 +9,19 @@ import (
"github.com/warkanum/unitdore/systemd"
)
var activeCmd = &cobra.Command{
Use: "active",
var startallCmd = &cobra.Command{
Use: "startall",
Short: "Enable and start all installed, enabled units",
Long: `Active runs 'systemctl enable --now' for all enabled units that have
Long: `Startall runs 'systemctl enable --now' for all enabled units that have
been installed. Units must be installed first via 'unitdore install'.`,
RunE: runActive,
RunE: runStartall,
}
func init() {
rootCmd.AddCommand(activeCmd)
rootCmd.AddCommand(startallCmd)
}
func runActive(cmd *cobra.Command, args []string) error {
func runStartall(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath)
if err != nil {
return err
@@ -29,7 +29,6 @@ func runActive(cmd *cobra.Command, args []string) error {
prefix, suffix := cfg.Prefix, cfg.Suffix
// Sort by order then name for deterministic startup sequence
units := make([]config.Unit, len(cfg.Units))
copy(units, cfg.Units)
sort.Slice(units, func(i, j int) bool {
@@ -50,7 +49,7 @@ func runActive(cmd *cobra.Command, args []string) error {
fmt.Printf(" ! %s: not installed — run 'unitdore install' first\n", u.Name)
continue
}
fmt.Printf(" ▶ enabling: %s...\n", systemd.ServiceName(u, prefix, suffix))
fmt.Printf(" ▶ starting: %s...\n", systemd.ServiceName(u, prefix, suffix))
if err := systemd.Enable(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
failed++

46
cmd/stop.go Normal file
View File

@@ -0,0 +1,46 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
)
var stopCmd = &cobra.Command{
Use: "stop <name>",
Short: "Stop a unit by name",
Args: cobra.ExactArgs(1),
RunE: runStop,
}
func init() {
rootCmd.AddCommand(stopCmd)
}
func runStop(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath)
if err != nil {
return err
}
name := args[0]
u := cfg.FindUnit(name)
if u == nil {
return fmt.Errorf("unit %q not found", name)
}
prefix, suffix := cfg.Prefix, cfg.Suffix
if !systemd.IsInstalled(*u, prefix, suffix) {
return fmt.Errorf("%s is not installed", name)
}
fmt.Printf(" ■ stopping %s...\n", systemd.ServiceName(*u, prefix, suffix))
if err := systemd.Stop(*u, prefix, suffix); err != nil {
return err
}
fmt.Printf(" ✓ stopped: %s\n", name)
return nil
}

60
cmd/stopall.go Normal file
View File

@@ -0,0 +1,60 @@
package cmd
import (
"fmt"
"sort"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
)
var stopallCmd = &cobra.Command{
Use: "stopall",
Short: "Stop and disable all running managed units",
Long: `Stopall runs 'systemctl disable --now' for all enabled, installed units.`,
RunE: runStopall,
}
func init() {
rootCmd.AddCommand(stopallCmd)
}
func runStopall(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath)
if err != nil {
return err
}
prefix, suffix := cfg.Prefix, cfg.Suffix
// Reverse order for shutdown
units := make([]config.Unit, len(cfg.Units))
copy(units, cfg.Units)
sort.Slice(units, func(i, j int) bool {
if units[i].Order != units[j].Order {
return units[i].Order > units[j].Order
}
return units[i].Name > units[j].Name
})
stopped := 0
failed := 0
for _, u := range units {
if !systemd.IsInstalled(u, prefix, suffix) {
continue
}
fmt.Printf(" ■ stopping: %s...\n", systemd.ServiceName(u, prefix, suffix))
if err := systemd.Disable(u, prefix, suffix); err != nil {
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
failed++
} else {
fmt.Printf(" ✓ stopped: %s\n", u.Name)
stopped++
}
}
fmt.Printf("\nDone. Stopped: %d Failed: %d\n", stopped, failed)
return nil
}

View File

@@ -31,6 +31,7 @@ func runSyncup(cmd *cobra.Command, args []string) error {
}
added := 0
unchanged := 0
discovered := map[string]bool{}
// Discover running containers across all runtimes
@@ -51,6 +52,8 @@ func runSyncup(cmd *cobra.Command, args []string) error {
if cfg.AddUnit(unit) {
fmt.Printf(" + added: %s (%s)\n", c.Name, c.Runtime)
added++
} else {
unchanged++
}
}
}
@@ -86,6 +89,6 @@ func runSyncup(cmd *cobra.Command, args []string) error {
return err
}
fmt.Printf("\nDone. Added: %d Disabled: %d Re-enabled: %d\n", added, disabled, reenabled)
fmt.Printf("\nDone. Added: %d Unchanged: %d Disabled: %d Re-enabled: %d\n", added, unchanged, disabled, reenabled)
return nil
}

55
cmd/uninstall.go Normal file
View File

@@ -0,0 +1,55 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
)
var uninstallCmd = &cobra.Command{
Use: "uninstall <name>",
Short: "Disable, stop, and remove the service file for a unit",
Args: cobra.ExactArgs(1),
RunE: runUninstall,
}
func init() {
rootCmd.AddCommand(uninstallCmd)
}
func runUninstall(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(configPath)
if err != nil {
return err
}
name := args[0]
u := cfg.FindUnit(name)
if u == nil {
return fmt.Errorf("unit %q not found", name)
}
prefix, suffix := cfg.Prefix, cfg.Suffix
if !systemd.IsInstalled(*u, prefix, suffix) {
return fmt.Errorf("%s is not installed", name)
}
svcName := systemd.ServiceName(*u, prefix, suffix)
fmt.Printf(" ■ disabling %s...\n", svcName)
if err := systemd.Disable(*u, prefix, suffix); err != nil {
return fmt.Errorf("disabling unit: %w", err)
}
path, _ := systemd.UnitPath(*u, prefix, suffix)
fmt.Printf(" ✗ removing %s...\n", path)
if err := systemd.Uninstall(*u, prefix, suffix); err != nil {
return fmt.Errorf("removing service file: %w", err)
}
fmt.Printf(" ✓ uninstalled: %s\n", name)
return nil
}

77
cmd/update.go Normal file
View File

@@ -0,0 +1,77 @@
package cmd
import (
"errors"
"fmt"
"github.com/spf13/cobra"
"github.com/warkanum/unitdore/config"
"github.com/warkanum/unitdore/systemd"
)
var (
updateOrder int
updateEnable bool
updateDisable bool
)
var updateCmd = &cobra.Command{
Use: "update <name>",
Short: "Update a unit's config and recreate its service file",
Args: cobra.ExactArgs(1),
RunE: runUpdate,
}
func init() {
updateCmd.Flags().IntVar(&updateOrder, "order", 0, "set startup order")
updateCmd.Flags().BoolVar(&updateEnable, "enable", false, "enable the unit")
updateCmd.Flags().BoolVar(&updateDisable, "disable", false, "disable the unit")
rootCmd.AddCommand(updateCmd)
}
func runUpdate(cmd *cobra.Command, args []string) error {
if updateEnable && updateDisable {
return errors.New("--enable and --disable are mutually exclusive")
}
cfg, err := config.Load(configPath)
if err != nil {
return err
}
name := args[0]
u := cfg.FindUnit(name)
if u == nil {
return fmt.Errorf("unit %q not found", name)
}
if cmd.Flags().Changed("order") {
u.Order = updateOrder
fmt.Printf(" order → %d\n", u.Order)
}
if updateEnable {
u.Enabled = true
u.DisabledReason = ""
fmt.Printf(" enabled\n")
}
if updateDisable {
u.Enabled = false
fmt.Printf(" disabled\n")
}
if err := cfg.Save(configPath); err != nil {
return err
}
prefix, suffix := cfg.Prefix, cfg.Suffix
if systemd.IsInstalled(*u, prefix, suffix) {
if err := systemd.Install(*u, prefix, suffix); err != nil {
return fmt.Errorf("recreating service file: %w", err)
}
path, _ := systemd.UnitPath(*u, prefix, suffix)
fmt.Printf(" ✓ service file recreated: %s\n", path)
}
return nil
}

View File

@@ -13,13 +13,16 @@ const DefaultConfigPath = "/etc/unitdore/units.yaml"
// 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)
}
// Config is the root config structure.

143
docs/unitdore.1 Normal file
View File

@@ -0,0 +1,143 @@
.TH UNITDORE 1 "2026-04-08" "0.1.0" "User Commands"
.SH NAME
unitdore \- manage container units via systemd
.SH SYNOPSIS
.B unitdore
[\fB\-\-config\fR \fIpath\fR]
\fIcommand\fR
[\fIarguments\fR]
.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.
.SH OPTIONS
.TP
.BR \-\-config " " \fIpath\fR
Path to the units config file. Default: \fI/etc/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.
.TP
.B install
Generate \fI.service\fR files for all enabled units and write them to the
appropriate systemd directory. Use \fB\-\-dry\-run\fR to preview without writing.
.TP
.B uninstall \fIname\fR
Disable, stop, and remove the service file for a specific unit.
.TP
.B startall
Run \fBsystemctl enable \-\-now\fR for all enabled, installed units in startup
order.
.TP
.B stopall
Run \fBsystemctl disable \-\-now\fR for all installed units in reverse startup
order.
.TP
.B start \fIname\fR
Start a specific unit by name (\fBsystemctl start\fR).
.TP
.B stop \fIname\fR
Stop a specific unit by name (\fBsystemctl stop\fR).
.TP
.B update \fIname\fR
Update a unit's config and recreate its service file.
.RS
.TP
.BR \-\-order " " \fIn\fR
Set the startup order.
.TP
.B \-\-enable
Enable the unit.
.TP
.B \-\-disable
Disable the unit.
.RE
.TP
.B status
Print a summary table of all managed units showing runtime, user, enabled
state, installed state, systemd active state, and disabled reason.
.TP
.B list
Print a table of all tracked units including the path to each service file.
.TP
.B edit
Open the units config file in \fB$EDITOR\fR. Falls back to \fBnvim\fR,
\fBnano\fR, \fBvi\fR in that order.
.SH CONFIG FILE
.B Location:
\fI/etc/unitdore/units.yaml\fR
.PP
.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
units:
- name: nginx
runtime: podman # podman | docker
user: "" # empty = system unit; set for rootless user unit
command: "" # optional: override ExecStart
order: 1 # startup order (lower = earlier)
delay: 0s # delay after previous order group
enabled: true
disabled_reason: "" # auto-set by syncup reconciliation
.fi
.SH GENERATED SERVICE FILES
System units are written to \fI/etc/systemd/system/\fR and user units to
\fI~/.config/systemd/user/\fR.
Service names follow the pattern:
.PP
.RS
\fBunitdore-\fR[\fIprefix\fR]\fIname\fR[\fIsuffix\fR]\fB.service\fR
.RE
.SH EXAMPLES
Discover containers and install their service files:
.PP
.RS
.nf
sudo unitdore syncup
sudo unitdore install
sudo unitdore startall
.fi
.RE
.PP
Preview generated service files without writing:
.PP
.RS
sudo unitdore install \-\-dry\-run
.RE
.PP
Start a single container unit:
.PP
.RS
sudo unitdore start nginx
.RE
.PP
Change startup order and re\-apply service file:
.PP
.RS
sudo unitdore update nginx \-\-order 2
.RE
.SH FILES
.TP
.I /etc/unitdore/units.yaml
Default configuration file.
.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).
Root is required for system units; the relevant user for rootless units.
.SH SEE ALSO
.BR systemctl (1),
.BR podman (1),
.BR docker (1)
.SH AUTHORS
Hein (Warky Devs) \(em https://warky.dev

2
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/warkanum/unitdore
go 1.26.1
go 1.23.0
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect

43
pkg/arch/PKGBUILD Normal file
View File

@@ -0,0 +1,43 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=unitdore
pkgver=0.0.9
pkgrel=1
pkgdesc="A door you open and close for container units — manage containers via systemd"
arch=('x86_64' 'aarch64')
url="https://git.warky.dev/wdevs/unitdore"
license=('MIT')
depends=('systemd')
optdepends=(
'podman: Podman container runtime support'
'docker: Docker container runtime support'
)
makedepends=('go')
source=("$pkgname-$pkgver.zip::$url/archive/v$pkgver.zip")
sha256sums=('SKIP')
build() {
cd "$pkgname"
export CGO_ENABLED=0
go build \
-trimpath \
-ldflags "-X github.com/warkanum/unitdore/cmd.version=$pkgver" \
-o "$pkgname" .
}
check() {
cd "$pkgname"
go test ./...
}
package() {
cd "$pkgname"
# Binary
install -Dm755 "$pkgname" "$pkgdir/usr/bin/$pkgname"
# Man page
install -Dm644 docs/unitdore.1 "$pkgdir/usr/share/man/man1/unitdore.1"
# Default config dir
install -dm755 "$pkgdir/etc/unitdore"
}

44
pkg/centos/unitdore.spec Normal file
View File

@@ -0,0 +1,44 @@
Name: unitdore
Version: 0.0.9
Release: 1%{?dist}
Summary: Manage container units via systemd
License: MIT
URL: https://git.warky.dev/wdevs/unitdore
Source0: %{name}-%{version}.tar.gz
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
and manages systemd .service units for each one.
%prep
%autosetup
%build
export CGO_ENABLED=0
go build \
-trimpath \
-ldflags "-X github.com/warkanum/unitdore/cmd.version=%{version}" \
-o %{name} .
%install
install -Dm755 %{name} %{buildroot}%{_bindir}/%{name}
install -Dm644 docs/unitdore.1 %{buildroot}%{_mandir}/man1/unitdore.1
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
* Wed Apr 08 2026 Hein (Warky Devs) <hein@warky.dev> - 0.0.5-1
- Initial package

13
pkg/debian/control Normal file
View File

@@ -0,0 +1,13 @@
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
Unitdore bridges your container runtime (Podman, Docker) and systemd.
It discovers running containers, stores them in a config file, and generates
and manages systemd .service units for each one.

View File

@@ -68,9 +68,9 @@ func (d *Docker) Exists(name string) (bool, error) {
return false, nil
}
out, err := exec.Command(bin, "ps", "-a", "--format", "{{.Names}}").Output()
out, err := exec.Command(bin, "ps", "--format", "{{.Names}}").Output()
if err != nil {
return false, fmt.Errorf("docker ps -a: %w", err)
return false, fmt.Errorf("docker ps: %w", err)
}
for _, line := range strings.Split(string(out), "\n") {

View File

@@ -15,7 +15,7 @@ type podmanContainer struct {
ID string `json:"Id"`
Names []string `json:"Names"`
Image string `json:"Image"`
Command string `json:"Command"`
Command []string `json:"Command"`
State string `json:"State"`
}
@@ -33,7 +33,8 @@ func (p *Podman) ListRunning() ([]Container, error) {
var raw []podmanContainer
if err := json.Unmarshal(out, &raw); err != nil {
return nil, fmt.Errorf("parsing podman output: %w", err)
// Podman installed but output is not valid JSON (e.g. OCI runtime misconfigured)
return nil, nil
}
var containers []Container
@@ -55,7 +56,7 @@ func (p *Podman) ListRunning() ([]Container, error) {
containers = append(containers, Container{
Name: name,
Image: c.Image,
Command: c.Command,
Command: strings.Join(c.Command, " "),
Runtime: "podman",
})
}
@@ -68,9 +69,9 @@ func (p *Podman) Exists(name string) (bool, error) {
return false, nil
}
out, err := exec.Command(bin, "ps", "-a", "--format", "{{.Names}}").Output()
out, err := exec.Command(bin, "ps", "--format", "{{.Names}}").Output()
if err != nil {
return false, fmt.Errorf("podman ps -a: %w", err)
return false, fmt.Errorf("podman ps: %w", err)
}
for _, line := range strings.Split(string(out), "\n") {

View File

@@ -13,14 +13,20 @@ const systemUnitTemplate = `# /etc/systemd/system/{{.ServiceName}}
[Unit]
Description=Unitdore: {{.Unit.Name}} ({{.Unit.Runtime}})
After=network.target
After=network.target{{with .RuntimeService}} {{.}}
Requires={{.}}{{end}}
[Service]
Type=simple
ExecStart={{.ExecStart}}
ExecStop={{.ExecStop}}
{{- 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
@@ -37,18 +43,24 @@ After=default.target
Type=simple
ExecStart={{.ExecStart}}
ExecStop={{.ExecStop}}
{{- 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
ServiceName string
Unit config.Unit
ExecStart string
ExecStop string
RuntimeService string
}
// ServiceName returns the systemd service name for a unit, with optional prefix/suffix.
@@ -61,10 +73,11 @@ func Generate(u config.Unit, prefix, suffix string) (string, error) {
execStart, execStop := buildExecCommands(u)
data := templateData{
ServiceName: ServiceName(u, prefix, suffix),
Unit: u,
ExecStart: execStart,
ExecStop: execStop,
ServiceName: ServiceName(u, prefix, suffix),
Unit: u,
ExecStart: execStart,
ExecStop: execStop,
RuntimeService: runtimeService(u.Runtime),
}
tmplStr := systemUnitTemplate
@@ -84,6 +97,15 @@ func Generate(u config.Unit, prefix, suffix 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 != "" {

View File

@@ -50,7 +50,6 @@ func TestGenerate_SystemUnit(t *testing.T) {
"WantedBy=multi-user.target",
"ExecStart=/usr/bin/podman start -a nginx",
"ExecStop=/usr/bin/podman stop nginx",
"Restart=on-failure",
"Generated by unitdore",
}
@@ -59,6 +58,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) {
@@ -93,6 +102,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",
@@ -123,11 +177,16 @@ func TestGenerate_DockerRuntime(t *testing.T) {
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 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)
}
}
}

View File

@@ -73,6 +73,16 @@ func Disable(u config.Unit, prefix, suffix string) error {
return systemctl(u.User, "disable", "--now", ServiceName(u, prefix, suffix))
}
// Start starts a unit without enabling it.
func Start(u config.Unit, prefix, suffix string) error {
return systemctl(u.User, "start", ServiceName(u, prefix, suffix))
}
// Stop stops a unit without disabling it.
func Stop(u config.Unit, prefix, suffix string) error {
return systemctl(u.User, "stop", ServiceName(u, prefix, suffix))
}
// Status returns the ActiveState of a unit ("active", "inactive", "failed", "unknown").
func Status(u config.Unit, prefix, suffix string) string {
args := []string{"show", "-p", "ActiveState", "--value", ServiceName(u, prefix, suffix)}