- syncup: podman first, docker only as fallback (podman wins on name clash;
existing docker units found in podman switch to podman)
- syncup: group podman-compose containers into one `kind: stack` unit per
project (project, working_dir, compose_files, compose_bin from labels);
disable existing member units ("part of stack <project>")
- generator: compose mode (podman-compose up/stop, WorkingDirectory) with
pod start/stop fallback when compose binary/dir/files are missing
- reconcile: stacks disabled with "stack not found" when neither compose
files nor pod exist
- root: scan root + rootless users listed in `users:` (podman only)
- non-root: config ~/.config/unitdore/units.yaml, own user units only
- sysuser package: runuser with XDG_RUNTIME_DIR/DBUS env, home/linger lookup
- install: warn when linger is not enabled for user units
- testdata/stack: podman-compose test stack and captured podman ps fixture
- docs: README, generated-units, man page, AGENTS, PLAN
150 lines
3.8 KiB
Go
150 lines
3.8 KiB
Go
package systemd
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/warkanum/unitdore/config"
|
|
"github.com/warkanum/unitdore/sysuser"
|
|
)
|
|
|
|
const systemUnitDir = "/etc/systemd/system"
|
|
|
|
// UnitPath returns the filesystem path where the .service file should be written.
|
|
func UnitPath(u config.Unit, prefix, suffix string) (string, error) {
|
|
svcName := ServiceName(u, prefix, suffix)
|
|
if u.User == "" {
|
|
return filepath.Join(systemUnitDir, svcName), nil
|
|
}
|
|
// Rootless: write to the user's systemd config dir
|
|
home, err := sysuser.HomeDir(u.User)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(home, ".config", "systemd", "user", svcName), nil
|
|
}
|
|
|
|
// Install writes the .service file for a unit and reloads systemd.
|
|
func Install(u config.Unit, prefix, suffix string) error {
|
|
content, err := Generate(u, prefix, suffix)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
path, err := UnitPath(u, prefix, suffix)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
return fmt.Errorf("creating unit dir: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
return fmt.Errorf("writing unit file: %w", err)
|
|
}
|
|
|
|
return daemonReload(u.User)
|
|
}
|
|
|
|
// Uninstall removes the .service file for a unit and reloads systemd.
|
|
func Uninstall(u config.Unit, prefix, suffix string) error {
|
|
path, err := UnitPath(u, prefix, suffix)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("removing unit file: %w", err)
|
|
}
|
|
|
|
return daemonReload(u.User)
|
|
}
|
|
|
|
// Enable enables and starts a unit.
|
|
func Enable(u config.Unit, prefix, suffix string) error {
|
|
return systemctl(u.User, "enable", "--now", ServiceName(u, prefix, suffix))
|
|
}
|
|
|
|
// Disable disables and stops a unit.
|
|
func Disable(u config.Unit, prefix, suffix string) error {
|
|
return systemctl(u.User, "disable", "--now", ServiceName(u, prefix, suffix))
|
|
}
|
|
|
|
// Start starts a unit without enabling it.
|
|
func Start(u config.Unit, prefix, suffix string) error {
|
|
return systemctl(u.User, "start", ServiceName(u, prefix, suffix))
|
|
}
|
|
|
|
// Stop stops a unit without disabling it.
|
|
func Stop(u config.Unit, prefix, suffix string) error {
|
|
return systemctl(u.User, "stop", ServiceName(u, prefix, suffix))
|
|
}
|
|
|
|
// Status returns the ActiveState of a unit ("active", "inactive", "failed", "unknown").
|
|
func Status(u config.Unit, prefix, suffix string) string {
|
|
args := []string{"show", "-p", "ActiveState", "--value", ServiceName(u, prefix, suffix)}
|
|
var out []byte
|
|
var err error
|
|
|
|
if u.User == "" {
|
|
out, err = exec.Command("systemctl", args...).Output()
|
|
} else {
|
|
out, err = runAsUser(u.User, "systemctl", append([]string{"--user"}, args...)...)
|
|
}
|
|
if err != nil {
|
|
return "unknown"
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
// IsInstalled returns true if the unit file exists on disk.
|
|
func IsInstalled(u config.Unit, prefix, suffix string) bool {
|
|
path, err := UnitPath(u, prefix, suffix)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
_, err = os.Stat(path)
|
|
return err == nil
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func daemonReload(user string) error {
|
|
if user == "" {
|
|
return systemctl("", "daemon-reload")
|
|
}
|
|
return systemctl(user, "daemon-reload")
|
|
}
|
|
|
|
func systemctl(user string, args ...string) error {
|
|
if user == "" {
|
|
cmd := exec.Command("systemctl", args...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
return runAsUserCmd(user, "systemctl", append([]string{"--user"}, args...)...)
|
|
}
|
|
|
|
func runAsUser(user string, bin string, args ...string) ([]byte, error) {
|
|
cmd, err := sysuser.Command(user, bin, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return cmd.Output()
|
|
}
|
|
|
|
func runAsUserCmd(user string, bin string, args ...string) error {
|
|
cmd, err := sysuser.Command(user, bin, args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|