6 Commits

Author SHA1 Message Date
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
14 changed files with 196 additions and 71 deletions

View File

@@ -48,7 +48,7 @@ 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
@@ -215,7 +215,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}"
@@ -319,13 +319,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 }}

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
@@ -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:

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

@@ -31,7 +31,7 @@ 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
@@ -57,7 +57,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,7 +68,7 @@ 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)

View File

@@ -7,6 +7,8 @@ import (
"github.com/spf13/cobra"
)
var version = "0.0.8"
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() {

View File

@@ -63,10 +63,10 @@ func runUpdate(cmd *cobra.Command, args []string) error {
return err
}
prefix, suffix, serviceUser := cfg.Prefix, cfg.Suffix, cfg.EffectiveServiceUser()
prefix, suffix := cfg.Prefix, cfg.Suffix
if systemd.IsInstalled(*u, prefix, suffix) {
if err := systemd.Install(*u, prefix, suffix, serviceUser); err != nil {
if err := systemd.Install(*u, prefix, suffix); err != nil {
return fmt.Errorf("recreating service file: %w", err)
}
path, _ := systemd.UnitPath(*u, prefix, suffix)

View File

@@ -3,7 +3,6 @@ package config
import (
"fmt"
"os"
"os/user"
"path/filepath"
"gopkg.in/yaml.v3"
@@ -14,32 +13,23 @@ 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.
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"
}
// Load reads and parses the config file at path.

View File

@@ -1,6 +1,6 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=unitdore
pkgver=0.0.4
pkgver=0.0.8
pkgrel=1
pkgdesc="A door you open and close for container units — manage containers via systemd"
arch=('x86_64' 'aarch64')
@@ -20,7 +20,7 @@ build() {
export CGO_ENABLED=0
go build \
-trimpath \
-ldflags "-X main.version=$pkgver" \
-ldflags "-X github.com/warkanum/unitdore/cmd.version=$pkgver" \
-o "$pkgname" .
}

View File

@@ -1,5 +1,5 @@
Name: unitdore
Version: 0.1.0
Version: 0.0.8
Release: 1%{?dist}
Summary: Manage container units via systemd
@@ -10,6 +10,8 @@ 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
@@ -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

View File

@@ -2,6 +2,9 @@ Package: unitdore
Version: VERSION
Architecture: ARCH
Maintainer: Hein (Warky Devs) <hein@warky.dev>
Section: admin
Priority: optional
Homepage: https://git.warky.dev/wdevs/unitdore
Depends: systemd
Recommends: podman | docker.io
Description: A door you open and close for container units

View File

@@ -13,15 +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
User={{.ServiceUser}}
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
@@ -38,19 +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
ServiceUser 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.
@@ -59,15 +69,15 @@ 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) {
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,
ServiceUser: serviceUser,
ServiceName: ServiceName(u, prefix, suffix),
Unit: u,
ExecStart: execStart,
ExecStop: execStop,
RuntimeService: runtimeService(u.Runtime),
}
tmplStr := systemUnitTemplate
@@ -87,6 +97,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 != "" {

View File

@@ -35,7 +35,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)
}
@@ -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) {
@@ -70,7 +79,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)
}
@@ -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",
@@ -101,7 +155,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 +172,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 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 +197,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 +214,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)
}

View File

@@ -27,8 +27,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
}