Files
kandev/apps/backend/internal/launcher/service.go
T
2026-06-20 19:40:15 +01:00

610 lines
17 KiB
Go

package launcher
import (
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"runtime"
"strings"
)
type serviceArgs struct {
Action string
System bool
Port int
HomeDir string
NoBootStart bool
Follow bool
ShowHelp bool
}
const (
actionInstall = "install"
actionUninstall = "uninstall"
actionRestart = "restart"
actionConfig = "config"
actionLogs = "logs"
actionStatus = "status"
actionStop = "stop"
flagHelp = "--help"
goosLinux = "linux"
goosDarwin = "darwin"
managedMarker = "managed by kandev"
serviceUnitName = "kandev.service"
)
const serviceHelp = `kandev service — install kandev as an OS-managed service
Usage:
kandev service install [--system] [--port <port>] [--home-dir <path>] [--no-boot-start]
kandev service uninstall [--system]
kandev service start|stop|restart|status [--system]
kandev service logs [-f] [--system]
kandev service config [--system]
`
var (
executeServiceCommand = defaultRunCommand
servicePrintln = func(message string) { fmt.Println(message) }
)
func runService(argv []string, build BuildInfo) int {
args, err := parseServiceArgs(argv)
if err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 2
}
if args.ShowHelp {
fmt.Print(serviceHelp)
return 0
}
if args.Action == actionConfig {
printServiceConfig(args)
return 0
}
switch runtime.GOOS {
case goosLinux:
return runLinuxService(args, build)
case goosDarwin:
return runLaunchdService(args, build)
default:
fmt.Fprintf(os.Stderr, "[kandev] service is not supported on %s\n", runtime.GOOS)
return 1
}
}
func parseServiceArgs(argv []string) (serviceArgs, error) {
if len(argv) == 0 || argv[0] == flagHelp || argv[0] == "-h" {
return serviceArgs{Action: actionInstall, ShowHelp: true}, nil
}
action := argv[0]
if !validServiceAction(action) {
return serviceArgs{}, ParseError{Message: fmt.Sprintf("unknown service action %q", action)}
}
out := serviceArgs{Action: action}
for i := 1; i < len(argv); i++ {
next, err := parseServiceFlag(argv, i, action, &out)
if err != nil {
return out, err
}
i = next
}
return out, nil
}
func validServiceAction(action string) bool {
switch action {
case actionInstall, actionUninstall, string(CommandStart), actionStop, actionRestart, actionStatus, actionLogs, actionConfig:
return true
default:
return false
}
}
func parseServiceFlag(argv []string, i int, action string, out *serviceArgs) (int, error) {
arg := argv[i]
switch {
case arg == flagHelp || arg == "-h":
out.ShowHelp = true
case arg == "--system":
out.System = true
case arg == "--no-boot-start":
out.NoBootStart = true
case arg == "-f" || arg == "--follow":
out.Follow = true
case arg == "--port":
return parseServicePort(argv, i, out)
case strings.HasPrefix(arg, "--port="):
return i, parseServicePortValue(strings.TrimPrefix(arg, "--port="), out)
case arg == "--home-dir":
return parseServiceHomeDir(argv, i, out)
case strings.HasPrefix(arg, "--home-dir="):
out.HomeDir = expandHome(strings.TrimPrefix(arg, "--home-dir="))
default:
return i, ParseError{Message: fmt.Sprintf("unknown flag %q for kandev service %s", arg, action)}
}
return i, nil
}
func parseServicePort(argv []string, i int, out *serviceArgs) (int, error) {
v, err := takeValue(argv, i, "--port")
if err != nil {
return i, err
}
if err := parseServicePortValue(v, out); err != nil {
return i, err
}
return i + 1, nil
}
func parseServicePortValue(value string, out *serviceArgs) error {
port, err := parsePort(value, "--port")
if err != nil {
return err
}
out.Port = port
return nil
}
func parseServiceHomeDir(argv []string, i int, out *serviceArgs) (int, error) {
v, err := takeValue(argv, i, "--home-dir")
if err != nil {
return i, err
}
out.HomeDir = expandHome(v)
return i + 1, nil
}
func runLinuxService(args serviceArgs, build BuildInfo) int {
unitPath := linuxUnitPath(args.System)
switch args.Action {
case actionInstall:
return installSystemd(args, build, unitPath)
case actionUninstall:
_ = runCommand("systemctl", append(systemctlScope(args.System), "disable", "--now", serviceUnitName)...)
_ = os.Remove(unitPath)
_ = runCommand("systemctl", append(systemctlScope(args.System), "daemon-reload")...)
return 0
case string(CommandStart), actionStop, actionRestart, actionStatus:
if err := runCommand("systemctl", append(systemctlScope(args.System), args.Action, serviceUnitName)...); err != nil {
return 1
}
return 0
case actionLogs:
if err := runCommand("journalctl", buildJournalArgs(args)...); err != nil {
return 1
}
return 0
}
return 1
}
func buildJournalArgs(args serviceArgs) []string {
journalArgs := []string{"-n", "200", "--no-pager"}
if args.System {
journalArgs = append([]string{"-u", serviceUnitName}, journalArgs...)
} else {
journalArgs = append([]string{"--user-unit", serviceUnitName}, journalArgs...)
}
if args.Follow {
journalArgs = journalArgs[:len(journalArgs)-1]
journalArgs = append(journalArgs, "-f")
}
return journalArgs
}
func installSystemd(args serviceArgs, build BuildInfo, unitPath string) int {
self, err := executablePath()
if err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
homeDir := serviceHomeDir(args)
logDir := filepath.Join(homeDir, "logs")
if err := os.MkdirAll(filepath.Dir(unitPath), 0o755); err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
unit := renderSystemdUnit(nativeServiceUnitInput{
Executable: self,
HomeDir: homeDir,
LogDir: logDir,
Port: args.Port,
System: args.System,
SystemUser: serviceUser(args.System),
BundleDir: serviceBundleDir(self),
Version: serviceVersion(build.Version),
})
if err := backupUnmanagedServiceFile(unitPath); err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
if err := runCommand("systemctl", append(systemctlScope(args.System), "daemon-reload")...); err != nil {
return 1
}
if args.NoBootStart {
if err := runCommand("systemctl", append(systemctlScope(args.System), string(CommandStart), serviceUnitName)...); err != nil {
return 1
}
servicePrintln("[kandev] service installed and started (boot-start disabled)")
return 0
}
if err := runCommand("systemctl", append(systemctlScope(args.System), "enable", "--now", serviceUnitName)...); err != nil {
return 1
}
servicePrintln("[kandev] service enabled and started")
return 0
}
func runLaunchdService(args serviceArgs, build BuildInfo) int {
plistPath := launchdPlistPath(args.System)
target := "gui/" + fmt.Sprint(os.Getuid()) + "/com.kdlbs.kandev"
domain := "gui/" + fmt.Sprint(os.Getuid())
if args.System {
target = "system/com.kdlbs.kandev"
domain = "system"
}
switch args.Action {
case actionInstall:
return installLaunchd(args, build, plistPath, target, domain)
case "uninstall":
_ = runCommand("launchctl", "bootout", target)
_ = os.Remove(plistPath)
return 0
case "start":
if err := runCommand("launchctl", "kickstart", target); err != nil {
return runCommandExit("launchctl", "bootstrap", domain, plistPath)
}
return 0
case "stop":
_ = runCommand("launchctl", "bootout", target)
return 0
case actionRestart:
if err := runCommand("launchctl", "kickstart", "-k", target); err != nil {
_ = runCommand("launchctl", "bootout", target)
return runCommandExit("launchctl", "bootstrap", domain, plistPath)
}
return 0
case "status":
return runCommandExit("launchctl", "print", target)
case "logs":
logDir := filepath.Join(serviceHomeDir(args), "logs")
tailArgs := []string{"-n", "200", filepath.Join(logDir, "service.out"), filepath.Join(logDir, "service.err")}
if args.Follow {
tailArgs = []string{"-f", "-n", "200", filepath.Join(logDir, "service.out"), filepath.Join(logDir, "service.err")}
}
return runCommandExit("tail", tailArgs...)
}
return 1
}
func installLaunchd(args serviceArgs, build BuildInfo, plistPath, target, domain string) int {
self, err := executablePath()
if err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
homeDir := serviceHomeDir(args)
logDir := filepath.Join(homeDir, "logs")
if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
if err := os.MkdirAll(logDir, 0o755); err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
plist := renderLaunchdPlist(nativeServiceUnitInput{
Executable: self,
HomeDir: homeDir,
LogDir: logDir,
Port: args.Port,
System: args.System,
SystemUser: serviceUser(args.System),
NoBootStart: args.NoBootStart,
BundleDir: serviceBundleDir(self),
Version: serviceVersion(build.Version),
})
if err := backupUnmanagedServiceFile(plistPath); err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
if err := os.WriteFile(plistPath, []byte(plist), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
return 1
}
_ = runCommand("launchctl", "bootout", target)
if err := runCommand("launchctl", "bootstrap", domain, plistPath); err != nil {
return 1
}
_ = runCommand("launchctl", "enable", target)
if args.NoBootStart {
if err := runCommand("launchctl", "kickstart", target); err != nil {
return 1
}
servicePrintln("[kandev] service loaded and started (boot-start disabled)")
return 0
}
servicePrintln("[kandev] service loaded, enabled, and started")
return 0
}
type nativeServiceUnitInput struct {
Executable string
HomeDir string
LogDir string
Port int
System bool
SystemUser string
NoBootStart bool
BundleDir string
Version string
}
func renderSystemdUnit(input nativeServiceUnitInput) string {
env := []string{
serviceEnvLine("KANDEV_HOME_DIR", input.HomeDir),
serviceEnvLine("KANDEV_LOG_LEVEL", "info"),
serviceEnvLineAllowSpecifiers("PATH", "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin:%h/.local/bin:%h/.bun/bin:%h/.opencode/bin"),
}
if input.Port != 0 {
env = append(env, serviceEnvLine("KANDEV_SERVER_PORT", fmt.Sprint(input.Port)))
}
if input.BundleDir != "" {
env = append(env, serviceEnvLine("KANDEV_BUNDLE_DIR", input.BundleDir))
}
if input.Version != "" {
env = append(env, serviceEnvLine("KANDEV_VERSION", input.Version))
}
userLine := ""
wantedBy := "default.target"
if input.System {
wantedBy = "multi-user.target"
if input.SystemUser != "" {
userLine = "User=" + input.SystemUser + "\n"
}
}
return "# managed by kandev — regenerated by `kandev service install`\n" +
"[Unit]\nDescription=Kandev autonomous agent platform\nAfter=network-online.target\nWants=network-online.target\n\n" +
"[Service]\nType=simple\nExecStart=" + quoteForUnit(input.Executable) + " --headless\n" +
userLine + strings.Join(env, "\n") + "\nRestart=on-failure\nRestartSec=5s\nKillMode=mixed\nTimeoutStopSec=30s\n\n" +
"[Install]\nWantedBy=" + wantedBy + "\n"
}
func renderLaunchdPlist(input nativeServiceUnitInput) string {
envEntries := [][2]string{{"KANDEV_HOME_DIR", input.HomeDir}, {"KANDEV_LOG_LEVEL", "info"}, {"PATH", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"}}
if input.Port != 0 {
envEntries = append(envEntries, [2]string{"KANDEV_SERVER_PORT", fmt.Sprint(input.Port)})
}
if input.BundleDir != "" {
envEntries = append(envEntries, [2]string{"KANDEV_BUNDLE_DIR", input.BundleDir})
}
if input.Version != "" {
envEntries = append(envEntries, [2]string{"KANDEV_VERSION", input.Version})
}
var envXML strings.Builder
for _, entry := range envEntries {
envXML.WriteString(" <key>" + escapeXML(entry[0]) + "</key>\n <string>" + escapeXML(entry[1]) + "</string>\n")
}
userBlock := ""
if input.System && input.SystemUser != "" {
userBlock = " <key>UserName</key>\n <string>" + escapeXML(input.SystemUser) + "</string>\n"
}
return `<?xml version="1.0" encoding="UTF-8"?>
<!-- managed by kandev — regenerated by ` + "`kandev service install`" + ` -->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.kdlbs.kandev</string>
<key>ProgramArguments</key>
<array>
<string>` + escapeXML(input.Executable) + `</string>
<string>--headless</string>
</array>
<key>EnvironmentVariables</key>
<dict>
` + envXML.String() + ` </dict>
` + userBlock + ` <key>RunAtLoad</key>
` + plistBool(!input.NoBootStart) + `
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>` + escapeXML(filepath.Join(input.LogDir, "service.out")) + `</string>
<key>StandardErrorPath</key>
<string>` + escapeXML(filepath.Join(input.LogDir, "service.err")) + `</string>
<key>WorkingDirectory</key>
<string>` + escapeXML(input.HomeDir) + `</string>
</dict>
</plist>
`
}
func plistBool(value bool) string {
if value {
return "<true/>"
}
return "<false/>"
}
func serviceEnvLine(key, value string) string {
return "Environment=" + quoteForUnit(key+"="+value)
}
func serviceEnvLineAllowSpecifiers(key, value string) string {
return "Environment=" + quoteForUnitAllowSpecifiers(key+"="+value)
}
func quoteForUnit(value string) string {
return quoteForUnitValue(value, true)
}
func quoteForUnitAllowSpecifiers(value string) string {
return quoteForUnitValue(value, false)
}
func quoteForUnitValue(value string, escapeSystemdSpecifiers bool) string {
chars := " \\\"$"
if escapeSystemdSpecifiers {
chars += "%"
}
if !strings.ContainsAny(value, chars) {
return value
}
replacements := []string{"\\", "\\\\", "$", "$$", "\"", "\\\""}
if escapeSystemdSpecifiers {
replacements = append(replacements, "%", "%%")
}
escaped := strings.NewReplacer(replacements...).Replace(value)
return `"` + escaped + `"`
}
func serviceBundleDir(executable string) string {
if v := os.Getenv("KANDEV_BUNDLE_DIR"); v != "" {
return v
}
return filepath.Dir(filepath.Dir(executable))
}
func serviceVersion(buildVersion string) string {
if v := os.Getenv("KANDEV_VERSION"); v != "" {
return v
}
return buildVersion
}
func backupUnmanagedServiceFile(path string) error {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if info.IsDir() {
return fmt.Errorf("%s is a directory", path)
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
if strings.Contains(string(data), managedMarker) {
return nil
}
backupPath := path + ".bak"
if err := os.WriteFile(backupPath, data, info.Mode().Perm()); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "[kandev] WARNING: existing unmanaged service file backed up to %s\n", backupPath)
return nil
}
func escapeXML(value string) string {
replacer := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")
return replacer.Replace(value)
}
func linuxUnitPath(system bool) string {
if system {
return "/etc/systemd/system/kandev.service"
}
return filepath.Join(mustHomeDir(), ".config", "systemd", "user", serviceUnitName)
}
func launchdPlistPath(system bool) string {
if system {
return "/Library/LaunchDaemons/com.kdlbs.kandev.plist"
}
return filepath.Join(mustHomeDir(), "Library", "LaunchAgents", "com.kdlbs.kandev.plist")
}
func systemctlScope(system bool) []string {
if system {
return nil
}
return []string{"--user"}
}
func serviceHomeDir(args serviceArgs) string {
if args.HomeDir != "" {
return args.HomeDir
}
if args.System {
return "/var/lib/kandev"
}
return resolveHomeDir()
}
func serviceUser(system bool) string {
if !system {
return ""
}
if sudo := os.Getenv("SUDO_USER"); sudo != "" && sudo != "root" {
return sudo
}
u, err := user.Current()
if err != nil {
return ""
}
return u.Username
}
func printServiceConfig(args serviceArgs) {
fmt.Println("manager:", runtime.GOOS)
fmt.Println("mode:", map[bool]string{true: "system", false: "user"}[args.System])
fmt.Println("home:", serviceHomeDir(args))
if runtime.GOOS == goosLinux {
fmt.Println("unit:", linuxUnitPath(args.System))
}
if runtime.GOOS == goosDarwin {
fmt.Println("plist:", launchdPlistPath(args.System))
}
}
func expandHome(path string) string {
if path == "~" {
return mustHomeDir()
}
if strings.HasPrefix(path, "~/") {
return filepath.Join(mustHomeDir(), strings.TrimPrefix(path, "~/"))
}
abs, err := filepath.Abs(path)
if err != nil {
return path
}
return abs
}
func mustHomeDir() string {
home, err := os.UserHomeDir()
if err != nil {
return "."
}
return home
}
func runCommand(name string, args ...string) error {
return executeServiceCommand(name, args...)
}
func defaultRunCommand(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}
func runCommandExit(name string, args ...string) int {
if err := runCommand(name, args...); err != nil {
return 1
}
return 0
}