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:
@@ -54,7 +54,7 @@ users: [] # root only: rootless users scanned by syncup
|
||||
|
||||
units:
|
||||
- name: nginx
|
||||
runtime: podman # podman | docker
|
||||
runtime: podman # podman | docker | script | exec
|
||||
user: "" # empty = system unit; set for rootless user unit
|
||||
command: "" # override ExecStart entirely
|
||||
order: 1 # startup order, lower = earlier; same = parallel
|
||||
@@ -69,6 +69,13 @@ units:
|
||||
working_dir: "" # stack: compose dir (from labels)
|
||||
compose_files: [] # stack: relative to working_dir
|
||||
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.
|
||||
|
||||
@@ -138,8 +138,43 @@ units:
|
||||
compose_bin: /usr/bin/podman-compose
|
||||
order: 3
|
||||
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)
|
||||
|
||||
| Condition | Unit |
|
||||
|
||||
@@ -21,6 +21,14 @@ const (
|
||||
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.
|
||||
type Unit struct {
|
||||
Name string `yaml:"name"`
|
||||
@@ -41,11 +49,38 @@ type Unit struct {
|
||||
WorkingDir string `yaml:"working_dir,omitempty"` // compose project directory
|
||||
ComposeFiles []string `yaml:"compose_files,omitempty"` // relative to working_dir unless absolute
|
||||
ComposeBin string `yaml:"compose_bin,omitempty"` // path to podman-compose
|
||||
|
||||
// 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.
|
||||
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.
|
||||
type Config struct {
|
||||
Units []Unit `yaml:"units"`
|
||||
|
||||
@@ -87,6 +87,15 @@ func ServiceName(u config.Unit, prefix, suffix string) string {
|
||||
|
||||
// Generate produces the .service file content for a unit.
|
||||
func Generate(u config.Unit, prefix, suffix 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{
|
||||
ServiceName: ServiceName(u, prefix, suffix),
|
||||
Unit: u,
|
||||
@@ -96,6 +105,16 @@ func Generate(u config.Unit, prefix, suffix string) (string, error) {
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -134,6 +153,33 @@ func runtimeService(runtime string) string {
|
||||
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 != "" {
|
||||
|
||||
@@ -391,3 +391,130 @@ func TestBuildComposeCommands_Quoting(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user