feat: OpenRC and runit init backends behind an Init interface
Extract init-neutral unit resolution into service/, add initsys.Init with systemd, openrc and runit backends, selected by 'init:' config or autodetect. OpenRC and runit are root only; rootless units run as system services as that user. Add docs and generated examples for all three.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
## Project Overview
|
## 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.
|
**Unitdore** is a Go CLI tool that bridges container runtimes (Podman, Docker) and the init system (systemd, OpenRC, runit). It discovers running containers, stores them in a YAML config, and generates + manages service files for each one.
|
||||||
|
|
||||||
# Agent Rules
|
# Agent Rules
|
||||||
|
|
||||||
@@ -33,7 +33,11 @@ config/ # Config struct + YAML load/save (units.yaml)
|
|||||||
runtime/ # Docker and Podman runtime abstraction, compose labels
|
runtime/ # Docker and Podman runtime abstraction, compose labels
|
||||||
sysuser/ # run commands as root/user (runuser), home/linger lookup
|
sysuser/ # run commands as root/user (runuser), home/linger lookup
|
||||||
testdata/stack # podman-compose test stack + captured `podman ps` fixture
|
testdata/stack # podman-compose test stack + captured `podman ps` fixture
|
||||||
systemd/ # .service file generator and systemd manager
|
service/ # init-neutral unit resolution: commands, quoting, user env
|
||||||
|
initsys/ # Init interface, backend selection (config init: / autodetect)
|
||||||
|
systemd/ # .service generator + systemctl manager (Backend)
|
||||||
|
openrc/ # /etc/init.d script generator + rc-service manager (Backend)
|
||||||
|
runit/ # /etc/sv run/finish generator + sv manager (Backend)
|
||||||
docs/ # generated-units.md (unit file examples), unitdore.1 (man page)
|
docs/ # generated-units.md (unit file examples), unitdore.1 (man page)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -41,13 +45,18 @@ docs/ # generated-units.md (unit file examples), unitdore.1 (man page)
|
|||||||
|
|
||||||
- **`config`** — `Unit` and `Config` structs; `Load`, `Save`, `FindUnit`, `AddUnit`
|
- **`config`** — `Unit` and `Config` structs; `Load`, `Save`, `FindUnit`, `AddUnit`
|
||||||
- **`runtime`** — `ContainerRuntime` interface; `docker.go`, `podman.go` implementations
|
- **`runtime`** — `ContainerRuntime` interface; `docker.go`, `podman.go` implementations
|
||||||
|
- **`service`** — `Resolve(unit)` → start/stop commands, type, workdir; shared by all backends
|
||||||
|
- **`initsys`** — `Init` interface; `New(cfg)` picks backend; cmd/ only talks to `Init`
|
||||||
- **`systemd`** — `generator.go` builds `.service` file content; `manager.go` calls `systemctl`
|
- **`systemd`** — `generator.go` builds `.service` file content; `manager.go` calls `systemctl`
|
||||||
|
- **`openrc`**, **`runit`** — same role; root only; tests stub command execution via package vars
|
||||||
|
|
||||||
### Config file
|
### Config file
|
||||||
|
|
||||||
**Location:** `/etc/unitdore/units.yaml`
|
**Location:** `/etc/unitdore/units.yaml`
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
init: "" # systemd | openrc | runit (empty = autodetect)
|
||||||
|
service_dir: "" # runit: supervised dir (empty = autodetect)
|
||||||
prefix: "" # prepended to service file name (e.g. "prod-")
|
prefix: "" # prepended to service file name (e.g. "prod-")
|
||||||
suffix: "" # appended to service file name (e.g. "-svc")
|
suffix: "" # appended to service file name (e.g. "-svc")
|
||||||
users: [] # root only: rootless users scanned by syncup
|
users: [] # root only: rootless users scanned by syncup
|
||||||
@@ -101,6 +110,10 @@ Tests exist in:
|
|||||||
- `runtime/runtime_test.go`
|
- `runtime/runtime_test.go`
|
||||||
- `cmd/syncup_test.go`
|
- `cmd/syncup_test.go`
|
||||||
- `sysuser/sysuser_test.go`
|
- `sysuser/sysuser_test.go`
|
||||||
|
- `service/spec_test.go`
|
||||||
|
- `initsys/initsys_test.go`
|
||||||
|
- `openrc/openrc_test.go`
|
||||||
|
- `runit/runit_test.go`
|
||||||
- `systemd/generator_test.go`
|
- `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.
|
Always run `make test` before marking a task complete. Add tests for new behaviour in the same package as the code under test.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> *A door you open and close for container units.*
|
> *A door you open and close for container units.*
|
||||||
|
|
||||||
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.
|
Unitdore bridges your container runtime (Podman, Docker) and your init system (systemd, OpenRC, runit). It discovers running containers and podman-compose stacks, stores them in a config file, and generates + manages a service for each one.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -107,6 +107,8 @@ oldthing podman hein no no — container
|
|||||||
**Location:** `/etc/unitdore/units.yaml`
|
**Location:** `/etc/unitdore/units.yaml`
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
init: "" # optional: systemd | openrc | runit (empty = autodetect)
|
||||||
|
service_dir: "" # optional: runit supervised dir (empty = /var/service or /etc/service)
|
||||||
prefix: "" # optional: prepended to all service names, e.g. "prod-"
|
prefix: "" # optional: prepended to all service names, e.g. "prod-"
|
||||||
suffix: "" # optional: appended to all service names, e.g. "-svc"
|
suffix: "" # optional: appended to all service names, e.g. "-svc"
|
||||||
users: [hein] # root only: rootless users scanned by syncup
|
users: [hein] # root only: rootless users scanned by syncup
|
||||||
@@ -158,6 +160,27 @@ units:
|
|||||||
enabled: true
|
enabled: true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Init systems
|
||||||
|
|
||||||
|
Set `init:` in the config, or leave empty to autodetect (`/run/systemd/system` → systemd, `/run/openrc` → openrc, `/etc/runit` → runit, else systemd).
|
||||||
|
|
||||||
|
| | systemd | openrc | runit |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Service file | `/etc/systemd/system/unitdore-<n>.service` | `/etc/init.d/unitdore-<n>` | `/etc/sv/unitdore-<n>/{run,finish}` + symlink in `service_dir` |
|
||||||
|
| Start / stop | `systemctl` | `rc-service` | `sv` |
|
||||||
|
| Enable at boot | `systemctl enable` | `rc-update add … default` | remove `down` file |
|
||||||
|
| Installed state | disabled until enabled | not in runlevel | symlinked with `down` file (no autostart) |
|
||||||
|
| Rootless `user:` | `systemctl --user` unit (needs linger) | system service, `command_user` | system service, `chpst -u` |
|
||||||
|
| Non-root use | yes (own user units) | **root only** | **root only** |
|
||||||
|
| `restart*` | `Restart=on-failure`, `StartLimitBurst` | `supervise-daemon` `respawn_*` (always respawns) | `finish`: on-failure, `RestartSec`; **no retry limit** |
|
||||||
|
| `docker` dependency | `Requires=docker.service` | `need docker` | none |
|
||||||
|
| `oneshot` | `RemainAfterExit` | `start()`/`stop()` functions | run once, then `tail -f /dev/null` |
|
||||||
|
|
||||||
|
- Rootless units on openrc/runit set `HOME` and `XDG_RUNTIME_DIR=/run/user/<uid>` and create that dir if missing.
|
||||||
|
- `command` / `args` use systemd quoting; they are re-quoted for sh on openrc/runit.
|
||||||
|
- No log redirection is generated on openrc; use the runtime's own logs.
|
||||||
|
- `docs/generated-units.md` shows systemd output only.
|
||||||
|
|
||||||
### Script / executable units
|
### Script / executable units
|
||||||
|
|
||||||
Added manually (`unitdore edit`); syncup never creates or disables them.
|
Added manually (`unitdore edit`); syncup never creates or disables them.
|
||||||
|
|||||||
+3
-2
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/warkanum/unitdore/config"
|
"github.com/warkanum/unitdore/config"
|
||||||
|
"github.com/warkanum/unitdore/initsys"
|
||||||
"github.com/warkanum/unitdore/sysuser"
|
"github.com/warkanum/unitdore/sysuser"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,8 +17,8 @@ func runtimeLabel(u config.Unit) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// lingerHint prints, once per user, how to make user units start at boot.
|
// lingerHint prints, once per user, how to make user units start at boot.
|
||||||
func lingerHint(seen map[string]bool, u config.Unit) {
|
func lingerHint(ini initsys.Init, seen map[string]bool, u config.Unit) {
|
||||||
if u.User == "" || seen[u.User] || sysuser.LingerEnabled(u.User) {
|
if !ini.SupportsLinger() || u.User == "" || seen[u.User] || sysuser.LingerEnabled(u.User) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
seen[u.User] = true
|
seen[u.User] = true
|
||||||
|
|||||||
+14
-10
@@ -5,7 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/warkanum/unitdore/config"
|
"github.com/warkanum/unitdore/config"
|
||||||
"github.com/warkanum/unitdore/systemd"
|
"github.com/warkanum/unitdore/initsys"
|
||||||
)
|
)
|
||||||
|
|
||||||
var dryRun bool
|
var dryRun bool
|
||||||
@@ -32,6 +32,10 @@ func runInstall(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prefix, suffix := cfg.Prefix, cfg.Suffix
|
prefix, suffix := cfg.Prefix, cfg.Suffix
|
||||||
|
ini, err := initsys.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
installed := 0
|
installed := 0
|
||||||
skipped := 0
|
skipped := 0
|
||||||
removed := 0
|
removed := 0
|
||||||
@@ -40,14 +44,14 @@ func runInstall(cmd *cobra.Command, args []string) error {
|
|||||||
for _, u := range cfg.Units {
|
for _, u := range cfg.Units {
|
||||||
if !u.Enabled {
|
if !u.Enabled {
|
||||||
// Remove unit file if it exists for disabled units
|
// Remove unit file if it exists for disabled units
|
||||||
if systemd.IsInstalled(u, prefix, suffix) {
|
if ini.IsInstalled(u, prefix, suffix) {
|
||||||
if dryRun {
|
if dryRun {
|
||||||
fmt.Printf(" ~ would remove: %s\n", systemd.ServiceName(u, prefix, suffix))
|
fmt.Printf(" ~ would remove: %s\n", ini.ServiceName(u, prefix, suffix))
|
||||||
} else {
|
} else {
|
||||||
if err := systemd.Uninstall(u, prefix, suffix); err != nil {
|
if err := ini.Uninstall(u, prefix, suffix); err != nil {
|
||||||
fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err)
|
fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf(" - removed: %s (disabled)\n", systemd.ServiceName(u, prefix, suffix))
|
fmt.Printf(" - removed: %s (disabled)\n", ini.ServiceName(u, prefix, suffix))
|
||||||
removed++
|
removed++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,23 +62,23 @@ func runInstall(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if dryRun {
|
if dryRun {
|
||||||
content, err := systemd.Generate(u, prefix, suffix)
|
content, err := ini.Generate(u, prefix, suffix)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf(" ✗ %s: %v\n", u.Name, err)
|
fmt.Printf(" ✗ %s: %v\n", u.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
path, _ := systemd.UnitPath(u, prefix, suffix)
|
path, _ := ini.UnitPath(u, prefix, suffix)
|
||||||
fmt.Printf("\n--- %s ---\n%s\n", path, content)
|
fmt.Printf("\n--- %s ---\n%s\n", path, content)
|
||||||
installed++
|
installed++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := systemd.Install(u, prefix, suffix); err != nil {
|
if err := ini.Install(u, prefix, suffix); err != nil {
|
||||||
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
|
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
|
||||||
} else {
|
} else {
|
||||||
path, _ := systemd.UnitPath(u, prefix, suffix)
|
path, _ := ini.UnitPath(u, prefix, suffix)
|
||||||
fmt.Printf(" ✓ installed: %s\n", path)
|
fmt.Printf(" ✓ installed: %s\n", path)
|
||||||
lingerHint(lingerSeen, u)
|
lingerHint(ini, lingerSeen, u)
|
||||||
installed++
|
installed++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-10
@@ -5,7 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/warkanum/unitdore/config"
|
"github.com/warkanum/unitdore/config"
|
||||||
"github.com/warkanum/unitdore/systemd"
|
"github.com/warkanum/unitdore/initsys"
|
||||||
)
|
)
|
||||||
|
|
||||||
var installallDryRun bool
|
var installallDryRun bool
|
||||||
@@ -32,6 +32,10 @@ func runInstallall(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prefix, suffix := cfg.Prefix, cfg.Suffix
|
prefix, suffix := cfg.Prefix, cfg.Suffix
|
||||||
|
ini, err := initsys.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
installed := 0
|
installed := 0
|
||||||
skipped := 0
|
skipped := 0
|
||||||
removed := 0
|
removed := 0
|
||||||
@@ -39,14 +43,14 @@ func runInstallall(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
for _, u := range cfg.Units {
|
for _, u := range cfg.Units {
|
||||||
if !u.Enabled {
|
if !u.Enabled {
|
||||||
if systemd.IsInstalled(u, prefix, suffix) {
|
if ini.IsInstalled(u, prefix, suffix) {
|
||||||
if installallDryRun {
|
if installallDryRun {
|
||||||
fmt.Printf(" ~ would remove: %s\n", systemd.ServiceName(u, prefix, suffix))
|
fmt.Printf(" ~ would remove: %s\n", ini.ServiceName(u, prefix, suffix))
|
||||||
} else {
|
} else {
|
||||||
if err := systemd.Uninstall(u, prefix, suffix); err != nil {
|
if err := ini.Uninstall(u, prefix, suffix); err != nil {
|
||||||
fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err)
|
fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf(" - removed: %s (disabled)\n", systemd.ServiceName(u, prefix, suffix))
|
fmt.Printf(" - removed: %s (disabled)\n", ini.ServiceName(u, prefix, suffix))
|
||||||
removed++
|
removed++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,23 +61,23 @@ func runInstallall(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if installallDryRun {
|
if installallDryRun {
|
||||||
content, err := systemd.Generate(u, prefix, suffix)
|
content, err := ini.Generate(u, prefix, suffix)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf(" ✗ %s: %v\n", u.Name, err)
|
fmt.Printf(" ✗ %s: %v\n", u.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
path, _ := systemd.UnitPath(u, prefix, suffix)
|
path, _ := ini.UnitPath(u, prefix, suffix)
|
||||||
fmt.Printf("\n--- %s ---\n%s\n", path, content)
|
fmt.Printf("\n--- %s ---\n%s\n", path, content)
|
||||||
installed++
|
installed++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := systemd.Install(u, prefix, suffix); err != nil {
|
if err := ini.Install(u, prefix, suffix); err != nil {
|
||||||
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
|
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
|
||||||
} else {
|
} else {
|
||||||
path, _ := systemd.UnitPath(u, prefix, suffix)
|
path, _ := ini.UnitPath(u, prefix, suffix)
|
||||||
fmt.Printf(" ✓ installed: %s\n", path)
|
fmt.Printf(" ✓ installed: %s\n", path)
|
||||||
lingerHint(lingerSeen, u)
|
lingerHint(ini, lingerSeen, u)
|
||||||
installed++
|
installed++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-3
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/warkanum/unitdore/config"
|
"github.com/warkanum/unitdore/config"
|
||||||
"github.com/warkanum/unitdore/systemd"
|
"github.com/warkanum/unitdore/initsys"
|
||||||
)
|
)
|
||||||
|
|
||||||
var listCmd = &cobra.Command{
|
var listCmd = &cobra.Command{
|
||||||
@@ -41,6 +41,10 @@ func runList(cmd *cobra.Command, args []string) error {
|
|||||||
})
|
})
|
||||||
|
|
||||||
prefix, suffix := cfg.Prefix, cfg.Suffix
|
prefix, suffix := cfg.Prefix, cfg.Suffix
|
||||||
|
ini, err := initsys.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
type row struct{ name, runtime, enabled, installed, path string }
|
type row struct{ name, runtime, enabled, installed, path string }
|
||||||
|
|
||||||
@@ -53,9 +57,9 @@ func runList(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
installed := "no"
|
installed := "no"
|
||||||
unitPath := "—"
|
unitPath := "—"
|
||||||
if p, err := systemd.UnitPath(u, prefix, suffix); err == nil {
|
if p, err := ini.UnitPath(u, prefix, suffix); err == nil {
|
||||||
unitPath = p
|
unitPath = p
|
||||||
if systemd.IsInstalled(u, prefix, suffix) {
|
if ini.IsInstalled(u, prefix, suffix) {
|
||||||
installed = "yes"
|
installed = "yes"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -16,10 +16,11 @@ var configPath string
|
|||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "unitdore",
|
Use: "unitdore",
|
||||||
Short: "A door you open and close for container units",
|
Short: "A door you open and close for container units",
|
||||||
Long: `Unitdore manages container units via systemd.
|
Long: `Unitdore manages container units via the system init (systemd, OpenRC or runit).
|
||||||
|
|
||||||
It discovers running containers and podman-compose stacks, stores them in a
|
It discovers running containers and podman-compose stacks, stores them in a
|
||||||
config file, and generates + manages systemd .service units for each one.
|
config file, and generates + manages init service units for each one (systemd by default;
|
||||||
|
set 'init:' in the config for openrc or runit).
|
||||||
|
|
||||||
As root: system units, plus user units for users listed in 'users:'.
|
As root: system units, plus user units for users listed in 'users:'.
|
||||||
As a normal user: own config and user units only.`,
|
As a normal user: own config and user units only.`,
|
||||||
|
|||||||
+8
-4
@@ -5,7 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/warkanum/unitdore/config"
|
"github.com/warkanum/unitdore/config"
|
||||||
"github.com/warkanum/unitdore/systemd"
|
"github.com/warkanum/unitdore/initsys"
|
||||||
)
|
)
|
||||||
|
|
||||||
var startCmd = &cobra.Command{
|
var startCmd = &cobra.Command{
|
||||||
@@ -32,13 +32,17 @@ func runStart(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prefix, suffix := cfg.Prefix, cfg.Suffix
|
prefix, suffix := cfg.Prefix, cfg.Suffix
|
||||||
|
ini, err := initsys.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if !systemd.IsInstalled(*u, prefix, suffix) {
|
if !ini.IsInstalled(*u, prefix, suffix) {
|
||||||
return fmt.Errorf("%s is not installed — run 'unitdore install' first", name)
|
return fmt.Errorf("%s is not installed — run 'unitdore install' first", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf(" ▶ starting %s...\n", systemd.ServiceName(*u, prefix, suffix))
|
fmt.Printf(" ▶ starting %s...\n", ini.ServiceName(*u, prefix, suffix))
|
||||||
if err := systemd.Start(*u, prefix, suffix); err != nil {
|
if err := ini.Start(*u, prefix, suffix); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf(" ✓ started: %s\n", name)
|
fmt.Printf(" ✓ started: %s\n", name)
|
||||||
|
|||||||
+8
-4
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/warkanum/unitdore/config"
|
"github.com/warkanum/unitdore/config"
|
||||||
"github.com/warkanum/unitdore/systemd"
|
"github.com/warkanum/unitdore/initsys"
|
||||||
)
|
)
|
||||||
|
|
||||||
var startallCmd = &cobra.Command{
|
var startallCmd = &cobra.Command{
|
||||||
@@ -28,6 +28,10 @@ func runStartall(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prefix, suffix := cfg.Prefix, cfg.Suffix
|
prefix, suffix := cfg.Prefix, cfg.Suffix
|
||||||
|
ini, err := initsys.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
units := make([]config.Unit, len(cfg.Units))
|
units := make([]config.Unit, len(cfg.Units))
|
||||||
copy(units, cfg.Units)
|
copy(units, cfg.Units)
|
||||||
@@ -45,12 +49,12 @@ func runStartall(cmd *cobra.Command, args []string) error {
|
|||||||
if !u.Enabled {
|
if !u.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !systemd.IsInstalled(u, prefix, suffix) {
|
if !ini.IsInstalled(u, prefix, suffix) {
|
||||||
fmt.Printf(" ! %s: not installed — run 'unitdore install' first\n", u.Name)
|
fmt.Printf(" ! %s: not installed — run 'unitdore install' first\n", u.Name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Printf(" ▶ starting: %s...\n", systemd.ServiceName(u, prefix, suffix))
|
fmt.Printf(" ▶ starting: %s...\n", ini.ServiceName(u, prefix, suffix))
|
||||||
if err := systemd.Start(u, prefix, suffix); err != nil {
|
if err := ini.Start(u, prefix, suffix); err != nil {
|
||||||
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
|
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
|
||||||
failed++
|
failed++
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+8
-4
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/warkanum/unitdore/config"
|
"github.com/warkanum/unitdore/config"
|
||||||
"github.com/warkanum/unitdore/systemd"
|
"github.com/warkanum/unitdore/initsys"
|
||||||
)
|
)
|
||||||
|
|
||||||
var statusCmd = &cobra.Command{
|
var statusCmd = &cobra.Command{
|
||||||
@@ -37,6 +37,10 @@ func runStatus(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prefix, suffix := cfg.Prefix, cfg.Suffix
|
prefix, suffix := cfg.Prefix, cfg.Suffix
|
||||||
|
ini, err := initsys.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if len(cfg.Units) == 0 {
|
if len(cfg.Units) == 0 {
|
||||||
fmt.Println("No units configured. Run 'unitdore syncup' to discover containers.")
|
fmt.Println("No units configured. Run 'unitdore syncup' to discover containers.")
|
||||||
@@ -65,13 +69,13 @@ func runStatus(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
installed := "yes"
|
installed := "yes"
|
||||||
if !systemd.IsInstalled(u, prefix, suffix) {
|
if !ini.IsInstalled(u, prefix, suffix) {
|
||||||
installed = "no"
|
installed = "no"
|
||||||
}
|
}
|
||||||
|
|
||||||
state := "—"
|
state := "—"
|
||||||
if systemd.IsInstalled(u, prefix, suffix) {
|
if ini.IsInstalled(u, prefix, suffix) {
|
||||||
state = systemd.Status(u, prefix, suffix)
|
state = ini.Status(u, prefix, suffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows = append(rows, statusRow{
|
rows = append(rows, statusRow{
|
||||||
|
|||||||
+8
-4
@@ -5,7 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/warkanum/unitdore/config"
|
"github.com/warkanum/unitdore/config"
|
||||||
"github.com/warkanum/unitdore/systemd"
|
"github.com/warkanum/unitdore/initsys"
|
||||||
)
|
)
|
||||||
|
|
||||||
var stopCmd = &cobra.Command{
|
var stopCmd = &cobra.Command{
|
||||||
@@ -32,13 +32,17 @@ func runStop(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prefix, suffix := cfg.Prefix, cfg.Suffix
|
prefix, suffix := cfg.Prefix, cfg.Suffix
|
||||||
|
ini, err := initsys.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if !systemd.IsInstalled(*u, prefix, suffix) {
|
if !ini.IsInstalled(*u, prefix, suffix) {
|
||||||
return fmt.Errorf("%s is not installed", name)
|
return fmt.Errorf("%s is not installed", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf(" ■ stopping %s...\n", systemd.ServiceName(*u, prefix, suffix))
|
fmt.Printf(" ■ stopping %s...\n", ini.ServiceName(*u, prefix, suffix))
|
||||||
if err := systemd.Stop(*u, prefix, suffix); err != nil {
|
if err := ini.Stop(*u, prefix, suffix); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf(" ✓ stopped: %s\n", name)
|
fmt.Printf(" ✓ stopped: %s\n", name)
|
||||||
|
|||||||
+8
-4
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/warkanum/unitdore/config"
|
"github.com/warkanum/unitdore/config"
|
||||||
"github.com/warkanum/unitdore/systemd"
|
"github.com/warkanum/unitdore/initsys"
|
||||||
)
|
)
|
||||||
|
|
||||||
var stopallCmd = &cobra.Command{
|
var stopallCmd = &cobra.Command{
|
||||||
@@ -27,6 +27,10 @@ func runStopall(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prefix, suffix := cfg.Prefix, cfg.Suffix
|
prefix, suffix := cfg.Prefix, cfg.Suffix
|
||||||
|
ini, err := initsys.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Reverse order for shutdown
|
// Reverse order for shutdown
|
||||||
units := make([]config.Unit, len(cfg.Units))
|
units := make([]config.Unit, len(cfg.Units))
|
||||||
@@ -42,11 +46,11 @@ func runStopall(cmd *cobra.Command, args []string) error {
|
|||||||
failed := 0
|
failed := 0
|
||||||
|
|
||||||
for _, u := range units {
|
for _, u := range units {
|
||||||
if !systemd.IsInstalled(u, prefix, suffix) {
|
if !ini.IsInstalled(u, prefix, suffix) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Printf(" ■ stopping: %s...\n", systemd.ServiceName(u, prefix, suffix))
|
fmt.Printf(" ■ stopping: %s...\n", ini.ServiceName(u, prefix, suffix))
|
||||||
if err := systemd.Stop(u, prefix, suffix); err != nil {
|
if err := ini.Stop(u, prefix, suffix); err != nil {
|
||||||
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
|
fmt.Printf(" ✗ failed: %s: %v\n", u.Name, err)
|
||||||
failed++
|
failed++
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/warkanum/unitdore/config"
|
"github.com/warkanum/unitdore/config"
|
||||||
"github.com/warkanum/unitdore/runtime"
|
"github.com/warkanum/unitdore/runtime"
|
||||||
"github.com/warkanum/unitdore/systemd"
|
"github.com/warkanum/unitdore/service"
|
||||||
"github.com/warkanum/unitdore/sysuser"
|
"github.com/warkanum/unitdore/sysuser"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ func mergeDiscovered(cfg *config.Config, containers []runtime.Container, compose
|
|||||||
// unitExists checks whether a unit's container or stack still exists.
|
// unitExists checks whether a unit's container or stack still exists.
|
||||||
func unitExists(u config.Unit) (bool, error) {
|
func unitExists(u config.Unit) (bool, error) {
|
||||||
if u.IsStack() {
|
if u.IsStack() {
|
||||||
if systemd.ComposeAvailable(u) {
|
if service.ComposeAvailable(u) {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
return (&runtime.Podman{}).PodExists(u.User, runtime.PodName(u.Project))
|
return (&runtime.Podman{}).PodExists(u.User, runtime.PodName(u.Project))
|
||||||
|
|||||||
+10
-6
@@ -5,7 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/warkanum/unitdore/config"
|
"github.com/warkanum/unitdore/config"
|
||||||
"github.com/warkanum/unitdore/systemd"
|
"github.com/warkanum/unitdore/initsys"
|
||||||
)
|
)
|
||||||
|
|
||||||
var uninstallCmd = &cobra.Command{
|
var uninstallCmd = &cobra.Command{
|
||||||
@@ -32,21 +32,25 @@ func runUninstall(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prefix, suffix := cfg.Prefix, cfg.Suffix
|
prefix, suffix := cfg.Prefix, cfg.Suffix
|
||||||
|
ini, err := initsys.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if !systemd.IsInstalled(*u, prefix, suffix) {
|
if !ini.IsInstalled(*u, prefix, suffix) {
|
||||||
return fmt.Errorf("%s is not installed", name)
|
return fmt.Errorf("%s is not installed", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
svcName := systemd.ServiceName(*u, prefix, suffix)
|
svcName := ini.ServiceName(*u, prefix, suffix)
|
||||||
|
|
||||||
fmt.Printf(" ■ disabling %s...\n", svcName)
|
fmt.Printf(" ■ disabling %s...\n", svcName)
|
||||||
if err := systemd.Disable(*u, prefix, suffix); err != nil {
|
if err := ini.Disable(*u, prefix, suffix); err != nil {
|
||||||
return fmt.Errorf("disabling unit: %w", err)
|
return fmt.Errorf("disabling unit: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
path, _ := systemd.UnitPath(*u, prefix, suffix)
|
path, _ := ini.UnitPath(*u, prefix, suffix)
|
||||||
fmt.Printf(" ✗ removing %s...\n", path)
|
fmt.Printf(" ✗ removing %s...\n", path)
|
||||||
if err := systemd.Uninstall(*u, prefix, suffix); err != nil {
|
if err := ini.Uninstall(*u, prefix, suffix); err != nil {
|
||||||
return fmt.Errorf("removing service file: %w", err)
|
return fmt.Errorf("removing service file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-6
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/warkanum/unitdore/config"
|
"github.com/warkanum/unitdore/config"
|
||||||
"github.com/warkanum/unitdore/systemd"
|
"github.com/warkanum/unitdore/initsys"
|
||||||
)
|
)
|
||||||
|
|
||||||
var uninstallallCmd = &cobra.Command{
|
var uninstallallCmd = &cobra.Command{
|
||||||
@@ -27,6 +27,10 @@ func runUninstallall(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prefix, suffix := cfg.Prefix, cfg.Suffix
|
prefix, suffix := cfg.Prefix, cfg.Suffix
|
||||||
|
ini, err := initsys.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
units := make([]config.Unit, len(cfg.Units))
|
units := make([]config.Unit, len(cfg.Units))
|
||||||
copy(units, cfg.Units)
|
copy(units, cfg.Units)
|
||||||
@@ -41,19 +45,19 @@ func runUninstallall(cmd *cobra.Command, args []string) error {
|
|||||||
failed := 0
|
failed := 0
|
||||||
|
|
||||||
for _, u := range units {
|
for _, u := range units {
|
||||||
if !systemd.IsInstalled(u, prefix, suffix) {
|
if !ini.IsInstalled(u, prefix, suffix) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
svcName := systemd.ServiceName(u, prefix, suffix)
|
svcName := ini.ServiceName(u, prefix, suffix)
|
||||||
fmt.Printf(" ■ disabling %s...\n", svcName)
|
fmt.Printf(" ■ disabling %s...\n", svcName)
|
||||||
if err := systemd.Disable(u, prefix, suffix); err != nil {
|
if err := ini.Disable(u, prefix, suffix); err != nil {
|
||||||
fmt.Printf(" ✗ failed to disable %s: %v\n", u.Name, err)
|
fmt.Printf(" ✗ failed to disable %s: %v\n", u.Name, err)
|
||||||
failed++
|
failed++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
path, _ := systemd.UnitPath(u, prefix, suffix)
|
path, _ := ini.UnitPath(u, prefix, suffix)
|
||||||
fmt.Printf(" ✗ removing %s...\n", path)
|
fmt.Printf(" ✗ removing %s...\n", path)
|
||||||
if err := systemd.Uninstall(u, prefix, suffix); err != nil {
|
if err := ini.Uninstall(u, prefix, suffix); err != nil {
|
||||||
fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err)
|
fmt.Printf(" ✗ failed to remove %s: %v\n", u.Name, err)
|
||||||
failed++
|
failed++
|
||||||
continue
|
continue
|
||||||
|
|||||||
+8
-4
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/warkanum/unitdore/config"
|
"github.com/warkanum/unitdore/config"
|
||||||
"github.com/warkanum/unitdore/systemd"
|
"github.com/warkanum/unitdore/initsys"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -64,12 +64,16 @@ func runUpdate(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prefix, suffix := cfg.Prefix, cfg.Suffix
|
prefix, suffix := cfg.Prefix, cfg.Suffix
|
||||||
|
ini, err := initsys.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if systemd.IsInstalled(*u, prefix, suffix) {
|
if ini.IsInstalled(*u, prefix, suffix) {
|
||||||
if err := systemd.Install(*u, prefix, suffix); err != nil {
|
if err := ini.Install(*u, prefix, suffix); err != nil {
|
||||||
return fmt.Errorf("recreating service file: %w", err)
|
return fmt.Errorf("recreating service file: %w", err)
|
||||||
}
|
}
|
||||||
path, _ := systemd.UnitPath(*u, prefix, suffix)
|
path, _ := ini.UnitPath(*u, prefix, suffix)
|
||||||
fmt.Printf(" ✓ service file recreated: %s\n", path)
|
fmt.Printf(" ✓ service file recreated: %s\n", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ type Config struct {
|
|||||||
Prefix string `yaml:"prefix,omitempty"` // prepended to generated service name, e.g. "prod-"
|
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"
|
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
|
Users []string `yaml:"users,omitempty"` // rootless users scanned by syncup when run as root
|
||||||
|
|
||||||
|
Init string `yaml:"init,omitempty"` // systemd | openrc | runit (empty = autodetect)
|
||||||
|
ServiceDir string `yaml:"service_dir,omitempty"` // runit: supervised service dir (empty = autodetect)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads and parses the config file at path.
|
// Load reads and parses the config file at path.
|
||||||
|
|||||||
+323
-1
@@ -1,4 +1,4 @@
|
|||||||
# Generated Systemd Unit Files
|
# Generated Unit Files (systemd, OpenRC, runit)
|
||||||
|
|
||||||
Unitdore generates `.service` files from your `units.yaml` config.
|
Unitdore generates `.service` files from your `units.yaml` config.
|
||||||
This document shows every combination with annotated examples.
|
This document shows every combination with annotated examples.
|
||||||
@@ -330,6 +330,326 @@ ExecStop=-/usr/bin/podman pod stop pod_shop
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Config Behind the Init System Examples
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
init: systemd # systemd | openrc | runit
|
||||||
|
units:
|
||||||
|
- {name: nginx, runtime: podman, order: 1, enabled: true}
|
||||||
|
- {name: redis, runtime: docker, order: 2, enabled: true, restart: true, restart_sec: 5, restart_retries: 3}
|
||||||
|
- {name: myapp, runtime: podman, user: alice, order: 3, enabled: true}
|
||||||
|
- {name: backup, runtime: script, script: /opt/scripts/backup.sh, stop_script: /opt/scripts/stop.sh, shell: /bin/sh, working_dir: /opt/scripts, order: 4, enabled: true}
|
||||||
|
- {name: agent, runtime: exec, executable: /opt/agent/bin/agent, args: [--config, /etc/agent.toml], service_type: oneshot, order: 5, enabled: true}
|
||||||
|
- {name: shop, runtime: podman, kind: stack, project: shop, order: 6, enabled: true}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Script and Executable Units (systemd)
|
||||||
|
|
||||||
|
`script` runs `<shell> <script>` (default shell `/bin/bash`); `exec` runs the binary directly.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/unitdore-backup.service
|
||||||
|
# Generated by unitdore — do not edit manually
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Unitdore: backup (sh script)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/opt/scripts
|
||||||
|
ExecStart=/bin/sh /opt/scripts/backup.sh
|
||||||
|
ExecStop=-/bin/sh /opt/scripts/stop.sh
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# /etc/systemd/system/unitdore-agent.service
|
||||||
|
# Generated by unitdore — do not edit manually
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Unitdore: agent (executable)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
ExecStart=/opt/agent/bin/agent --config /etc/agent.toml
|
||||||
|
ExecStop=
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenRC
|
||||||
|
|
||||||
|
Root only. Files in `/etc/init.d/`. Rootless `user:` units run as a system service via `command_user`.
|
||||||
|
|
||||||
|
### nginx
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/sbin/openrc-run
|
||||||
|
# /etc/init.d/unitdore-nginx
|
||||||
|
# Generated by unitdore — do not edit manually
|
||||||
|
|
||||||
|
description="Unitdore: nginx (podman)"
|
||||||
|
|
||||||
|
command_background=yes
|
||||||
|
pidfile="/run/${RC_SVCNAME}.pid"
|
||||||
|
command="/usr/bin/podman"
|
||||||
|
command_args="start -a nginx"
|
||||||
|
|
||||||
|
depend() {
|
||||||
|
need net
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_pre() {
|
||||||
|
/usr/bin/podman stop nginx || true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### redis
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/sbin/openrc-run
|
||||||
|
# /etc/init.d/unitdore-redis
|
||||||
|
# Generated by unitdore — do not edit manually
|
||||||
|
|
||||||
|
description="Unitdore: redis (docker)"
|
||||||
|
|
||||||
|
supervisor=supervise-daemon
|
||||||
|
respawn_delay=5
|
||||||
|
respawn_max=3
|
||||||
|
command="/usr/bin/docker"
|
||||||
|
command_args="start -a redis"
|
||||||
|
|
||||||
|
depend() {
|
||||||
|
need net
|
||||||
|
need docker
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_pre() {
|
||||||
|
/usr/bin/docker stop redis || true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### myapp
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/sbin/openrc-run
|
||||||
|
# /etc/init.d/unitdore-myapp
|
||||||
|
# Generated by unitdore — do not edit manually
|
||||||
|
|
||||||
|
description="Unitdore: myapp (podman)"
|
||||||
|
export HOME=/home/alice
|
||||||
|
export XDG_RUNTIME_DIR=/run/user/1000
|
||||||
|
|
||||||
|
command_background=yes
|
||||||
|
pidfile="/run/${RC_SVCNAME}.pid"
|
||||||
|
command="/usr/bin/podman"
|
||||||
|
command_args="start -a myapp"
|
||||||
|
command_user="alice"
|
||||||
|
|
||||||
|
depend() {
|
||||||
|
need net
|
||||||
|
}
|
||||||
|
|
||||||
|
start_pre() {
|
||||||
|
checkpath -d -m 0700 -o alice /run/user/1000
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_pre() {
|
||||||
|
su -m -s /bin/sh -c '/usr/bin/podman stop myapp' alice || true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### backup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/sbin/openrc-run
|
||||||
|
# /etc/init.d/unitdore-backup
|
||||||
|
# Generated by unitdore — do not edit manually
|
||||||
|
|
||||||
|
description="Unitdore: backup (sh script)"
|
||||||
|
|
||||||
|
command_background=yes
|
||||||
|
pidfile="/run/${RC_SVCNAME}.pid"
|
||||||
|
command="/bin/sh"
|
||||||
|
command_args="/opt/scripts/backup.sh"
|
||||||
|
directory="/opt/scripts"
|
||||||
|
|
||||||
|
depend() {
|
||||||
|
need net
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_pre() {
|
||||||
|
/bin/sh /opt/scripts/stop.sh || true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### agent
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/sbin/openrc-run
|
||||||
|
# /etc/init.d/unitdore-agent
|
||||||
|
# Generated by unitdore — do not edit manually
|
||||||
|
|
||||||
|
description="Unitdore: agent (executable)"
|
||||||
|
|
||||||
|
start() {
|
||||||
|
ebegin "Starting $RC_SVCNAME"
|
||||||
|
/opt/agent/bin/agent --config /etc/agent.toml
|
||||||
|
eend $?
|
||||||
|
}
|
||||||
|
|
||||||
|
depend() {
|
||||||
|
need net
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### shop
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/sbin/openrc-run
|
||||||
|
# /etc/init.d/unitdore-shop
|
||||||
|
# Generated by unitdore — do not edit manually
|
||||||
|
|
||||||
|
description="Unitdore: shop (podman pod)"
|
||||||
|
|
||||||
|
start() {
|
||||||
|
ebegin "Starting $RC_SVCNAME"
|
||||||
|
/usr/bin/podman pod start pod_shop
|
||||||
|
eend $?
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
ebegin "Stopping $RC_SVCNAME"
|
||||||
|
/usr/bin/podman pod stop pod_shop
|
||||||
|
eend $?
|
||||||
|
}
|
||||||
|
|
||||||
|
depend() {
|
||||||
|
need net
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## runit
|
||||||
|
|
||||||
|
Root only. Each unit is `/etc/sv/unitdore-<name>/{run,finish}` symlinked into the service dir. Install adds a `down` file (no autostart until started/enabled). Both files are shown per unit.
|
||||||
|
|
||||||
|
### nginx
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# ===== /etc/sv/unitdore-nginx/run =====
|
||||||
|
#!/bin/sh
|
||||||
|
# unitdore-nginx — generated by unitdore, do not edit manually
|
||||||
|
exec 2>&1
|
||||||
|
exec /usr/bin/podman start -a nginx
|
||||||
|
|
||||||
|
# ===== /etc/sv/unitdore-nginx/finish =====
|
||||||
|
#!/bin/sh
|
||||||
|
# unitdore-nginx — generated by unitdore, do not edit manually
|
||||||
|
/usr/bin/podman stop nginx || true
|
||||||
|
exec sv down .
|
||||||
|
```
|
||||||
|
|
||||||
|
### redis
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# ===== /etc/sv/unitdore-redis/run =====
|
||||||
|
#!/bin/sh
|
||||||
|
# unitdore-redis — generated by unitdore, do not edit manually
|
||||||
|
exec 2>&1
|
||||||
|
exec /usr/bin/docker start -a redis
|
||||||
|
|
||||||
|
# ===== /etc/sv/unitdore-redis/finish =====
|
||||||
|
#!/bin/sh
|
||||||
|
# unitdore-redis — generated by unitdore, do not edit manually
|
||||||
|
/usr/bin/docker stop redis || true
|
||||||
|
[ "$1" = 0 ] && exec sv down .
|
||||||
|
sleep 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### myapp
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# ===== /etc/sv/unitdore-myapp/run =====
|
||||||
|
#!/bin/sh
|
||||||
|
# unitdore-myapp — generated by unitdore, do not edit manually
|
||||||
|
exec 2>&1
|
||||||
|
[ -d /run/user/1000 ] || install -d -m 0700 -o alice /run/user/1000
|
||||||
|
export HOME=/home/alice
|
||||||
|
export XDG_RUNTIME_DIR=/run/user/1000
|
||||||
|
exec chpst -u alice /usr/bin/podman start -a myapp
|
||||||
|
|
||||||
|
# ===== /etc/sv/unitdore-myapp/finish =====
|
||||||
|
#!/bin/sh
|
||||||
|
# unitdore-myapp — generated by unitdore, do not edit manually
|
||||||
|
chpst -u alice /usr/bin/podman stop myapp || true
|
||||||
|
exec sv down .
|
||||||
|
```
|
||||||
|
|
||||||
|
### backup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# ===== /etc/sv/unitdore-backup/run =====
|
||||||
|
#!/bin/sh
|
||||||
|
# unitdore-backup — generated by unitdore, do not edit manually
|
||||||
|
exec 2>&1
|
||||||
|
cd /opt/scripts || exit 1
|
||||||
|
exec /bin/sh /opt/scripts/backup.sh
|
||||||
|
|
||||||
|
# ===== /etc/sv/unitdore-backup/finish =====
|
||||||
|
#!/bin/sh
|
||||||
|
# unitdore-backup — generated by unitdore, do not edit manually
|
||||||
|
cd /opt/scripts 2>/dev/null
|
||||||
|
/bin/sh /opt/scripts/stop.sh || true
|
||||||
|
exec sv down .
|
||||||
|
```
|
||||||
|
|
||||||
|
### agent
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# ===== /etc/sv/unitdore-agent/run =====
|
||||||
|
#!/bin/sh
|
||||||
|
# unitdore-agent — generated by unitdore, do not edit manually
|
||||||
|
exec 2>&1
|
||||||
|
/opt/agent/bin/agent --config /etc/agent.toml || exit 1
|
||||||
|
exec tail -f /dev/null
|
||||||
|
|
||||||
|
# ===== /etc/sv/unitdore-agent/finish =====
|
||||||
|
#!/bin/sh
|
||||||
|
# unitdore-agent — generated by unitdore, do not edit manually
|
||||||
|
exec sv down .
|
||||||
|
```
|
||||||
|
|
||||||
|
### shop
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# ===== /etc/sv/unitdore-shop/run =====
|
||||||
|
#!/bin/sh
|
||||||
|
# unitdore-shop — generated by unitdore, do not edit manually
|
||||||
|
exec 2>&1
|
||||||
|
/usr/bin/podman pod start pod_shop || exit 1
|
||||||
|
exec tail -f /dev/null
|
||||||
|
|
||||||
|
# ===== /etc/sv/unitdore-shop/finish =====
|
||||||
|
#!/bin/sh
|
||||||
|
# unitdore-shop — generated by unitdore, do not edit manually
|
||||||
|
/usr/bin/podman pod stop pod_shop || true
|
||||||
|
exec sv down .
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quick Reference: Service File Locations
|
## Quick Reference: Service File Locations
|
||||||
|
|
||||||
| Scenario | Path |
|
| Scenario | Path |
|
||||||
@@ -339,3 +659,5 @@ ExecStop=-/usr/bin/podman pod stop pod_shop
|
|||||||
| With prefix `prod-` | `/etc/systemd/system/unitdore-prod-<name>.service` |
|
| With prefix `prod-` | `/etc/systemd/system/unitdore-prod-<name>.service` |
|
||||||
| With suffix `-svc` | `/etc/systemd/system/unitdore-<name>-svc.service` |
|
| With suffix `-svc` | `/etc/systemd/system/unitdore-<name>-svc.service` |
|
||||||
| With both | `/etc/systemd/system/unitdore-prod-<name>-svc.service` |
|
| With both | `/etc/systemd/system/unitdore-prod-<name>-svc.service` |
|
||||||
|
| OpenRC | `/etc/init.d/unitdore-<name>` |
|
||||||
|
| runit | `/etc/sv/unitdore-<name>/` (+ symlink in `/var/service` or `/etc/service`) |
|
||||||
|
|||||||
+9
-2
@@ -1,6 +1,6 @@
|
|||||||
.TH UNITDORE 1 "2026-04-08" "0.1.0" "User Commands"
|
.TH UNITDORE 1 "2026-04-08" "0.1.0" "User Commands"
|
||||||
.SH NAME
|
.SH NAME
|
||||||
unitdore \- manage container units via systemd
|
unitdore \- manage container units via systemd, OpenRC or runit
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
.B unitdore
|
.B unitdore
|
||||||
[\fB\-\-config\fR \fIpath\fR]
|
[\fB\-\-config\fR \fIpath\fR]
|
||||||
@@ -106,6 +106,13 @@ units:
|
|||||||
Stack units run \fBpodman\-compose up\fR/\fBstop\fR when compose_bin,
|
Stack units run \fBpodman\-compose up\fR/\fBstop\fR when compose_bin,
|
||||||
working_dir and compose_files exist; otherwise \fBpodman pod start\fR/\fBstop
|
working_dir and compose_files exist; otherwise \fBpodman pod start\fR/\fBstop
|
||||||
pod_\fR\fIproject\fR.
|
pod_\fR\fIproject\fR.
|
||||||
|
.SH INIT SYSTEMS
|
||||||
|
The \fBinit:\fR config key selects \fBsystemd\fR, \fBopenrc\fR or \fBrunit\fR.
|
||||||
|
Empty means autodetect, falling back to systemd. OpenRC and runit are root only;
|
||||||
|
rootless units run as system services as that user. runit service links go in
|
||||||
|
\fBservice_dir:\fR (default \fI/var/service\fR or \fI/etc/service\fR).
|
||||||
|
OpenRC scripts are written to \fI/etc/init.d/unitdore\-*\fR and runit services to
|
||||||
|
\fI/etc/sv/unitdore\-*\fR.
|
||||||
.SH GENERATED SERVICE FILES
|
.SH GENERATED SERVICE FILES
|
||||||
System units are written to \fI/etc/systemd/system/\fR and user units to
|
System units are written to \fI/etc/systemd/system/\fR and user units to
|
||||||
\fI~/.config/systemd/user/\fR.
|
\fI~/.config/systemd/user/\fR.
|
||||||
@@ -156,7 +163,7 @@ Generated system unit files.
|
|||||||
.I ~/.config/systemd/user/unitdore-*.service
|
.I ~/.config/systemd/user/unitdore-*.service
|
||||||
Generated rootless user unit files.
|
Generated rootless user unit files.
|
||||||
.SH REQUIREMENTS
|
.SH REQUIREMENTS
|
||||||
systemd; Podman and/or Docker (only installed runtimes are used);
|
systemd, OpenRC or runit; Podman and/or Docker (only installed runtimes are used);
|
||||||
podman\-compose for compose\-mode stacks.
|
podman\-compose for compose\-mode stacks.
|
||||||
User units need linger to start at boot: \fBloginctl enable\-linger\fR \fIuser\fR.
|
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.
|
Root is required for system units; the relevant user for rootless units.
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Package initsys selects the init system backend that installs and controls
|
||||||
|
// unitdore services.
|
||||||
|
package initsys
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/warkanum/unitdore/config"
|
||||||
|
"github.com/warkanum/unitdore/openrc"
|
||||||
|
"github.com/warkanum/unitdore/runit"
|
||||||
|
"github.com/warkanum/unitdore/systemd"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Backend names.
|
||||||
|
const (
|
||||||
|
Systemd = "systemd"
|
||||||
|
OpenRC = "openrc"
|
||||||
|
Runit = "runit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init is implemented by every init system backend. u.User selects the scope;
|
||||||
|
// backends that cannot do per-user managers run user units as system services.
|
||||||
|
type Init interface {
|
||||||
|
Name() string
|
||||||
|
ServiceName(u config.Unit, prefix, suffix string) string
|
||||||
|
UnitPath(u config.Unit, prefix, suffix string) (string, error)
|
||||||
|
Generate(u config.Unit, prefix, suffix string) (string, error)
|
||||||
|
Install(u config.Unit, prefix, suffix string) error
|
||||||
|
Uninstall(u config.Unit, prefix, suffix string) error
|
||||||
|
Enable(u config.Unit, prefix, suffix string) error
|
||||||
|
Disable(u config.Unit, prefix, suffix string) error
|
||||||
|
Start(u config.Unit, prefix, suffix string) error
|
||||||
|
Stop(u config.Unit, prefix, suffix string) error
|
||||||
|
// Status returns active | inactive | failed | activating | deactivating | unknown.
|
||||||
|
Status(u config.Unit, prefix, suffix string) string
|
||||||
|
IsInstalled(u config.Unit, prefix, suffix string) bool
|
||||||
|
// SupportsLinger reports whether user units need systemd-style linger.
|
||||||
|
SupportsLinger() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns the backend named in cfg.Init, or the detected one.
|
||||||
|
func New(cfg *config.Config) (Init, error) {
|
||||||
|
name := cfg.Init
|
||||||
|
if name == "" {
|
||||||
|
name = Detect("/")
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
|
case Systemd:
|
||||||
|
return systemd.Backend{}, nil
|
||||||
|
case OpenRC:
|
||||||
|
return openrc.Backend{}, nil
|
||||||
|
case Runit:
|
||||||
|
return runit.Backend{ServiceDir: cfg.ServiceDir}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown init %q (want systemd, openrc or runit)", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect guesses the running init system below root. Falls back to systemd.
|
||||||
|
func Detect(root string) string {
|
||||||
|
has := func(p string) bool {
|
||||||
|
_, err := os.Stat(filepath.Join(root, p))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case has("run/systemd/system"):
|
||||||
|
return Systemd
|
||||||
|
case has("run/openrc"), has("sbin/openrc-run"):
|
||||||
|
return OpenRC
|
||||||
|
case has("run/runit"), has("etc/runit"):
|
||||||
|
return Runit
|
||||||
|
default:
|
||||||
|
return Systemd
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package initsys
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/warkanum/unitdore/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetect(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
paths []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"systemd", []string{"run/systemd/system"}, Systemd},
|
||||||
|
{"openrc", []string{"run/openrc"}, OpenRC},
|
||||||
|
{"openrc binary", []string{"sbin/openrc-run"}, OpenRC},
|
||||||
|
{"runit", []string{"etc/runit"}, Runit},
|
||||||
|
{"systemd wins", []string{"run/systemd/system", "etc/runit"}, Systemd},
|
||||||
|
{"fallback", nil, Systemd},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
for _, p := range tt.paths {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filepath.Join(root, p)), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, p), nil, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := Detect(root); got != tt.want {
|
||||||
|
t.Errorf("Detect() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew(t *testing.T) {
|
||||||
|
for _, name := range []string{Systemd, OpenRC, Runit} {
|
||||||
|
ini, err := New(&config.Config{Init: name})
|
||||||
|
if err != nil || ini.Name() != name {
|
||||||
|
t.Errorf("New(%q) = %v, %v", name, ini, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := New(&config.Config{Init: "sysv"}); err == nil {
|
||||||
|
t.Error("New() should reject an unknown init")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
// Package openrc generates and manages OpenRC init scripts.
|
||||||
|
//
|
||||||
|
// Services run at system level (root only). Units with a user run as that
|
||||||
|
// user through command_user, with HOME and XDG_RUNTIME_DIR set.
|
||||||
|
package openrc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/warkanum/unitdore/config"
|
||||||
|
"github.com/warkanum/unitdore/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
const runlevel = "default"
|
||||||
|
|
||||||
|
// Overridable for tests.
|
||||||
|
var (
|
||||||
|
initDir = "/etc/init.d"
|
||||||
|
|
||||||
|
// runCmd runs a command with stdio passed through.
|
||||||
|
runCmd = func(name string, args ...string) error {
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
// outCmd runs a command and returns its combined output.
|
||||||
|
outCmd = func(name string, args ...string) ([]byte, error) {
|
||||||
|
return exec.Command(name, args...).CombinedOutput()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Backend is the OpenRC implementation of the init interface.
|
||||||
|
type Backend struct{}
|
||||||
|
|
||||||
|
func (Backend) Name() string { return "openrc" }
|
||||||
|
|
||||||
|
// SupportsLinger is false: OpenRC services need no linger.
|
||||||
|
func (Backend) SupportsLinger() bool { return false }
|
||||||
|
|
||||||
|
func (Backend) ServiceName(u config.Unit, prefix, suffix string) string {
|
||||||
|
return service.BaseName(u, prefix, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Backend) UnitPath(u config.Unit, prefix, suffix string) (string, error) {
|
||||||
|
return filepath.Join(initDir, b.ServiceName(u, prefix, suffix)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Backend) IsInstalled(u config.Unit, prefix, suffix string) bool {
|
||||||
|
path, _ := b.UnitPath(u, prefix, suffix)
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Backend) Install(u config.Unit, prefix, suffix string) error {
|
||||||
|
if err := service.RequireRoot("openrc"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content, err := b.Generate(u, prefix, suffix)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path, _ := b.UnitPath(u, prefix, suffix)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating init dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0755); err != nil {
|
||||||
|
return fmt.Errorf("writing init script: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Backend) Uninstall(u config.Unit, prefix, suffix string) error {
|
||||||
|
if err := service.RequireRoot("openrc"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name := b.ServiceName(u, prefix, suffix)
|
||||||
|
_ = runCmd("rc-update", "del", name, runlevel) // not in runlevel is fine
|
||||||
|
path, _ := b.UnitPath(u, prefix, suffix)
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("removing init script: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable adds the service to the default runlevel and starts it.
|
||||||
|
func (b Backend) Enable(u config.Unit, prefix, suffix string) error {
|
||||||
|
if err := service.RequireRoot("openrc"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name := b.ServiceName(u, prefix, suffix)
|
||||||
|
if err := runCmd("rc-update", "add", name, runlevel); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runCmd("rc-service", name, "start")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable stops the service and removes it from the default runlevel.
|
||||||
|
func (b Backend) Disable(u config.Unit, prefix, suffix string) error {
|
||||||
|
if err := service.RequireRoot("openrc"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name := b.ServiceName(u, prefix, suffix)
|
||||||
|
if err := runCmd("rc-service", name, "stop"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = runCmd("rc-update", "del", name, runlevel) // not in runlevel is fine
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Backend) Start(u config.Unit, prefix, suffix string) error {
|
||||||
|
if err := service.RequireRoot("openrc"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runCmd("rc-service", b.ServiceName(u, prefix, suffix), "start")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Backend) Stop(u config.Unit, prefix, suffix string) error {
|
||||||
|
if err := service.RequireRoot("openrc"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runCmd("rc-service", b.ServiceName(u, prefix, suffix), "stop")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Backend) Status(u config.Unit, prefix, suffix string) string {
|
||||||
|
// rc-service status exits non-zero for stopped services but still prints
|
||||||
|
out, _ := outCmd("rc-service", b.ServiceName(u, prefix, suffix), "status")
|
||||||
|
return parseStatus(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStatus maps `rc-service <name> status` output to a systemd-like state.
|
||||||
|
func parseStatus(out string) string {
|
||||||
|
out = strings.ToLower(out)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(out, "crashed"):
|
||||||
|
return "failed"
|
||||||
|
case strings.Contains(out, "starting"):
|
||||||
|
return "activating"
|
||||||
|
case strings.Contains(out, "stopping"):
|
||||||
|
return "deactivating"
|
||||||
|
case strings.Contains(out, "started"):
|
||||||
|
return "active"
|
||||||
|
case strings.Contains(out, "stopped"):
|
||||||
|
return "inactive"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate produces the init script for a unit.
|
||||||
|
func (b Backend) Generate(u config.Unit, prefix, suffix string) (string, error) {
|
||||||
|
if err := service.Validate(u); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
user, err := service.LookupUser(u.User)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
spec := service.Resolve(u)
|
||||||
|
|
||||||
|
var w strings.Builder
|
||||||
|
w.WriteString("#!/sbin/openrc-run\n")
|
||||||
|
w.WriteString("# " + filepath.Join(initDir, b.ServiceName(u, prefix, suffix)) + "\n")
|
||||||
|
w.WriteString("# Generated by unitdore — do not edit manually\n\n")
|
||||||
|
fmt.Fprintf(&w, "description=%s\n", dq(fmt.Sprintf("Unitdore: %s (%s)", u.Name, spec.Label)))
|
||||||
|
|
||||||
|
if user != nil {
|
||||||
|
fmt.Fprintf(&w, "export HOME=%s\n", service.ShellQuote(user.Home))
|
||||||
|
fmt.Fprintf(&w, "export XDG_RUNTIME_DIR=%s\n", service.ShellQuote(user.RuntimeDir))
|
||||||
|
}
|
||||||
|
|
||||||
|
start, err := service.ShellCommand(spec.Start)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("unit %s: %w", u.Name, err)
|
||||||
|
}
|
||||||
|
stop := ""
|
||||||
|
if spec.Stop != "" {
|
||||||
|
if stop, err = service.ShellCommand(spec.Stop); err != nil {
|
||||||
|
return "", fmt.Errorf("unit %s: %w", u.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if spec.Type == service.TypeOneshot {
|
||||||
|
writeOneshot(&w, spec, user, start, stop)
|
||||||
|
} else if err := writeSupervised(&w, u, spec, user); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteString("\ndepend() {\n\tneed net\n")
|
||||||
|
if u.Runtime == "docker" {
|
||||||
|
w.WriteString("\tneed docker\n")
|
||||||
|
}
|
||||||
|
w.WriteString("}\n")
|
||||||
|
|
||||||
|
if spec.Type != service.TypeOneshot {
|
||||||
|
if user != nil {
|
||||||
|
fmt.Fprintf(&w, "\nstart_pre() {\n\tcheckpath -d -m 0700 -o %s %s\n}\n",
|
||||||
|
service.ShellQuote(user.Name), service.ShellQuote(user.RuntimeDir))
|
||||||
|
}
|
||||||
|
if stop != "" {
|
||||||
|
fmt.Fprintf(&w, "\nstop_pre() {\n\t%s || true\n}\n", asUser(user, stop))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSupervised writes the variables for a long-running foreground command.
|
||||||
|
func writeSupervised(w *strings.Builder, u config.Unit, spec service.Spec, user *service.UserEnv) error {
|
||||||
|
argv, err := service.SplitCommand(spec.Start)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unit %s: %w", u.Name, err)
|
||||||
|
}
|
||||||
|
if len(argv) == 0 {
|
||||||
|
return fmt.Errorf("unit %s: empty command", u.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteString("\n")
|
||||||
|
if u.Restart {
|
||||||
|
w.WriteString("supervisor=supervise-daemon\n")
|
||||||
|
fmt.Fprintf(w, "respawn_delay=%d\n", u.RestartSec)
|
||||||
|
if u.RestartRetries > 0 {
|
||||||
|
fmt.Fprintf(w, "respawn_max=%d\n", u.RestartRetries)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
w.WriteString("command_background=yes\n")
|
||||||
|
w.WriteString("pidfile=\"/run/${RC_SVCNAME}.pid\"\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "command=%s\n", dq(argv[0]))
|
||||||
|
if len(argv) > 1 {
|
||||||
|
quoted := make([]string, len(argv)-1)
|
||||||
|
for i, a := range argv[1:] {
|
||||||
|
quoted[i] = service.ShellQuote(a)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "command_args=%s\n", dq(strings.Join(quoted, " ")))
|
||||||
|
}
|
||||||
|
if spec.WorkingDir != "" {
|
||||||
|
fmt.Fprintf(w, "directory=%s\n", dq(spec.WorkingDir))
|
||||||
|
}
|
||||||
|
if user != nil {
|
||||||
|
fmt.Fprintf(w, "command_user=%s\n", dq(user.Name))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeOneshot writes start/stop functions; OpenRC tracks the started state.
|
||||||
|
func writeOneshot(w *strings.Builder, spec service.Spec, user *service.UserEnv, start, stop string) {
|
||||||
|
w.WriteString("\nstart() {\n\tebegin \"Starting $RC_SVCNAME\"\n")
|
||||||
|
if spec.WorkingDir != "" {
|
||||||
|
fmt.Fprintf(w, "\tcd %s || return 1\n", service.ShellQuote(spec.WorkingDir))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "\t%s\n\teend $?\n}\n", asUser(user, start))
|
||||||
|
if stop != "" {
|
||||||
|
w.WriteString("\nstop() {\n\tebegin \"Stopping $RC_SVCNAME\"\n")
|
||||||
|
if spec.WorkingDir != "" {
|
||||||
|
fmt.Fprintf(w, "\tcd %s || return 1\n", service.ShellQuote(spec.WorkingDir))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "\t%s\n\teend $?\n}\n", asUser(user, stop))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// asUser wraps a shell command line so it runs as the unit's user, keeping the
|
||||||
|
// exported HOME / XDG_RUNTIME_DIR.
|
||||||
|
func asUser(user *service.UserEnv, cmd string) string {
|
||||||
|
if user == nil {
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("su -m -s /bin/sh -c %s %s", service.ShellQuote(cmd), service.ShellQuote(user.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// dq double-quotes s for an OpenRC (sh) variable assignment.
|
||||||
|
func dq(s string) string {
|
||||||
|
r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "$", `\$`, "`", "\\`")
|
||||||
|
return `"` + r.Replace(s) + `"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package openrc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/warkanum/unitdore/config"
|
||||||
|
"github.com/warkanum/unitdore/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setup points the backend at a temp init dir with recorded commands.
|
||||||
|
func setup(t *testing.T) *[]string {
|
||||||
|
t.Helper()
|
||||||
|
oldDir, oldRun, oldOut, oldRoot := initDir, runCmd, outCmd, service.IsRoot
|
||||||
|
t.Cleanup(func() { initDir, runCmd, outCmd, service.IsRoot = oldDir, oldRun, oldOut, oldRoot })
|
||||||
|
initDir = t.TempDir()
|
||||||
|
service.IsRoot = func() bool { return true }
|
||||||
|
var calls []string
|
||||||
|
runCmd = func(name string, args ...string) error {
|
||||||
|
calls = append(calls, name+" "+strings.Join(args, " "))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &calls
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustContain(t *testing.T, content string, wants ...string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, w := range wants {
|
||||||
|
if !strings.Contains(content, w) {
|
||||||
|
t.Errorf("missing %q in:\n%s", w, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerate_Container(t *testing.T) {
|
||||||
|
setup(t)
|
||||||
|
u := config.Unit{Name: "nginx", Runtime: "podman", Enabled: true}
|
||||||
|
got, err := Backend{}.Generate(u, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustContain(t, got,
|
||||||
|
"#!/sbin/openrc-run",
|
||||||
|
`description="Unitdore: nginx (podman)"`,
|
||||||
|
"command_background=yes",
|
||||||
|
`pidfile="/run/${RC_SVCNAME}.pid"`,
|
||||||
|
`command="/usr/bin/podman"`,
|
||||||
|
`command_args="start -a nginx"`,
|
||||||
|
"need net",
|
||||||
|
"stop_pre() {\n\t/usr/bin/podman stop nginx || true\n}",
|
||||||
|
)
|
||||||
|
if strings.Contains(got, "supervise-daemon") || strings.Contains(got, "need docker") {
|
||||||
|
t.Errorf("unexpected supervisor or docker dep:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerate_DockerRestart(t *testing.T) {
|
||||||
|
setup(t)
|
||||||
|
u := config.Unit{Name: "redis", Runtime: "docker", Restart: true, RestartSec: 5, RestartRetries: 3}
|
||||||
|
got, err := Backend{}.Generate(u, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustContain(t, got, "supervisor=supervise-daemon", "respawn_delay=5", "respawn_max=3", "need docker")
|
||||||
|
if strings.Contains(got, "command_background") {
|
||||||
|
t.Errorf("supervised unit must not background:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerate_ScriptCustomShellAndDir(t *testing.T) {
|
||||||
|
setup(t)
|
||||||
|
u := config.Unit{
|
||||||
|
Name: "job", Runtime: config.RuntimeScript, Shell: "/bin/sh",
|
||||||
|
Script: "/opt/my jobs/run.sh", WorkingDir: "/opt",
|
||||||
|
}
|
||||||
|
got, err := Backend{}.Generate(u, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustContain(t, got,
|
||||||
|
"(sh script)",
|
||||||
|
`command="/bin/sh"`,
|
||||||
|
`command_args="'/opt/my jobs/run.sh'"`,
|
||||||
|
`directory="/opt"`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerate_OneshotStack(t *testing.T) {
|
||||||
|
setup(t)
|
||||||
|
u := config.Unit{Name: "shop", Runtime: "podman", Kind: config.KindStack, Project: "shop"}
|
||||||
|
got, err := Backend{}.Generate(u, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustContain(t, got,
|
||||||
|
"start() {",
|
||||||
|
"/usr/bin/podman pod start pod_shop",
|
||||||
|
"stop() {",
|
||||||
|
"/usr/bin/podman pod stop pod_shop",
|
||||||
|
)
|
||||||
|
if strings.Contains(got, "command=") {
|
||||||
|
t.Errorf("oneshot must not set command:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerate_RootlessUser(t *testing.T) {
|
||||||
|
setup(t)
|
||||||
|
u := config.Unit{Name: "app", Runtime: "podman", User: "root"}
|
||||||
|
got, err := Backend{}.Generate(u, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustContain(t, got,
|
||||||
|
"export HOME=/root",
|
||||||
|
"export XDG_RUNTIME_DIR=/run/user/0",
|
||||||
|
`command_user="root"`,
|
||||||
|
"checkpath -d -m 0700 -o root /run/user/0",
|
||||||
|
"su -m -s /bin/sh -c '/usr/bin/podman stop app' root",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerate_Invalid(t *testing.T) {
|
||||||
|
setup(t)
|
||||||
|
if _, err := (Backend{}).Generate(config.Unit{Name: "x", Runtime: config.RuntimeExec}, "", ""); err == nil {
|
||||||
|
t.Error("expected validation error")
|
||||||
|
}
|
||||||
|
if _, err := (Backend{}).Generate(config.Unit{Name: "x", Runtime: "podman", Command: `/bin/x "open`}, "", ""); err == nil {
|
||||||
|
t.Error("expected quote error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallUninstall(t *testing.T) {
|
||||||
|
calls := setup(t)
|
||||||
|
b := Backend{}
|
||||||
|
u := config.Unit{Name: "nginx", Runtime: "podman"}
|
||||||
|
|
||||||
|
if b.IsInstalled(u, "", "") {
|
||||||
|
t.Fatal("should not be installed yet")
|
||||||
|
}
|
||||||
|
if err := b.Install(u, "p-", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(initDir, "unitdore-p-nginx")
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fi.Mode().Perm()&0111 == 0 {
|
||||||
|
t.Errorf("init script must be executable, mode %v", fi.Mode())
|
||||||
|
}
|
||||||
|
if !b.IsInstalled(u, "p-", "") {
|
||||||
|
t.Error("should be installed")
|
||||||
|
}
|
||||||
|
if err := b.Uninstall(u, "p-", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if b.IsInstalled(u, "p-", "") {
|
||||||
|
t.Error("should be uninstalled")
|
||||||
|
}
|
||||||
|
if got := strings.Join(*calls, "|"); got != "rc-update del unitdore-p-nginx default" {
|
||||||
|
t.Errorf("calls = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlCommands(t *testing.T) {
|
||||||
|
calls := setup(t)
|
||||||
|
b := Backend{}
|
||||||
|
u := config.Unit{Name: "nginx", Runtime: "podman"}
|
||||||
|
|
||||||
|
_ = b.Start(u, "", "")
|
||||||
|
_ = b.Stop(u, "", "")
|
||||||
|
_ = b.Enable(u, "", "")
|
||||||
|
_ = b.Disable(u, "", "")
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
"rc-service unitdore-nginx start",
|
||||||
|
"rc-service unitdore-nginx stop",
|
||||||
|
"rc-update add unitdore-nginx default",
|
||||||
|
"rc-service unitdore-nginx start",
|
||||||
|
"rc-service unitdore-nginx stop",
|
||||||
|
"rc-update del unitdore-nginx default",
|
||||||
|
}
|
||||||
|
if got := strings.Join(*calls, "|"); got != strings.Join(want, "|") {
|
||||||
|
t.Errorf("calls =\n%q\nwant\n%q", *calls, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequiresRoot(t *testing.T) {
|
||||||
|
setup(t)
|
||||||
|
service.IsRoot = func() bool { return false }
|
||||||
|
u := config.Unit{Name: "nginx", Runtime: "podman"}
|
||||||
|
b := Backend{}
|
||||||
|
for name, err := range map[string]error{
|
||||||
|
"install": b.Install(u, "", ""), "uninstall": b.Uninstall(u, "", ""),
|
||||||
|
"start": b.Start(u, "", ""), "stop": b.Stop(u, "", ""),
|
||||||
|
"enable": b.Enable(u, "", ""), "disable": b.Disable(u, "", ""),
|
||||||
|
} {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("%s should require root", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStatus(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
" * status: started": "active",
|
||||||
|
" * status: stopped": "inactive",
|
||||||
|
" * status: crashed": "failed",
|
||||||
|
" * status: starting": "activating",
|
||||||
|
" * status: stopping": "deactivating",
|
||||||
|
"": "unknown",
|
||||||
|
"rc-service: not found": "unknown",
|
||||||
|
}
|
||||||
|
for in, want := range tests {
|
||||||
|
if got := parseStatus(in); got != want {
|
||||||
|
t.Errorf("parseStatus(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+364
@@ -0,0 +1,364 @@
|
|||||||
|
// Package runit generates and manages runit service directories.
|
||||||
|
//
|
||||||
|
// Each unit is a directory /etc/sv/<name> (run, finish) linked into the
|
||||||
|
// supervised service dir. Services run at system level (root only). Units with
|
||||||
|
// a user run as that user through chpst, with HOME and XDG_RUNTIME_DIR set.
|
||||||
|
//
|
||||||
|
// Install links the service with a `down` file: it is supervised but does not
|
||||||
|
// start until started or enabled. Enable removes `down`, Disable restores it.
|
||||||
|
package runit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/warkanum/unitdore/config"
|
||||||
|
"github.com/warkanum/unitdore/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Overridable for tests.
|
||||||
|
var (
|
||||||
|
svDir = "/etc/sv"
|
||||||
|
|
||||||
|
// serviceDirCandidates are probed in order when no service_dir is set.
|
||||||
|
serviceDirCandidates = []string{"/var/service", "/etc/service"}
|
||||||
|
|
||||||
|
// superviseWait is how long Start waits for runsvdir to pick up a new link.
|
||||||
|
superviseWait = 10 * time.Second
|
||||||
|
|
||||||
|
runCmd = func(name string, args ...string) error {
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
outCmd = func(name string, args ...string) ([]byte, error) {
|
||||||
|
return exec.Command(name, args...).CombinedOutput()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Backend is the runit implementation of the init interface.
|
||||||
|
type Backend struct {
|
||||||
|
ServiceDir string // supervised dir; empty = autodetect
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Backend) Name() string { return "runit" }
|
||||||
|
|
||||||
|
// SupportsLinger is false: runit services need no linger.
|
||||||
|
func (Backend) SupportsLinger() bool { return false }
|
||||||
|
|
||||||
|
func (Backend) ServiceName(u config.Unit, prefix, suffix string) string {
|
||||||
|
return service.BaseName(u, prefix, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnitPath is the service definition directory under /etc/sv.
|
||||||
|
func (b Backend) UnitPath(u config.Unit, prefix, suffix string) (string, error) {
|
||||||
|
return filepath.Join(svDir, b.ServiceName(u, prefix, suffix)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Backend) IsInstalled(u config.Unit, prefix, suffix string) bool {
|
||||||
|
dir, _ := b.UnitPath(u, prefix, suffix)
|
||||||
|
_, err := os.Stat(filepath.Join(dir, "run"))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// serviceDir returns the supervised dir that gets the symlinks.
|
||||||
|
func (b Backend) serviceDir() string {
|
||||||
|
if b.ServiceDir != "" {
|
||||||
|
return b.ServiceDir
|
||||||
|
}
|
||||||
|
for _, d := range serviceDirCandidates {
|
||||||
|
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return serviceDirCandidates[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Backend) link(u config.Unit, prefix, suffix string) string {
|
||||||
|
return filepath.Join(b.serviceDir(), b.ServiceName(u, prefix, suffix))
|
||||||
|
}
|
||||||
|
|
||||||
|
type file struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate produces the run and finish scripts, separated by marker lines.
|
||||||
|
func (b Backend) Generate(u config.Unit, prefix, suffix string) (string, error) {
|
||||||
|
files, err := b.files(u, prefix, suffix)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dir, _ := b.UnitPath(u, prefix, suffix)
|
||||||
|
var w strings.Builder
|
||||||
|
for _, f := range files {
|
||||||
|
fmt.Fprintf(&w, "# ===== %s =====\n%s\n", filepath.Join(dir, f.name), f.content)
|
||||||
|
}
|
||||||
|
return w.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Backend) files(u config.Unit, prefix, suffix string) ([]file, error) {
|
||||||
|
if err := service.Validate(u); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
user, err := service.LookupUser(u.User)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
spec := service.Resolve(u)
|
||||||
|
|
||||||
|
start, err := service.ShellCommand(spec.Start)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unit %s: %w", u.Name, err)
|
||||||
|
}
|
||||||
|
stop := ""
|
||||||
|
if spec.Stop != "" {
|
||||||
|
if stop, err = service.ShellCommand(spec.Stop); err != nil {
|
||||||
|
return nil, fmt.Errorf("unit %s: %w", u.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header := "#!/bin/sh\n# " + b.ServiceName(u, prefix, suffix) + " — generated by unitdore, do not edit manually\n"
|
||||||
|
|
||||||
|
run := header + "exec 2>&1\n" + preamble(user, spec.WorkingDir)
|
||||||
|
if spec.Type == service.TypeOneshot {
|
||||||
|
// Run once, then stay up (like RemainAfterExit) until stopped
|
||||||
|
run += asUser(user, start) + " || exit 1\nexec tail -f /dev/null\n"
|
||||||
|
} else {
|
||||||
|
run += "exec " + asUser(user, start) + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
files := []file{{"run", run}}
|
||||||
|
if fin := finishScript(header, u, user, spec.WorkingDir, stop); fin != "" {
|
||||||
|
files = append(files, file{"finish", fin})
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// preamble prepares the environment: working dir and rootless user runtime.
|
||||||
|
func preamble(user *service.UserEnv, workDir string) string {
|
||||||
|
var s string
|
||||||
|
if workDir != "" {
|
||||||
|
s += "cd " + service.ShellQuote(workDir) + " || exit 1\n"
|
||||||
|
}
|
||||||
|
if user != nil {
|
||||||
|
s += fmt.Sprintf("[ -d %[1]s ] || install -d -m 0700 -o %[2]s %[1]s\n",
|
||||||
|
service.ShellQuote(user.RuntimeDir), service.ShellQuote(user.Name))
|
||||||
|
s += "export HOME=" + service.ShellQuote(user.Home) + "\n"
|
||||||
|
s += "export XDG_RUNTIME_DIR=" + service.ShellQuote(user.RuntimeDir) + "\n"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func asUser(user *service.UserEnv, cmd string) string {
|
||||||
|
if user == nil {
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
return "chpst -u " + service.ShellQuote(user.Name) + " " + cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishScript runs after the service exits: best-effort stop command, then
|
||||||
|
// the restart policy. runit restarts by default, so no-restart stops it.
|
||||||
|
func finishScript(header string, u config.Unit, user *service.UserEnv, workDir, stop string) string {
|
||||||
|
var body string
|
||||||
|
if workDir != "" {
|
||||||
|
body += "cd " + service.ShellQuote(workDir) + " 2>/dev/null\n"
|
||||||
|
}
|
||||||
|
if stop != "" {
|
||||||
|
body += asUser(user, stop) + " || true\n"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !u.Restart:
|
||||||
|
body += "exec sv down .\n"
|
||||||
|
default:
|
||||||
|
// Restart=on-failure: a clean exit stays down
|
||||||
|
body += "[ \"$1\" = 0 ] && exec sv down .\n"
|
||||||
|
if u.RestartSec > 0 {
|
||||||
|
body += fmt.Sprintf("sleep %d\n", u.RestartSec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return header + body
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Backend) Install(u config.Unit, prefix, suffix string) error {
|
||||||
|
if err := service.RequireRoot("runit"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
files, err := b.files(u, prefix, suffix)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dir, _ := b.UnitPath(u, prefix, suffix)
|
||||||
|
link := b.link(u, prefix, suffix)
|
||||||
|
|
||||||
|
// A re-install keeps an already enabled service enabled
|
||||||
|
wasEnabled := isLinked(link, dir) && !exists(filepath.Join(dir, "down"))
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating service dir: %w", err)
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, f.name), []byte(f.content), 0755); err != nil {
|
||||||
|
return fmt.Errorf("writing %s: %w", f.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasFile(files, "finish") {
|
||||||
|
_ = os.Remove(filepath.Join(dir, "finish"))
|
||||||
|
}
|
||||||
|
if !wasEnabled {
|
||||||
|
if err := setDown(dir, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ensureLink(dir, link)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Backend) Uninstall(u config.Unit, prefix, suffix string) error {
|
||||||
|
if err := service.RequireRoot("runit"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dir, _ := b.UnitPath(u, prefix, suffix)
|
||||||
|
link := b.link(u, prefix, suffix)
|
||||||
|
|
||||||
|
_ = runCmd("sv", "stop", link) // not running is fine
|
||||||
|
if err := os.Remove(link); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("removing service link: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
return fmt.Errorf("removing service dir: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable makes the service start with runsvdir and starts it now.
|
||||||
|
func (b Backend) Enable(u config.Unit, prefix, suffix string) error {
|
||||||
|
if err := service.RequireRoot("runit"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dir, _ := b.UnitPath(u, prefix, suffix)
|
||||||
|
link := b.link(u, prefix, suffix)
|
||||||
|
if err := setDown(dir, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureLink(dir, link); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return startLink(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable stops the service and keeps it from starting with runsvdir.
|
||||||
|
func (b Backend) Disable(u config.Unit, prefix, suffix string) error {
|
||||||
|
if err := service.RequireRoot("runit"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dir, _ := b.UnitPath(u, prefix, suffix)
|
||||||
|
if err := setDown(dir, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = runCmd("sv", "stop", b.link(u, prefix, suffix)) // not running is fine
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Backend) Start(u config.Unit, prefix, suffix string) error {
|
||||||
|
if err := service.RequireRoot("runit"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dir, _ := b.UnitPath(u, prefix, suffix)
|
||||||
|
link := b.link(u, prefix, suffix)
|
||||||
|
if err := ensureLink(dir, link); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return startLink(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Backend) Stop(u config.Unit, prefix, suffix string) error {
|
||||||
|
if err := service.RequireRoot("runit"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return runCmd("sv", "stop", b.link(u, prefix, suffix))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Backend) Status(u config.Unit, prefix, suffix string) string {
|
||||||
|
out, _ := outCmd("sv", "status", b.link(u, prefix, suffix))
|
||||||
|
return parseStatus(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStatus maps `sv status` output to a systemd-like state.
|
||||||
|
func parseStatus(out string) string {
|
||||||
|
out = strings.TrimSpace(out)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(out, "run:"):
|
||||||
|
return "active"
|
||||||
|
case strings.HasPrefix(out, "down:"):
|
||||||
|
return "inactive"
|
||||||
|
case strings.HasPrefix(out, "finish:"):
|
||||||
|
return "deactivating"
|
||||||
|
case strings.Contains(out, "runsv not running"):
|
||||||
|
return "inactive"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startLink waits until runsvdir supervises the link, then starts it.
|
||||||
|
func startLink(link string) error {
|
||||||
|
deadline := time.Now().Add(superviseWait)
|
||||||
|
for !exists(filepath.Join(link, "supervise", "ok")) {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return fmt.Errorf("%s is not supervised yet (is runsvdir running?)", link)
|
||||||
|
}
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return runCmd("sv", "start", link)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureLink(dir, link string) error {
|
||||||
|
if isLinked(link, dir) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(link), 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating service dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(dir, link); err != nil {
|
||||||
|
return fmt.Errorf("linking service: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLinked(link, dir string) bool {
|
||||||
|
target, err := os.Readlink(link)
|
||||||
|
return err == nil && target == dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func setDown(dir string, down bool) error {
|
||||||
|
path := filepath.Join(dir, "down")
|
||||||
|
if !down {
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("removing down file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, nil, 0644); err != nil {
|
||||||
|
return fmt.Errorf("writing down file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func exists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasFile(files []file, name string) bool {
|
||||||
|
for _, f := range files {
|
||||||
|
if f.name == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
package runit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/warkanum/unitdore/config"
|
||||||
|
"github.com/warkanum/unitdore/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setup points the backend at temp dirs with recorded commands.
|
||||||
|
func setup(t *testing.T) (b Backend, calls *[]string) {
|
||||||
|
t.Helper()
|
||||||
|
oldSv, oldRun, oldOut, oldRoot, oldWait := svDir, runCmd, outCmd, service.IsRoot, superviseWait
|
||||||
|
t.Cleanup(func() { svDir, runCmd, outCmd, service.IsRoot, superviseWait = oldSv, oldRun, oldOut, oldRoot, oldWait })
|
||||||
|
svDir = t.TempDir()
|
||||||
|
superviseWait = 300 * time.Millisecond
|
||||||
|
service.IsRoot = func() bool { return true }
|
||||||
|
var recorded []string
|
||||||
|
runCmd = func(name string, args ...string) error {
|
||||||
|
recorded = append(recorded, name+" "+strings.Join(args, " "))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Backend{ServiceDir: t.TempDir()}, &recorded
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustContain(t *testing.T, content string, wants ...string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, w := range wants {
|
||||||
|
if !strings.Contains(content, w) {
|
||||||
|
t.Errorf("missing %q in:\n%s", w, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func read(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerate_Container(t *testing.T) {
|
||||||
|
b, _ := setup(t)
|
||||||
|
u := config.Unit{Name: "nginx", Runtime: "podman"}
|
||||||
|
got, err := b.Generate(u, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustContain(t, got,
|
||||||
|
"===== "+filepath.Join(svDir, "unitdore-nginx", "run")+" =====",
|
||||||
|
"exec 2>&1",
|
||||||
|
"exec /usr/bin/podman start -a nginx",
|
||||||
|
"===== "+filepath.Join(svDir, "unitdore-nginx", "finish")+" =====",
|
||||||
|
"/usr/bin/podman stop nginx || true",
|
||||||
|
"exec sv down .",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerate_RestartPolicy(t *testing.T) {
|
||||||
|
b, _ := setup(t)
|
||||||
|
u := config.Unit{Name: "app", Runtime: "podman", Restart: true, RestartSec: 7}
|
||||||
|
got, err := b.Generate(u, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustContain(t, got, `[ "$1" = 0 ] && exec sv down .`, "sleep 7")
|
||||||
|
if strings.Contains(got, "\nexec sv down .\n") {
|
||||||
|
t.Errorf("restarting unit must not always go down:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerate_ScriptExecAndWorkingDir(t *testing.T) {
|
||||||
|
b, _ := setup(t)
|
||||||
|
u := config.Unit{
|
||||||
|
Name: "agent", Runtime: config.RuntimeExec, Executable: "/opt/agent",
|
||||||
|
Args: []string{"--config", "/etc/my agent.toml"}, WorkingDir: "/opt",
|
||||||
|
}
|
||||||
|
got, err := b.Generate(u, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustContain(t, got, "cd /opt || exit 1", `exec /opt/agent --config '/etc/my agent.toml'`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerate_Oneshot(t *testing.T) {
|
||||||
|
b, _ := setup(t)
|
||||||
|
u := config.Unit{Name: "shop", Runtime: "podman", Kind: config.KindStack, Project: "shop"}
|
||||||
|
got, err := b.Generate(u, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustContain(t, got,
|
||||||
|
"/usr/bin/podman pod start pod_shop || exit 1\nexec tail -f /dev/null",
|
||||||
|
"/usr/bin/podman pod stop pod_shop || true",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerate_RootlessUser(t *testing.T) {
|
||||||
|
b, _ := setup(t)
|
||||||
|
u := config.Unit{Name: "app", Runtime: "podman", User: "root"}
|
||||||
|
got, err := b.Generate(u, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustContain(t, got,
|
||||||
|
"[ -d /run/user/0 ] || install -d -m 0700 -o root /run/user/0",
|
||||||
|
"export HOME=/root",
|
||||||
|
"export XDG_RUNTIME_DIR=/run/user/0",
|
||||||
|
"exec chpst -u root /usr/bin/podman start -a app",
|
||||||
|
"chpst -u root /usr/bin/podman stop app || true",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerate_Invalid(t *testing.T) {
|
||||||
|
b, _ := setup(t)
|
||||||
|
if _, err := b.Generate(config.Unit{Name: "x", Runtime: config.RuntimeScript}, "", ""); err == nil {
|
||||||
|
t.Error("expected validation error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstall_WritesDirLinkAndDown(t *testing.T) {
|
||||||
|
b, _ := setup(t)
|
||||||
|
u := config.Unit{Name: "nginx", Runtime: "podman"}
|
||||||
|
dir := filepath.Join(svDir, "unitdore-nginx")
|
||||||
|
link := filepath.Join(b.ServiceDir, "unitdore-nginx")
|
||||||
|
|
||||||
|
if b.IsInstalled(u, "", "") {
|
||||||
|
t.Fatal("should not be installed yet")
|
||||||
|
}
|
||||||
|
if err := b.Install(u, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"run", "finish"} {
|
||||||
|
fi, err := os.Stat(filepath.Join(dir, name))
|
||||||
|
if err != nil || fi.Mode().Perm()&0111 == 0 {
|
||||||
|
t.Errorf("%s missing or not executable: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "down")); err != nil {
|
||||||
|
t.Error("fresh install must write a down file")
|
||||||
|
}
|
||||||
|
if target, err := os.Readlink(link); err != nil || target != dir {
|
||||||
|
t.Errorf("link = %q, %v", target, err)
|
||||||
|
}
|
||||||
|
if !b.IsInstalled(u, "", "") {
|
||||||
|
t.Error("should be installed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstall_ReinstallKeepsEnabled(t *testing.T) {
|
||||||
|
b, _ := setup(t)
|
||||||
|
u := config.Unit{Name: "nginx", Runtime: "podman"}
|
||||||
|
dir := filepath.Join(svDir, "unitdore-nginx")
|
||||||
|
|
||||||
|
if err := b.Install(u, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := b.Enable(u, "", ""); err == nil {
|
||||||
|
t.Log("enable ok") // may time out waiting for supervise; down file still removed
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "down")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("enable must remove the down file")
|
||||||
|
}
|
||||||
|
|
||||||
|
u.Restart = true
|
||||||
|
if err := b.Install(u, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "down")); !os.IsNotExist(err) {
|
||||||
|
t.Error("re-install must keep an enabled service enabled")
|
||||||
|
}
|
||||||
|
if !strings.Contains(read(t, filepath.Join(dir, "finish")), `"$1" = 0`) {
|
||||||
|
t.Error("re-install must rewrite scripts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDisable_WritesDownAndStops(t *testing.T) {
|
||||||
|
b, calls := setup(t)
|
||||||
|
u := config.Unit{Name: "nginx", Runtime: "podman"}
|
||||||
|
dir := filepath.Join(svDir, "unitdore-nginx")
|
||||||
|
if err := b.Install(u, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
*calls = nil
|
||||||
|
if err := b.Disable(u, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "down")); err != nil {
|
||||||
|
t.Error("disable must write a down file")
|
||||||
|
}
|
||||||
|
if got := strings.Join(*calls, "|"); got != "sv stop "+filepath.Join(b.ServiceDir, "unitdore-nginx") {
|
||||||
|
t.Errorf("calls = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartStop_UseSv(t *testing.T) {
|
||||||
|
b, calls := setup(t)
|
||||||
|
u := config.Unit{Name: "nginx", Runtime: "podman"}
|
||||||
|
link := filepath.Join(b.ServiceDir, "unitdore-nginx")
|
||||||
|
if err := b.Install(u, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not supervised yet: Start must give up with a clear error
|
||||||
|
if err := b.Start(u, "", ""); err == nil || !strings.Contains(err.Error(), "not supervised") {
|
||||||
|
t.Errorf("Start() err = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Join(link, "supervise"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(link, "supervise", "ok"), nil, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
*calls = nil
|
||||||
|
if err := b.Start(u, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := b.Stop(u, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := strings.Join(*calls, "|"); got != "sv start "+link+"|sv stop "+link {
|
||||||
|
t.Errorf("calls = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUninstall_RemovesDirAndLink(t *testing.T) {
|
||||||
|
b, calls := setup(t)
|
||||||
|
u := config.Unit{Name: "nginx", Runtime: "podman"}
|
||||||
|
if err := b.Install(u, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
*calls = nil
|
||||||
|
if err := b.Uninstall(u, "", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if b.IsInstalled(u, "", "") {
|
||||||
|
t.Error("should be uninstalled")
|
||||||
|
}
|
||||||
|
if _, err := os.Lstat(filepath.Join(b.ServiceDir, "unitdore-nginx")); !os.IsNotExist(err) {
|
||||||
|
t.Error("link should be gone")
|
||||||
|
}
|
||||||
|
if len(*calls) != 1 || !strings.HasPrefix((*calls)[0], "sv stop ") {
|
||||||
|
t.Errorf("calls = %q", *calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceDirAutodetect(t *testing.T) {
|
||||||
|
old := serviceDirCandidates
|
||||||
|
t.Cleanup(func() { serviceDirCandidates = old })
|
||||||
|
existing := t.TempDir()
|
||||||
|
serviceDirCandidates = []string{filepath.Join(existing, "nope"), existing}
|
||||||
|
if got := (Backend{}).serviceDir(); got != existing {
|
||||||
|
t.Errorf("serviceDir() = %q, want %q", got, existing)
|
||||||
|
}
|
||||||
|
if got := (Backend{ServiceDir: "/custom"}).serviceDir(); got != "/custom" {
|
||||||
|
t.Errorf("explicit serviceDir() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequiresRoot(t *testing.T) {
|
||||||
|
b, _ := setup(t)
|
||||||
|
service.IsRoot = func() bool { return false }
|
||||||
|
u := config.Unit{Name: "nginx", Runtime: "podman"}
|
||||||
|
for name, err := range map[string]error{
|
||||||
|
"install": b.Install(u, "", ""), "uninstall": b.Uninstall(u, "", ""),
|
||||||
|
"start": b.Start(u, "", ""), "stop": b.Stop(u, "", ""),
|
||||||
|
"enable": b.Enable(u, "", ""), "disable": b.Disable(u, "", ""),
|
||||||
|
} {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("%s should require root", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseStatus(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
"run: /var/service/x: (pid 123) 5s": "active",
|
||||||
|
"down: /var/service/x: 3s, normally up": "inactive",
|
||||||
|
"finish: /var/service/x: (pid 9) 1s": "deactivating",
|
||||||
|
"fail: /var/service/x: runsv not running": "inactive",
|
||||||
|
"warning: weird": "unknown",
|
||||||
|
}
|
||||||
|
for in, want := range tests {
|
||||||
|
if got := parseStatus(in); got != want {
|
||||||
|
t.Errorf("parseStatus(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+294
@@ -0,0 +1,294 @@
|
|||||||
|
// Package service resolves a config.Unit into init-system-neutral run
|
||||||
|
// instructions (commands, working dir, type) shared by every init backend.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/warkanum/unitdore/config"
|
||||||
|
"github.com/warkanum/unitdore/sysuser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service types.
|
||||||
|
const (
|
||||||
|
TypeSimple = "simple"
|
||||||
|
TypeOneshot = "oneshot"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stack modes: compose (podman-compose up/stop) with pod start/stop as fallback.
|
||||||
|
const (
|
||||||
|
StackModeCompose = "compose"
|
||||||
|
StackModePod = "pod"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Spec is what an init backend needs to run a unit. Start and Stop are
|
||||||
|
// command lines in systemd quoting; use SplitCommand to get argv.
|
||||||
|
type Spec struct {
|
||||||
|
Start string
|
||||||
|
Stop string // empty = none
|
||||||
|
Label string
|
||||||
|
WorkingDir string
|
||||||
|
Type string // TypeSimple | TypeOneshot
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseName returns the init-neutral service name: unitdore-<prefix><name><suffix>.
|
||||||
|
func BaseName(u config.Unit, prefix, suffix string) string {
|
||||||
|
return fmt.Sprintf("unitdore-%s%s%s", prefix, u.Name, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reports unit configs that cannot be turned into a service.
|
||||||
|
func Validate(u config.Unit) error {
|
||||||
|
if u.Command != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if u.IsScript() && u.Script == "" {
|
||||||
|
return fmt.Errorf("unit %s: runtime script requires 'script' (or 'command')", u.Name)
|
||||||
|
}
|
||||||
|
if u.IsExec() && u.Executable == "" {
|
||||||
|
return fmt.Errorf("unit %s: runtime exec requires 'executable' (or 'command')", u.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve builds the run instructions for a unit. Call Validate first.
|
||||||
|
func Resolve(u config.Unit) Spec {
|
||||||
|
spec := Spec{Label: u.Runtime, Type: TypeSimple}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case u.IsScript() && u.Command == "":
|
||||||
|
spec.Start, spec.Stop = buildScriptCommands(u)
|
||||||
|
spec.Label = filepath.Base(u.ShellPath()) + " script"
|
||||||
|
spec.WorkingDir = u.WorkingDir
|
||||||
|
spec.Type = serviceType(u)
|
||||||
|
case u.IsExec() && u.Command == "":
|
||||||
|
spec.Start, spec.Stop = buildExecutableCommands(u)
|
||||||
|
spec.Label = "executable"
|
||||||
|
spec.WorkingDir = u.WorkingDir
|
||||||
|
spec.Type = serviceType(u)
|
||||||
|
case u.IsStack() && u.Command == "" && StackMode(u) == StackModeCompose:
|
||||||
|
spec.Start, spec.Stop = buildComposeCommands(u)
|
||||||
|
spec.Label = "podman-compose stack"
|
||||||
|
spec.WorkingDir = u.WorkingDir
|
||||||
|
case u.IsStack() && u.Command == "":
|
||||||
|
spec.Start, spec.Stop = buildPodCommands(u)
|
||||||
|
spec.Label = "podman pod"
|
||||||
|
spec.Type = TypeOneshot
|
||||||
|
default:
|
||||||
|
spec.Start, spec.Stop = buildExecCommands(u)
|
||||||
|
}
|
||||||
|
return spec
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceType(u config.Unit) string {
|
||||||
|
if u.ServiceType == TypeOneshot {
|
||||||
|
return TypeOneshot
|
||||||
|
}
|
||||||
|
return TypeSimple
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildScriptCommands(u config.Unit) (start, stop string) {
|
||||||
|
shell := QuoteArg(u.ShellPath())
|
||||||
|
start = shell + " " + QuoteArg(u.Script)
|
||||||
|
if u.StopScript != "" {
|
||||||
|
stop = shell + " " + QuoteArg(u.StopScript)
|
||||||
|
}
|
||||||
|
return start, stop
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExecutableCommands(u config.Unit) (start, stop string) {
|
||||||
|
parts := []string{QuoteArg(u.Executable)}
|
||||||
|
for _, a := range u.Args {
|
||||||
|
parts = append(parts, QuoteArg(a))
|
||||||
|
}
|
||||||
|
if u.StopExecutable != "" {
|
||||||
|
stop = QuoteArg(u.StopExecutable)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " "), stop
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildExecCommands(u config.Unit) (start, stop string) {
|
||||||
|
// If a custom command is provided, use it directly
|
||||||
|
if u.Command != "" {
|
||||||
|
return u.Command, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch u.Runtime {
|
||||||
|
case "podman":
|
||||||
|
start = fmt.Sprintf("/usr/bin/podman start -a %s", u.Name)
|
||||||
|
stop = fmt.Sprintf("/usr/bin/podman stop %s", u.Name)
|
||||||
|
case "docker":
|
||||||
|
start = fmt.Sprintf("/usr/bin/docker start -a %s", u.Name)
|
||||||
|
stop = fmt.Sprintf("/usr/bin/docker stop %s", u.Name)
|
||||||
|
default:
|
||||||
|
start = fmt.Sprintf("/usr/bin/%s start %s", u.Runtime, u.Name)
|
||||||
|
stop = fmt.Sprintf("/usr/bin/%s stop %s", u.Runtime, u.Name)
|
||||||
|
}
|
||||||
|
return start, stop
|
||||||
|
}
|
||||||
|
|
||||||
|
// StackMode picks how a stack unit is started: compose when the compose binary,
|
||||||
|
// working dir and compose files all exist; otherwise pod start/stop.
|
||||||
|
func StackMode(u config.Unit) string {
|
||||||
|
if ComposeAvailable(u) {
|
||||||
|
return StackModeCompose
|
||||||
|
}
|
||||||
|
return StackModePod
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComposeAvailable reports whether the stack can be driven by podman-compose.
|
||||||
|
func ComposeAvailable(u config.Unit) bool {
|
||||||
|
if u.ComposeBin == "" || u.WorkingDir == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !exists(u.ComposeBin) || !exists(u.WorkingDir) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, f := range u.ComposeFiles {
|
||||||
|
if !filepath.IsAbs(f) {
|
||||||
|
f = filepath.Join(u.WorkingDir, f)
|
||||||
|
}
|
||||||
|
if !exists(f) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func exists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildComposeCommands(u config.Unit) (start, stop string) {
|
||||||
|
args := []string{QuoteArg(u.ComposeBin), "-p", QuoteArg(u.Project)}
|
||||||
|
for _, f := range u.ComposeFiles {
|
||||||
|
args = append(args, "-f", QuoteArg(f))
|
||||||
|
}
|
||||||
|
base := strings.Join(args, " ")
|
||||||
|
return base + " up", base + " stop"
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPodCommands(u config.Unit) (start, stop string) {
|
||||||
|
pod := QuoteArg("pod_" + u.Project)
|
||||||
|
return "/usr/bin/podman pod start " + pod, "/usr/bin/podman pod stop " + pod
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuoteArg double-quotes an argument for systemd command lines if it
|
||||||
|
// contains whitespace or quotes.
|
||||||
|
func QuoteArg(s string) string {
|
||||||
|
if !strings.ContainsAny(s, " \t\"'\\") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
r := strings.NewReplacer(`\`, `\\`, `"`, `\"`)
|
||||||
|
return `"` + r.Replace(s) + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitCommand splits a systemd-style command line into argv. It understands
|
||||||
|
// double quotes, single quotes and backslash escapes.
|
||||||
|
func SplitCommand(s string) ([]string, error) {
|
||||||
|
var (
|
||||||
|
args []string
|
||||||
|
cur strings.Builder
|
||||||
|
inArg bool
|
||||||
|
quote rune
|
||||||
|
escape bool
|
||||||
|
)
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case escape:
|
||||||
|
cur.WriteRune(r)
|
||||||
|
escape = false
|
||||||
|
case r == '\\' && quote != '\'':
|
||||||
|
escape = true
|
||||||
|
inArg = true
|
||||||
|
case quote != 0:
|
||||||
|
if r == quote {
|
||||||
|
quote = 0
|
||||||
|
} else {
|
||||||
|
cur.WriteRune(r)
|
||||||
|
}
|
||||||
|
case r == '"' || r == '\'':
|
||||||
|
quote = r
|
||||||
|
inArg = true
|
||||||
|
case r == ' ' || r == '\t' || r == '\n':
|
||||||
|
if inArg {
|
||||||
|
args = append(args, cur.String())
|
||||||
|
cur.Reset()
|
||||||
|
inArg = false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
cur.WriteRune(r)
|
||||||
|
inArg = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if quote != 0 {
|
||||||
|
return nil, fmt.Errorf("unterminated quote in command: %s", s)
|
||||||
|
}
|
||||||
|
if escape {
|
||||||
|
return nil, fmt.Errorf("trailing backslash in command: %s", s)
|
||||||
|
}
|
||||||
|
if inArg {
|
||||||
|
args = append(args, cur.String())
|
||||||
|
}
|
||||||
|
return args, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShellQuote quotes s for a POSIX shell, only when needed.
|
||||||
|
func ShellQuote(s string) string {
|
||||||
|
if s != "" && strings.Trim(s, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-") == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShellCommand converts a systemd-style command line into a POSIX shell line.
|
||||||
|
func ShellCommand(s string) (string, error) {
|
||||||
|
argv, err := SplitCommand(s)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for i, a := range argv {
|
||||||
|
argv[i] = ShellQuote(a)
|
||||||
|
}
|
||||||
|
return strings.Join(argv, " "), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserEnv holds what a system-level service needs to run a command as a
|
||||||
|
// rootless user (podman needs HOME and XDG_RUNTIME_DIR).
|
||||||
|
type UserEnv struct {
|
||||||
|
Name string
|
||||||
|
UID string
|
||||||
|
Home string
|
||||||
|
RuntimeDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupUser resolves the account behind a unit's user. Empty user returns nil.
|
||||||
|
func LookupUser(user string) (*UserEnv, error) {
|
||||||
|
if user == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
uid, err := sysuser.UID(user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
home, err := sysuser.HomeDir(user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &UserEnv{Name: user, UID: uid, Home: home, RuntimeDir: "/run/user/" + uid}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRoot is sysuser.IsRoot; a variable so tests can override it.
|
||||||
|
var IsRoot = sysuser.IsRoot
|
||||||
|
|
||||||
|
// RequireRoot errors unless running as root. Non-systemd backends write to
|
||||||
|
// system directories and manage system-level services only.
|
||||||
|
func RequireRoot(backend string) error {
|
||||||
|
if !IsRoot() {
|
||||||
|
return fmt.Errorf("%s backend requires root", backend)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/warkanum/unitdore/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildExecCommands(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
unit config.Unit
|
||||||
|
wantStart string
|
||||||
|
wantStop string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "podman",
|
||||||
|
unit: config.Unit{Name: "app", Runtime: "podman"},
|
||||||
|
wantStart: "/usr/bin/podman start -a app",
|
||||||
|
wantStop: "/usr/bin/podman stop app",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "docker",
|
||||||
|
unit: config.Unit{Name: "app", Runtime: "docker"},
|
||||||
|
wantStart: "/usr/bin/docker start -a app",
|
||||||
|
wantStop: "/usr/bin/docker stop app",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom command",
|
||||||
|
unit: config.Unit{Name: "app", Runtime: "podman", Command: "/bin/custom"},
|
||||||
|
wantStart: "/bin/custom",
|
||||||
|
wantStop: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
start, stop := buildExecCommands(tt.unit)
|
||||||
|
if start != tt.wantStart {
|
||||||
|
t.Errorf("start = %q, want %q", start, tt.wantStart)
|
||||||
|
}
|
||||||
|
if stop != tt.wantStop {
|
||||||
|
t.Errorf("stop = %q, want %q", stop, tt.wantStop)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStackMode(t *testing.T) {
|
||||||
|
u := newComposeStack(t)
|
||||||
|
if StackMode(u) != StackModeCompose {
|
||||||
|
t.Errorf("StackMode() = %q, want compose", StackMode(u))
|
||||||
|
}
|
||||||
|
|
||||||
|
missingFile := u
|
||||||
|
missingFile.ComposeFiles = []string{"gone.yaml"}
|
||||||
|
if StackMode(missingFile) != StackModePod {
|
||||||
|
t.Error("missing compose file should fall back to pod mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
missingBin := u
|
||||||
|
missingBin.ComposeBin = ""
|
||||||
|
if StackMode(missingBin) != StackModePod {
|
||||||
|
t.Error("missing compose binary should fall back to pod mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
missingDir := u
|
||||||
|
missingDir.WorkingDir = "/nonexistent/unitdore-test"
|
||||||
|
if StackMode(missingDir) != StackModePod {
|
||||||
|
t.Error("missing working dir should fall back to pod mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildComposeCommands_Quoting(t *testing.T) {
|
||||||
|
u := config.Unit{
|
||||||
|
Project: "shop",
|
||||||
|
ComposeBin: "/usr/bin/podman-compose",
|
||||||
|
ComposeFiles: []string{"compose.yaml", "my override.yaml"},
|
||||||
|
}
|
||||||
|
start, stop := buildComposeCommands(u)
|
||||||
|
wantBase := `/usr/bin/podman-compose -p shop -f compose.yaml -f "my override.yaml"`
|
||||||
|
if start != wantBase+" up" {
|
||||||
|
t.Errorf("start = %q", start)
|
||||||
|
}
|
||||||
|
if stop != wantBase+" stop" {
|
||||||
|
t.Errorf("stop = %q", stop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newComposeStack creates a stack unit whose compose binary, working dir and
|
||||||
|
// compose file exist on disk (compose mode).
|
||||||
|
func newComposeStack(t *testing.T) config.Unit {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
bin := filepath.Join(dir, "podman-compose")
|
||||||
|
if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "compose.yaml"), []byte("services: {}\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return config.Unit{
|
||||||
|
Name: "shop",
|
||||||
|
Runtime: "podman",
|
||||||
|
Kind: config.KindStack,
|
||||||
|
Project: "shop",
|
||||||
|
WorkingDir: dir,
|
||||||
|
ComposeFiles: []string{"compose.yaml"},
|
||||||
|
ComposeBin: bin,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitCommand(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
in string
|
||||||
|
want []string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{in: "/usr/bin/podman start -a app", want: []string{"/usr/bin/podman", "start", "-a", "app"}},
|
||||||
|
{in: `/bin/x "my file.txt" 'a b'`, want: []string{"/bin/x", "my file.txt", "a b"}},
|
||||||
|
{in: `/bin/x "say \"hi\""`, want: []string{"/bin/x", `say "hi"`}},
|
||||||
|
{in: `/bin/x ""`, want: []string{"/bin/x", ""}},
|
||||||
|
{in: " ", want: nil},
|
||||||
|
{in: `/bin/x "open`, wantErr: true},
|
||||||
|
{in: `/bin/x \`, wantErr: true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got, err := SplitCommand(tt.in)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("SplitCommand(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Errorf("SplitCommand(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tt.want[i] {
|
||||||
|
t.Errorf("SplitCommand(%q)[%d] = %q, want %q", tt.in, i, got[i], tt.want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuoteArg_SplitCommand_RoundTrip(t *testing.T) {
|
||||||
|
for _, arg := range []string{"plain", "with space", `a"b`, `back\slash`, "it's"} {
|
||||||
|
got, err := SplitCommand("x " + QuoteArg(arg))
|
||||||
|
if err != nil || len(got) != 2 || got[1] != arg {
|
||||||
|
t.Errorf("round trip %q = %q, %v", arg, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellQuote(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
"plain-1.0/x": "plain-1.0/x",
|
||||||
|
"": "''",
|
||||||
|
"a b": "'a b'",
|
||||||
|
"it's": `'it'\''s'`,
|
||||||
|
"$HOME": "'$HOME'",
|
||||||
|
}
|
||||||
|
for in, want := range tests {
|
||||||
|
if got := ShellQuote(in); got != want {
|
||||||
|
t.Errorf("ShellQuote(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShellCommand(t *testing.T) {
|
||||||
|
got, err := ShellCommand(`/opt/agent --config "/etc/my agent.toml"`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if want := `/opt/agent --config '/etc/my agent.toml'`; got != want {
|
||||||
|
t.Errorf("ShellCommand() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate(t *testing.T) {
|
||||||
|
bad := []config.Unit{
|
||||||
|
{Name: "a", Runtime: config.RuntimeScript},
|
||||||
|
{Name: "b", Runtime: config.RuntimeExec},
|
||||||
|
}
|
||||||
|
for _, u := range bad {
|
||||||
|
if Validate(u) == nil {
|
||||||
|
t.Errorf("Validate(%s) should fail", u.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok := []config.Unit{
|
||||||
|
{Name: "a", Runtime: config.RuntimeScript, Command: "/x"},
|
||||||
|
{Name: "b", Runtime: "podman"},
|
||||||
|
}
|
||||||
|
for _, u := range ok {
|
||||||
|
if err := Validate(u); err != nil {
|
||||||
|
t.Errorf("Validate(%s) = %v", u.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBaseName(t *testing.T) {
|
||||||
|
if got := BaseName(config.Unit{Name: "x"}, "p-", "-s"); got != "unitdore-p-x-s" {
|
||||||
|
t.Errorf("BaseName() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_PodFallbackIsOneshot(t *testing.T) {
|
||||||
|
u := config.Unit{Name: "shop", Runtime: "podman", Kind: config.KindStack, Project: "shop"}
|
||||||
|
spec := Resolve(u)
|
||||||
|
if spec.Type != TypeOneshot || spec.Start != "/usr/bin/podman pod start pod_shop" {
|
||||||
|
t.Errorf("Resolve() = %+v", spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package systemd
|
||||||
|
|
||||||
|
import "github.com/warkanum/unitdore/config"
|
||||||
|
|
||||||
|
// Backend is the systemd implementation of the init interface.
|
||||||
|
type Backend struct{}
|
||||||
|
|
||||||
|
func (Backend) Name() string { return "systemd" }
|
||||||
|
|
||||||
|
func (Backend) ServiceName(u config.Unit, prefix, suffix string) string {
|
||||||
|
return ServiceName(u, prefix, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Backend) UnitPath(u config.Unit, prefix, suffix string) (string, error) {
|
||||||
|
return UnitPath(u, prefix, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Backend) Generate(u config.Unit, prefix, suffix string) (string, error) {
|
||||||
|
return Generate(u, prefix, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Backend) Install(u config.Unit, prefix, suffix string) error { return Install(u, prefix, suffix) }
|
||||||
|
func (Backend) Uninstall(u config.Unit, prefix, suffix string) error {
|
||||||
|
return Uninstall(u, prefix, suffix)
|
||||||
|
}
|
||||||
|
func (Backend) Enable(u config.Unit, prefix, suffix string) error { return Enable(u, prefix, suffix) }
|
||||||
|
func (Backend) Disable(u config.Unit, prefix, suffix string) error { return Disable(u, prefix, suffix) }
|
||||||
|
func (Backend) Start(u config.Unit, prefix, suffix string) error { return Start(u, prefix, suffix) }
|
||||||
|
func (Backend) Stop(u config.Unit, prefix, suffix string) error { return Stop(u, prefix, suffix) }
|
||||||
|
func (Backend) Status(u config.Unit, prefix, suffix string) string { return Status(u, prefix, suffix) }
|
||||||
|
|
||||||
|
func (Backend) IsInstalled(u config.Unit, prefix, suffix string) bool {
|
||||||
|
return IsInstalled(u, prefix, suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SupportsLinger reports that user units need loginctl linger to start at boot.
|
||||||
|
func (Backend) SupportsLinger() bool { return true }
|
||||||
+10
-145
@@ -2,12 +2,11 @@ package systemd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"github.com/warkanum/unitdore/config"
|
"github.com/warkanum/unitdore/config"
|
||||||
|
"github.com/warkanum/unitdore/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
const systemUnitTemplate = `# /etc/systemd/system/{{.ServiceName}}
|
const systemUnitTemplate = `# /etc/systemd/system/{{.ServiceName}}
|
||||||
@@ -82,49 +81,25 @@ type templateData struct {
|
|||||||
|
|
||||||
// ServiceName returns the systemd service name for a unit, with optional prefix/suffix.
|
// ServiceName returns the systemd service name for a unit, with optional prefix/suffix.
|
||||||
func ServiceName(u config.Unit, prefix, suffix string) string {
|
func ServiceName(u config.Unit, prefix, suffix string) string {
|
||||||
return fmt.Sprintf("unitdore-%s%s%s.service", prefix, u.Name, suffix)
|
return service.BaseName(u, prefix, suffix) + ".service"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate produces the .service file content for a unit.
|
// Generate produces the .service file content for a unit.
|
||||||
func Generate(u config.Unit, prefix, suffix string) (string, error) {
|
func Generate(u config.Unit, prefix, suffix string) (string, error) {
|
||||||
if u.Command == "" {
|
if err := service.Validate(u); err != nil {
|
||||||
if u.IsScript() && u.Script == "" {
|
return "", err
|
||||||
return "", fmt.Errorf("unit %s: runtime script requires 'script' (or 'command')", u.Name)
|
|
||||||
}
|
|
||||||
if u.IsExec() && u.Executable == "" {
|
|
||||||
return "", fmt.Errorf("unit %s: runtime exec requires 'executable' (or 'command')", u.Name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
spec := service.Resolve(u)
|
||||||
data := templateData{
|
data := templateData{
|
||||||
ServiceName: ServiceName(u, prefix, suffix),
|
ServiceName: ServiceName(u, prefix, suffix),
|
||||||
Unit: u,
|
Unit: u,
|
||||||
|
ExecStart: spec.Start,
|
||||||
|
ExecStop: spec.Stop,
|
||||||
RuntimeService: runtimeService(u.Runtime),
|
RuntimeService: runtimeService(u.Runtime),
|
||||||
Label: u.Runtime,
|
Label: spec.Label,
|
||||||
ServiceType: "simple",
|
ServiceType: spec.Type,
|
||||||
}
|
WorkingDir: spec.WorkingDir,
|
||||||
|
|
||||||
switch {
|
|
||||||
case u.IsScript() && u.Command == "":
|
|
||||||
data.ExecStart, data.ExecStop = buildScriptCommands(u)
|
|
||||||
data.Label = filepath.Base(u.ShellPath()) + " script"
|
|
||||||
data.WorkingDir = u.WorkingDir
|
|
||||||
data.ServiceType = serviceType(u)
|
|
||||||
case u.IsExec() && u.Command == "":
|
|
||||||
data.ExecStart, data.ExecStop = buildExecutableCommands(u)
|
|
||||||
data.Label = "executable"
|
|
||||||
data.WorkingDir = u.WorkingDir
|
|
||||||
data.ServiceType = serviceType(u)
|
|
||||||
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
|
tmplStr := systemUnitTemplate
|
||||||
@@ -152,113 +127,3 @@ func runtimeService(runtime string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func serviceType(u config.Unit) string {
|
|
||||||
if u.ServiceType == "oneshot" {
|
|
||||||
return "oneshot"
|
|
||||||
}
|
|
||||||
return "simple"
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildScriptCommands(u config.Unit) (start, stop string) {
|
|
||||||
shell := quoteArg(u.ShellPath())
|
|
||||||
start = shell + " " + quoteArg(u.Script)
|
|
||||||
if u.StopScript != "" {
|
|
||||||
stop = shell + " " + quoteArg(u.StopScript)
|
|
||||||
}
|
|
||||||
return start, stop
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildExecutableCommands(u config.Unit) (start, stop string) {
|
|
||||||
parts := []string{quoteArg(u.Executable)}
|
|
||||||
for _, a := range u.Args {
|
|
||||||
parts = append(parts, quoteArg(a))
|
|
||||||
}
|
|
||||||
if u.StopExecutable != "" {
|
|
||||||
stop = quoteArg(u.StopExecutable)
|
|
||||||
}
|
|
||||||
return strings.Join(parts, " "), stop
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildExecCommands(u config.Unit) (start, stop string) {
|
|
||||||
// If a custom command is provided, use it directly
|
|
||||||
if u.Command != "" {
|
|
||||||
return u.Command, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
switch u.Runtime {
|
|
||||||
case "podman":
|
|
||||||
start = fmt.Sprintf("/usr/bin/podman start -a %s", u.Name)
|
|
||||||
stop = fmt.Sprintf("/usr/bin/podman stop %s", u.Name)
|
|
||||||
case "docker":
|
|
||||||
start = fmt.Sprintf("/usr/bin/docker start -a %s", u.Name)
|
|
||||||
stop = fmt.Sprintf("/usr/bin/docker stop %s", u.Name)
|
|
||||||
default:
|
|
||||||
start = fmt.Sprintf("/usr/bin/%s start %s", u.Runtime, u.Name)
|
|
||||||
stop = fmt.Sprintf("/usr/bin/%s stop %s", u.Runtime, u.Name)
|
|
||||||
}
|
|
||||||
return start, stop
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) + `"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -226,46 +226,6 @@ func TestGenerate_WithPrefixSuffix(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildExecCommands(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
unit config.Unit
|
|
||||||
wantStart string
|
|
||||||
wantStop string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "podman",
|
|
||||||
unit: config.Unit{Name: "app", Runtime: "podman"},
|
|
||||||
wantStart: "/usr/bin/podman start -a app",
|
|
||||||
wantStop: "/usr/bin/podman stop app",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "docker",
|
|
||||||
unit: config.Unit{Name: "app", Runtime: "docker"},
|
|
||||||
wantStart: "/usr/bin/docker start -a app",
|
|
||||||
wantStop: "/usr/bin/docker stop app",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "custom command",
|
|
||||||
unit: config.Unit{Name: "app", Runtime: "podman", Command: "/bin/custom"},
|
|
||||||
wantStart: "/bin/custom",
|
|
||||||
wantStop: "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
start, stop := buildExecCommands(tt.unit)
|
|
||||||
if start != tt.wantStart {
|
|
||||||
t.Errorf("start = %q, want %q", start, tt.wantStart)
|
|
||||||
}
|
|
||||||
if stop != tt.wantStop {
|
|
||||||
t.Errorf("stop = %q, want %q", stop, tt.wantStop)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newComposeStack creates a stack unit whose compose binary, working dir and
|
// newComposeStack creates a stack unit whose compose binary, working dir and
|
||||||
// compose file exist on disk (compose mode).
|
// compose file exist on disk (compose mode).
|
||||||
func newComposeStack(t *testing.T) config.Unit {
|
func newComposeStack(t *testing.T) config.Unit {
|
||||||
@@ -290,31 +250,6 @@ func newComposeStack(t *testing.T) config.Unit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
func TestGenerate_StackCompose(t *testing.T) {
|
||||||
u := newComposeStack(t)
|
u := newComposeStack(t)
|
||||||
content, err := Generate(u, "", "")
|
content, err := Generate(u, "", "")
|
||||||
@@ -376,22 +311,6 @@ func TestGenerate_StackCustomCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGenerate_Script(t *testing.T) {
|
func TestGenerate_Script(t *testing.T) {
|
||||||
u := config.Unit{
|
u := config.Unit{
|
||||||
Name: "backup",
|
Name: "backup",
|
||||||
|
|||||||
@@ -2,36 +2,11 @@ package systemd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/warkanum/unitdore/config"
|
"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.
|
// TestStartAll_SortsUnitsInStartupOrder verifies that startall sorts by Order asc + Name.
|
||||||
func TestStartAll_SortsUnitsInStartupOrder(t *testing.T) {
|
func TestStartAll_SortsUnitsInStartupOrder(t *testing.T) {
|
||||||
u1 := config.Unit{Name: "app2", Runtime: "podman", Order: 5, Enabled: true}
|
u1 := config.Unit{Name: "app2", Runtime: "podman", Order: 5, Enabled: true}
|
||||||
|
|||||||
Reference in New Issue
Block a user