feat: script and exec runtimes with configurable shell

Add runtime: script (shell file, default /bin/bash, optional stop_script) and runtime: exec (executable with args, optional stop_executable). Both support service_type simple|oneshot and working_dir.
This commit is contained in:
2026-10-12 19:45:54 +02:00
parent 173b4b2523
commit 95143dd998
5 changed files with 251 additions and 1 deletions
+8 -1
View File
@@ -54,7 +54,7 @@ users: [] # root only: rootless users scanned by syncup
units: units:
- name: nginx - name: nginx
runtime: podman # podman | docker runtime: podman # podman | docker | script | exec
user: "" # empty = system unit; set for rootless user unit user: "" # empty = system unit; set for rootless user unit
command: "" # override ExecStart entirely command: "" # override ExecStart entirely
order: 1 # startup order, lower = earlier; same = parallel order: 1 # startup order, lower = earlier; same = parallel
@@ -69,6 +69,13 @@ units:
working_dir: "" # stack: compose dir (from labels) working_dir: "" # stack: compose dir (from labels)
compose_files: [] # stack: relative to working_dir compose_files: [] # stack: relative to working_dir
compose_bin: "" # stack: podman-compose path compose_bin: "" # stack: podman-compose path
script: "" # script: file run via shell (ExecStart)
stop_script: "" # script: optional ExecStop
shell: "" # script: interpreter, default /bin/bash
executable: "" # exec: binary run as ExecStart
args: [] # exec: arguments
stop_executable: "" # exec: optional ExecStop
service_type: "" # script/exec: simple (default) | oneshot
``` ```
Non-root runs use `~/.config/unitdore/units.yaml` and manage only own user units. Non-root runs use `~/.config/unitdore/units.yaml` and manage only own user units.
+35
View File
@@ -138,8 +138,43 @@ units:
compose_bin: /usr/bin/podman-compose compose_bin: /usr/bin/podman-compose
order: 3 order: 3
enabled: true enabled: true
- name: backup # shell script unit
runtime: script
script: /opt/scripts/backup.sh # required
stop_script: /opt/scripts/stop.sh # optional → ExecStop
shell: /bin/bash # optional, default /bin/bash
service_type: simple # simple (default) | oneshot
working_dir: /opt/scripts # optional
order: 4
enabled: true
- name: agent # executable unit
runtime: exec
executable: /opt/agent/bin/agent # required
args: [--config, /etc/agent.toml] # optional
stop_executable: /opt/agent/bin/stop # optional → ExecStop
order: 5
enabled: true
``` ```
### Script / executable units
Added manually (`unitdore edit`); syncup never creates or disables them.
| Key | Runtime | Meaning |
|---|---|---|
| `script` | script | Required. `ExecStart=<shell> <script>` |
| `stop_script` | script | Optional. `ExecStop=-<shell> <stop_script>` |
| `shell` | script | Interpreter, default `/bin/bash` |
| `executable` | exec | Required. `ExecStart=<executable> <args>` |
| `args` | exec | Optional list, quoted as needed |
| `stop_executable` | exec | Optional. `ExecStop=-<stop_executable>` |
| `service_type` | both | `simple` (default) or `oneshot` (RemainAfterExit=yes) |
| `working_dir` | both | Optional `WorkingDirectory` |
| `command` | both | If set, overrides ExecStart |
| `user` | both | Set for a user unit |
### Stacks (podman-compose) ### Stacks (podman-compose)
| Condition | Unit | | Condition | Unit |
+35
View File
@@ -21,6 +21,14 @@ const (
KindStack = "stack" KindStack = "stack"
) )
// Runtimes for units that are not containers.
const (
RuntimeScript = "script" // run a shell script
RuntimeExec = "exec" // run an executable directly
DefaultShell = "/bin/bash"
)
// Unit represents a single managed container unit. // Unit represents a single managed container unit.
type Unit struct { type Unit struct {
Name string `yaml:"name"` Name string `yaml:"name"`
@@ -41,11 +49,38 @@ type Unit struct {
WorkingDir string `yaml:"working_dir,omitempty"` // compose project directory WorkingDir string `yaml:"working_dir,omitempty"` // compose project directory
ComposeFiles []string `yaml:"compose_files,omitempty"` // relative to working_dir unless absolute ComposeFiles []string `yaml:"compose_files,omitempty"` // relative to working_dir unless absolute
ComposeBin string `yaml:"compose_bin,omitempty"` // path to podman-compose ComposeBin string `yaml:"compose_bin,omitempty"` // path to podman-compose
// Script fields (runtime: script) — run a shell file as a service.
Script string `yaml:"script,omitempty"` // path to script run as ExecStart
StopScript string `yaml:"stop_script,omitempty"` // optional script run as ExecStop
Shell string `yaml:"shell,omitempty"` // interpreter, default /bin/bash
// Executable fields (runtime: exec) — run a binary directly.
Executable string `yaml:"executable,omitempty"` // path to binary run as ExecStart
Args []string `yaml:"args,omitempty"` // arguments for executable
StopExecutable string `yaml:"stop_executable,omitempty"` // optional binary run as ExecStop
// Script and exec units: simple (default) | oneshot. WorkingDir is also used.
ServiceType string `yaml:"service_type,omitempty"`
} }
// IsStack reports whether the unit manages a compose stack. // IsStack reports whether the unit manages a compose stack.
func (u Unit) IsStack() bool { return u.Kind == KindStack } func (u Unit) IsStack() bool { return u.Kind == KindStack }
// IsScript reports whether the unit runs a shell script.
func (u Unit) IsScript() bool { return u.Runtime == RuntimeScript }
// IsExec reports whether the unit runs an executable directly.
func (u Unit) IsExec() bool { return u.Runtime == RuntimeExec }
// ShellPath returns the configured interpreter, or the default.
func (u Unit) ShellPath() string {
if u.Shell == "" {
return DefaultShell
}
return u.Shell
}
// Config is the root config structure. // Config is the root config structure.
type Config struct { type Config struct {
Units []Unit `yaml:"units"` Units []Unit `yaml:"units"`
+46
View File
@@ -87,6 +87,15 @@ func ServiceName(u config.Unit, prefix, suffix string) string {
// 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 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)
}
}
data := templateData{ data := templateData{
ServiceName: ServiceName(u, prefix, suffix), ServiceName: ServiceName(u, prefix, suffix),
Unit: u, Unit: u,
@@ -96,6 +105,16 @@ func Generate(u config.Unit, prefix, suffix string) (string, error) {
} }
switch { 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: case u.IsStack() && u.Command == "" && StackMode(u) == StackModeCompose:
data.ExecStart, data.ExecStop = buildComposeCommands(u) data.ExecStart, data.ExecStop = buildComposeCommands(u)
data.Label = "podman-compose stack" data.Label = "podman-compose stack"
@@ -134,6 +153,33 @@ 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) { func buildExecCommands(u config.Unit) (start, stop string) {
// If a custom command is provided, use it directly // If a custom command is provided, use it directly
if u.Command != "" { if u.Command != "" {
+127
View File
@@ -391,3 +391,130 @@ func TestBuildComposeCommands_Quoting(t *testing.T) {
t.Errorf("stop = %q", stop) t.Errorf("stop = %q", stop)
} }
} }
func TestGenerate_Script(t *testing.T) {
u := config.Unit{
Name: "backup",
Runtime: config.RuntimeScript,
Script: "/opt/scripts/backup.sh",
StopScript: "/opt/scripts/backup-stop.sh",
WorkingDir: "/opt/scripts",
Enabled: true,
}
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
checks := []string{
"Description=Unitdore: backup (bash script)",
"Type=simple",
"WorkingDirectory=/opt/scripts",
"ExecStart=/bin/bash /opt/scripts/backup.sh",
"ExecStop=-/bin/bash /opt/scripts/backup-stop.sh",
}
for _, check := range checks {
if !strings.Contains(content, check) {
t.Errorf("Generate() script missing %q in output:\n%s", check, content)
}
}
if strings.Contains(content, "Requires=") {
t.Errorf("Generate() script unit should not contain Requires=:\n%s", content)
}
}
func TestGenerate_ScriptOneshotUserQuoted(t *testing.T) {
u := config.Unit{
Name: "setup",
Runtime: config.RuntimeScript,
User: "alice",
Script: "/home/alice/my scripts/setup.sh",
ServiceType: "oneshot",
Enabled: true,
}
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
checks := []string{
"Type=oneshot",
"RemainAfterExit=yes",
`ExecStart=/bin/bash "/home/alice/my scripts/setup.sh"`,
"WantedBy=default.target",
}
for _, check := range checks {
if !strings.Contains(content, check) {
t.Errorf("Generate() missing %q in output:\n%s", check, content)
}
}
if strings.Contains(content, "ExecStop=-") {
t.Errorf("Generate() should have no stop command without stop_script:\n%s", content)
}
}
func TestGenerate_ScriptMissingPath(t *testing.T) {
u := config.Unit{Name: "bad", Runtime: config.RuntimeScript, Enabled: true}
if _, err := Generate(u, "", ""); err == nil {
t.Error("Generate() should error when script path is empty")
}
}
func TestGenerate_ScriptCustomShell(t *testing.T) {
u := config.Unit{
Name: "fishy",
Runtime: config.RuntimeScript,
Shell: "/usr/bin/zsh",
Script: "/opt/run.zsh",
Enabled: true,
}
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
for _, check := range []string{"Description=Unitdore: fishy (zsh script)", "ExecStart=/usr/bin/zsh /opt/run.zsh"} {
if !strings.Contains(content, check) {
t.Errorf("Generate() missing %q in output:\n%s", check, content)
}
}
}
func TestGenerate_Executable(t *testing.T) {
u := config.Unit{
Name: "agent",
Runtime: config.RuntimeExec,
Executable: "/opt/agent/bin/agent",
Args: []string{"--config", "/etc/my agent.toml"},
StopExecutable: "/opt/agent/bin/agent-stop",
WorkingDir: "/opt/agent",
ServiceType: "oneshot",
Enabled: true,
}
content, err := Generate(u, "", "")
if err != nil {
t.Fatalf("Generate() error: %v", err)
}
checks := []string{
"Description=Unitdore: agent (executable)",
"Type=oneshot",
"WorkingDirectory=/opt/agent",
`ExecStart=/opt/agent/bin/agent --config "/etc/my agent.toml"`,
"ExecStop=-/opt/agent/bin/agent-stop",
}
for _, check := range checks {
if !strings.Contains(content, check) {
t.Errorf("Generate() missing %q in output:\n%s", check, content)
}
}
}
func TestGenerate_ExecutableMissingPath(t *testing.T) {
u := config.Unit{Name: "bad", Runtime: config.RuntimeExec, Enabled: true}
if _, err := Generate(u, "", ""); err == nil {
t.Error("Generate() should error when executable is empty")
}
}