diff --git a/apps/backend/internal/launcher/launchctl_reload.go b/apps/backend/internal/launcher/launchctl_reload.go new file mode 100644 index 0000000000..2d132cafc1 --- /dev/null +++ b/apps/backend/internal/launcher/launchctl_reload.go @@ -0,0 +1,48 @@ +package launcher + +import ( + "fmt" + "time" +) + +const ( + launchdBootoutPollInterval = 100 * time.Millisecond + launchdBootoutPollAttempts = 50 + launchdBootstrapAttempts = 5 + launchdBootstrapRetryBase = 300 * time.Millisecond +) + +var ( + launchctlCommand = func(args ...string) error { + return runCommand("launchctl", args...) + } + launchctlSleep = time.Sleep +) + +func reloadLaunchdService(target, domain, plistPath string) error { + if err := bootoutLaunchdAndWait(target); err != nil { + return err + } + var lastErr error + for attempt := 1; attempt <= launchdBootstrapAttempts; attempt++ { + lastErr = launchctlCommand("bootstrap", domain, plistPath) + if lastErr == nil { + return nil + } + if attempt < launchdBootstrapAttempts { + launchctlSleep(launchdBootstrapRetryBase * time.Duration(attempt)) + } + } + return fmt.Errorf("launchctl bootstrap %s %s: %w", domain, plistPath, lastErr) +} + +func bootoutLaunchdAndWait(target string) error { + _ = launchctlCommand("bootout", target) + for attempt := 0; attempt < launchdBootoutPollAttempts; attempt++ { + if err := launchctlCommand("print", target); err != nil { + return nil + } + launchctlSleep(launchdBootoutPollInterval) + } + return fmt.Errorf("timed out waiting for launchctl bootout %s", target) +} diff --git a/apps/backend/internal/launcher/service.go b/apps/backend/internal/launcher/service.go index 975346d6d0..89d64a3f03 100644 --- a/apps/backend/internal/launcher/service.go +++ b/apps/backend/internal/launcher/service.go @@ -17,22 +17,25 @@ type serviceArgs struct { HomeDir string NoBootStart bool Follow bool + Intent string + DryRun 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" + actionInstall = "install" + actionUninstall = "uninstall" + actionRestart = "restart" + actionConfig = "config" + actionLogs = "logs" + actionStatus = "status" + actionStop = "stop" + actionSelfUpdate = "self-update" + flagHelp = "--help" + goosLinux = "linux" + goosDarwin = "darwin" + managedMarker = "managed by kandev" + serviceUnitName = "kandev.service" ) const serviceHelp = `kandev service — install kandev as an OS-managed service @@ -64,6 +67,13 @@ func runService(argv []string, build BuildInfo) int { printServiceConfig(args) return 0 } + if args.Action == actionSelfUpdate { + if err := runNativeSelfUpdate(args); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + return 0 + } switch runtime.GOOS { case goosLinux: return runLinuxService(args, build) @@ -91,12 +101,16 @@ func parseServiceArgs(argv []string) (serviceArgs, error) { } i = next } + if action == actionSelfUpdate && !out.ShowHelp && out.Intent == "" { + return out, ParseError{Message: "kandev service self-update requires --intent "} + } return out, nil } func validServiceAction(action string) bool { switch action { - case actionInstall, actionUninstall, string(CommandStart), actionStop, actionRestart, actionStatus, actionLogs, actionConfig: + case actionInstall, actionUninstall, string(CommandStart), actionStop, actionRestart, actionStatus, actionLogs, actionConfig, + actionSelfUpdate: return true default: return false @@ -114,6 +128,20 @@ func parseServiceFlag(argv []string, i int, action string, out *serviceArgs) (in out.NoBootStart = true case arg == "-f" || arg == "--follow": out.Follow = true + case arg == "--dry-run" && action == actionSelfUpdate: + out.DryRun = true + case arg == "--intent" && action == actionSelfUpdate: + value, err := takeValue(argv, i, "--intent") + if err != nil { + return i, err + } + out.Intent = value + return i + 1, nil + case strings.HasPrefix(arg, "--intent=") && action == actionSelfUpdate: + out.Intent = strings.TrimPrefix(arg, "--intent=") + if out.Intent == "" { + return i, ParseError{Message: "--intent requires a value"} + } case arg == "--port": return parseServicePort(argv, i, out) case strings.HasPrefix(arg, "--port="): @@ -203,21 +231,32 @@ func installSystemd(args serviceArgs, build BuildInfo, unitPath string) int { } 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{ + bundleDir := serviceBundleDir(self) + systemUser := serviceUser(args.System) + input := nativeServiceUnitInput{ Executable: self, HomeDir: homeDir, LogDir: logDir, Port: args.Port, System: args.System, - SystemUser: serviceUser(args.System), - BundleDir: serviceBundleDir(self), + SystemUser: systemUser, + NoBootStart: args.NoBootStart, + BundleDir: bundleDir, Version: serviceVersion(build.Version), PathPrefixes: serviceNodeToolBinDirs(), - }) + } + metadata := buildNativeServiceMetadata(nativeServiceManagerSystemd, args, input, unitPath) + prepared, err := prepareNativeServiceMetadata(metadata) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + defer func() { _ = prepared.Close() }() + if err := os.MkdirAll(filepath.Dir(unitPath), 0o755); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + unit := renderSystemdUnit(input) if err := backupUnmanagedServiceFile(unitPath); err != nil { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 @@ -226,6 +265,14 @@ func installSystemd(args serviceArgs, build BuildInfo, unitPath string) int { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 } + if err := prepared.Commit(); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + if err := prepared.Close(); 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 } @@ -293,26 +340,41 @@ func installLaunchd(args serviceArgs, build BuildInfo, plistPath, target, domain } 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{ + bundleDir := serviceBundleDir(self) + systemUser := serviceUser(args.System) + input := nativeServiceUnitInput{ Executable: self, HomeDir: homeDir, LogDir: logDir, Port: args.Port, System: args.System, - SystemUser: serviceUser(args.System), + SystemUser: systemUser, NoBootStart: args.NoBootStart, - BundleDir: serviceBundleDir(self), + BundleDir: bundleDir, Version: serviceVersion(build.Version), PathPrefixes: serviceNodeToolBinDirs(), - }) + } + metadata := buildNativeServiceMetadata(nativeServiceManagerLaunchd, args, input, plistPath) + prepared, err := prepareNativeServiceMetadata(metadata) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + defer func() { _ = prepared.Close() }() + if args.System { + if err := prepared.EnsureHomeDirectory("logs", 0o755); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + } else if err := os.MkdirAll(logDir, 0o755); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + plist := renderLaunchdPlist(input) if err := backupUnmanagedServiceFile(plistPath); err != nil { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 @@ -321,8 +383,15 @@ func installLaunchd(args serviceArgs, build BuildInfo, plistPath, target, domain fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 } - _ = runCommand("launchctl", "bootout", target) - if err := runCommand("launchctl", "bootstrap", domain, plistPath); err != nil { + if err := prepared.Commit(); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + if err := prepared.Close(); err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + if err := reloadLaunchdService(target, domain, plistPath); err != nil { return 1 } _ = runCommand("launchctl", "enable", target) @@ -448,6 +517,11 @@ func renderSystemdUnit(input nativeServiceUnitInput) string { serviceEnvLine("KANDEV_HOME_DIR", input.HomeDir), serviceEnvLine("KANDEV_LOG_LEVEL", "info"), serviceEnvLineAllowSpecifiers("PATH", pathValue), + serviceEnvLine("KANDEV_RUNNING_AS_SERVICE", "true"), + serviceEnvLine("KANDEV_SERVICE_MODE", nativeServiceMode(input.System)), + serviceEnvLine("KANDEV_SERVICE_MANAGER", nativeServiceManagerSystemd), + serviceEnvLine("KANDEV_INSTALL_KIND", nativeInstallKind(input.Executable, input.BundleDir)), + serviceEnvLine("KANDEV_SERVICE_METADATA", nativeServiceMetadataPath(input.HomeDir)), } if input.Port != 0 { env = append(env, serviceEnvLine("KANDEV_SERVER_PORT", fmt.Sprint(input.Port))) @@ -475,7 +549,16 @@ func renderSystemdUnit(input nativeServiceUnitInput) string { func renderLaunchdPlist(input nativeServiceUnitInput) string { pathValue := servicePathWithPrefixes(launchdServicePath, input.PathPrefixes) - envEntries := [][2]string{{"KANDEV_HOME_DIR", input.HomeDir}, {"KANDEV_LOG_LEVEL", "info"}, {"PATH", pathValue}} + envEntries := [][2]string{ + {"KANDEV_HOME_DIR", input.HomeDir}, + {"KANDEV_LOG_LEVEL", "info"}, + {"PATH", pathValue}, + {"KANDEV_RUNNING_AS_SERVICE", "true"}, + {"KANDEV_SERVICE_MODE", nativeServiceMode(input.System)}, + {"KANDEV_SERVICE_MANAGER", nativeServiceManagerLaunchd}, + {"KANDEV_INSTALL_KIND", nativeInstallKind(input.Executable, input.BundleDir)}, + {"KANDEV_SERVICE_METADATA", nativeServiceMetadataPath(input.HomeDir)}, + } if input.Port != 0 { envEntries = append(envEntries, [2]string{"KANDEV_SERVER_PORT", fmt.Sprint(input.Port)}) } diff --git a/apps/backend/internal/launcher/service_metadata.go b/apps/backend/internal/launcher/service_metadata.go new file mode 100644 index 0000000000..b450f8cfe3 --- /dev/null +++ b/apps/backend/internal/launcher/service_metadata.go @@ -0,0 +1,369 @@ +package launcher + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + nativeServiceMetadataVersion = 1 + nativeServiceManagerSystemd = "systemd" + nativeServiceManagerLaunchd = "launchd" + nativeServiceModeUser = "user" + nativeServiceModeSystem = "system" + nativeInstallKindHomebrew = "homebrew" + nativeInstallKindNPM = "npm" + nativeInstallKindNPX = "npx" + nativeInstallKindLocal = "local" +) + +type nativeServiceMetadata struct { + Version int `json:"version"` + Manager string `json:"manager"` + Mode string `json:"mode"` + Kind string `json:"kind"` + HomeDir string `json:"home_dir"` + LogDir string `json:"log_dir"` + ServicePath string `json:"service_path"` + LauncherPath string `json:"launcher_path"` + BundleDir string `json:"bundle_dir,omitempty"` + LauncherVersion string `json:"launcher_version,omitempty"` + Port int `json:"port,omitempty"` + SystemUser string `json:"system_user,omitempty"` + NoBootStart bool `json:"no_boot_start,omitempty"` + InstalledAt string `json:"installed_at"` +} + +func nativeServiceMetadataPath(homeDir string) string { + return filepath.Join(homeDir, "service", "install.json") +} + +func nativeServiceMode(system bool) string { + if system { + return nativeServiceModeSystem + } + return nativeServiceModeUser +} + +func nativeInstallKind(executable, bundleDir string) string { + paths := filepath.ToSlash(executable + "\n" + bundleDir) + switch { + case strings.Contains(paths, "/Cellar/kandev/"): + return nativeInstallKindHomebrew + case strings.Contains(paths, "/.npm/_npx/") || strings.Contains(paths, "/_npx/"): + return nativeInstallKindNPX + case strings.Contains(paths, "/node_modules/@kdlbs/runtime-") || + strings.Contains(paths, "/node_modules/kandev/"): + return nativeInstallKindNPM + default: + return nativeInstallKindLocal + } +} + +func buildNativeServiceMetadata( + manager string, + args serviceArgs, + input nativeServiceUnitInput, + servicePath string, +) nativeServiceMetadata { + return nativeServiceMetadata{ + Version: nativeServiceMetadataVersion, + Manager: manager, + Mode: nativeServiceMode(args.System), + Kind: nativeInstallKind(input.Executable, input.BundleDir), + HomeDir: input.HomeDir, + LogDir: input.LogDir, + ServicePath: servicePath, + LauncherPath: input.Executable, + BundleDir: input.BundleDir, + LauncherVersion: input.Version, + Port: input.Port, + SystemUser: input.SystemUser, + NoBootStart: input.NoBootStart, + InstalledAt: time.Now().UTC().Format(time.RFC3339Nano), + } +} + +func writeNativeServiceMetadata(metadata nativeServiceMetadata) error { + prepared, err := prepareNativeServiceMetadata(metadata) + if err != nil { + return err + } + defer func() { _ = prepared.Close() }() + if err := prepared.Commit(); err != nil { + return err + } + return prepared.Close() +} + +type preparedNativeServiceMetadata struct { + data []byte + homeRoot nativeMetadataRoot + serviceRoot nativeMetadataRoot + owner nativeServiceMetadataOwner +} + +func prepareNativeServiceMetadata(metadata nativeServiceMetadata) (*preparedNativeServiceMetadata, error) { + data, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal service metadata: %w", err) + } + homeRoot, err := openNativeMetadataHome(metadata) + if err != nil { + return nil, err + } + owner, err := resolveNativeServiceMetadataOwner(metadata) + if err != nil { + _ = homeRoot.Close() + return nil, err + } + serviceRoot, err := inspectNativeServiceMetadataRoot(homeRoot) + if err != nil { + _ = homeRoot.Close() + return nil, err + } + return &preparedNativeServiceMetadata{ + data: append(data, '\n'), + homeRoot: homeRoot, + serviceRoot: serviceRoot, + owner: owner, + }, nil +} + +func (p *preparedNativeServiceMetadata) Commit() error { + if p.serviceRoot == nil { + serviceRoot, err := createNativeServiceMetadataRoot(p.homeRoot) + if err != nil { + return err + } + p.serviceRoot = serviceRoot + } + return atomicWriteNativeServiceMetadata(p.serviceRoot, p.data, p.owner) +} + +func (p *preparedNativeServiceMetadata) EnsureHomeDirectory(name string, mode os.FileMode) error { + return ensureNativeMetadataDirectory(p.homeRoot, name, mode, p.owner) +} + +func (p *preparedNativeServiceMetadata) Close() error { + var closeErrs []error + if p.serviceRoot != nil { + closeErrs = append(closeErrs, p.serviceRoot.Close()) + p.serviceRoot = nil + } + if p.homeRoot != nil { + closeErrs = append(closeErrs, p.homeRoot.Close()) + p.homeRoot = nil + } + return errors.Join(closeErrs...) +} + +type nativeServiceMetadataOwner struct { + UID int + GID int + Enabled bool +} + +func resolveNativeServiceMetadataOwner(metadata nativeServiceMetadata) (nativeServiceMetadataOwner, error) { + if metadata.Mode != nativeServiceModeSystem { + return nativeServiceMetadataOwner{}, nil + } + if metadata.SystemUser == "" { + return nativeServiceMetadataOwner{}, fmt.Errorf("system service metadata missing system_user") + } + uid, gid, err := lookupNativeServiceOwner(metadata.SystemUser) + if err != nil { + return nativeServiceMetadataOwner{}, fmt.Errorf("resolve system service user %q: %w", metadata.SystemUser, err) + } + return nativeServiceMetadataOwner{UID: uid, GID: gid, Enabled: true}, nil +} + +func inspectNativeServiceMetadataRoot(homeRoot nativeMetadataRoot) (nativeMetadataRoot, error) { + mode, err := homeRoot.EntryMode("service") + switch { + case os.IsNotExist(err): + return nil, nil + case err != nil: + return nil, fmt.Errorf("inspect service metadata dir: %w", err) + case mode&os.ModeSymlink != 0: + return nil, fmt.Errorf("service metadata dir must not be a symlink") + case !mode.IsDir(): + return nil, fmt.Errorf("service metadata path is not a directory") + } + root, err := homeRoot.OpenRoot("service") + if err != nil { + return nil, fmt.Errorf("open service metadata dir: %w", err) + } + if err := rejectNativeMetadataSymlink(root); err != nil { + _ = root.Close() + return nil, err + } + return root, nil +} + +func createNativeServiceMetadataRoot(homeRoot nativeMetadataRoot) (nativeMetadataRoot, error) { + if err := homeRoot.Mkdir("service", 0o700); err != nil && !errors.Is(err, os.ErrExist) { + return nil, fmt.Errorf("create service metadata dir: %w", err) + } + root, err := inspectNativeServiceMetadataRoot(homeRoot) + if err != nil { + return nil, err + } + if root == nil { + return nil, fmt.Errorf("service metadata dir does not exist after creation") + } + return root, nil +} + +func ensureNativeMetadataDirectory( + parent nativeMetadataRoot, + name string, + mode os.FileMode, + owner nativeServiceMetadataOwner, +) error { + entryMode, err := parent.EntryMode(name) + switch { + case os.IsNotExist(err): + if err := parent.Mkdir(name, mode); err != nil { + return fmt.Errorf("create %s directory: %w", name, err) + } + case err != nil: + return fmt.Errorf("inspect %s directory: %w", name, err) + case entryMode&os.ModeSymlink != 0: + return fmt.Errorf("%s directory must not be a symlink", name) + case !entryMode.IsDir(): + return fmt.Errorf("%s path is not a directory", name) + } + root, err := parent.OpenRoot(name) + if err != nil { + return fmt.Errorf("open %s directory: %w", name, err) + } + defer func() { _ = root.Close() }() + dir, err := root.OpenSelf() + if err != nil { + return fmt.Errorf("open %s directory for ownership: %w", name, err) + } + defer func() { _ = dir.Close() }() + if err := secureNativeMetadataFile(dir, mode, owner); err != nil { + return fmt.Errorf("secure %s directory: %w", name, err) + } + return nil +} + +func atomicWriteNativeServiceMetadata( + serviceRoot nativeMetadataRoot, + data []byte, + owner nativeServiceMetadataOwner, +) error { + if err := rejectNativeMetadataSymlink(serviceRoot); err != nil { + return err + } + dir, err := serviceRoot.OpenSelf() + if err != nil { + return fmt.Errorf("open service metadata dir for ownership: %w", err) + } + if err := secureNativeMetadataFile(dir, 0o700, owner); err != nil { + _ = dir.Close() + return fmt.Errorf("secure service metadata dir: %w", err) + } + if err := dir.Close(); err != nil { + return fmt.Errorf("close service metadata dir: %w", err) + } + + tempName, err := nativeMetadataTempName() + if err != nil { + return err + } + file, err := serviceRoot.CreateExclusive(tempName, 0o600) + if err != nil { + return fmt.Errorf("create temporary service metadata: %w", err) + } + renamed := false + defer func() { + _ = file.Close() + if !renamed { + _ = serviceRoot.Remove(tempName) + } + }() + if err := writeAndSecureNativeMetadata(file, data, owner); err != nil { + return err + } + if err := file.Close(); err != nil { + return fmt.Errorf("close temporary service metadata: %w", err) + } + if err := serviceRoot.Rename(tempName, "install.json"); err != nil { + return fmt.Errorf("replace service metadata: %w", err) + } + renamed = true + return nil +} + +func rejectNativeMetadataSymlink(serviceRoot nativeMetadataRoot) error { + mode, err := serviceRoot.EntryMode("install.json") + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("inspect service metadata: %w", err) + } + if mode&os.ModeSymlink != 0 { + return fmt.Errorf("service metadata file must not be a symlink") + } + if !mode.IsRegular() { + return fmt.Errorf("service metadata path is not a regular file") + } + return nil +} + +func nativeMetadataTempName() (string, error) { + var suffix [12]byte + if _, err := rand.Read(suffix[:]); err != nil { + return "", fmt.Errorf("generate service metadata temp name: %w", err) + } + return ".install.json.tmp-" + hex.EncodeToString(suffix[:]), nil +} + +func writeAndSecureNativeMetadata( + file *os.File, + data []byte, + owner nativeServiceMetadataOwner, +) error { + written, err := file.Write(data) + if err != nil { + return fmt.Errorf("write temporary service metadata: %w", err) + } + if written != len(data) { + return fmt.Errorf("write temporary service metadata: %w", io.ErrShortWrite) + } + if err := secureNativeMetadataFile(file, 0o600, owner); err != nil { + return fmt.Errorf("secure temporary service metadata: %w", err) + } + if err := file.Sync(); err != nil { + return fmt.Errorf("sync temporary service metadata: %w", err) + } + return nil +} + +func secureNativeMetadataFile( + file *os.File, + mode os.FileMode, + owner nativeServiceMetadataOwner, +) error { + if err := file.Chmod(mode); err != nil { + return err + } + if owner.Enabled { + if err := chownNativeServiceMetadata(file, owner.UID, owner.GID); err != nil { + return err + } + } + return nil +} diff --git a/apps/backend/internal/launcher/service_metadata_owner_unix.go b/apps/backend/internal/launcher/service_metadata_owner_unix.go new file mode 100644 index 0000000000..4e2ec740c6 --- /dev/null +++ b/apps/backend/internal/launcher/service_metadata_owner_unix.go @@ -0,0 +1,32 @@ +//go:build !windows + +package launcher + +import ( + "os" + "os/user" + "strconv" +) + +var ( + lookupNativeServiceOwner = defaultLookupNativeServiceOwner + chownNativeServiceMetadata = func(file *os.File, uid, gid int) error { + return file.Chown(uid, gid) + } +) + +func defaultLookupNativeServiceOwner(username string) (int, int, error) { + account, err := user.Lookup(username) + if err != nil { + return 0, 0, err + } + uid, err := strconv.Atoi(account.Uid) + if err != nil { + return 0, 0, err + } + gid, err := strconv.Atoi(account.Gid) + if err != nil { + return 0, 0, err + } + return uid, gid, nil +} diff --git a/apps/backend/internal/launcher/service_metadata_owner_windows.go b/apps/backend/internal/launcher/service_metadata_owner_windows.go new file mode 100644 index 0000000000..4a791510ad --- /dev/null +++ b/apps/backend/internal/launcher/service_metadata_owner_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package launcher + +import ( + "fmt" + "os" +) + +var ( + lookupNativeServiceOwner = func(string) (int, int, error) { + return 0, 0, fmt.Errorf("system service metadata ownership is unsupported on windows") + } + chownNativeServiceMetadata = func(*os.File, int, int) error { + return fmt.Errorf("system service metadata ownership is unsupported on windows") + } +) diff --git a/apps/backend/internal/launcher/service_metadata_root.go b/apps/backend/internal/launcher/service_metadata_root.go new file mode 100644 index 0000000000..3131494c32 --- /dev/null +++ b/apps/backend/internal/launcher/service_metadata_root.go @@ -0,0 +1,75 @@ +package launcher + +import ( + "fmt" + "os" +) + +type nativeMetadataRoot interface { + Close() error + EntryMode(name string) (os.FileMode, error) + Mkdir(name string, perm os.FileMode) error + OpenRoot(name string) (nativeMetadataRoot, error) + OpenSelf() (*os.File, error) + CreateExclusive(name string, perm os.FileMode) (*os.File, error) + Rename(oldName, newName string) error + Remove(name string) error +} + +type portableNativeMetadataRoot struct { + root *os.Root +} + +func openNativeMetadataHome(metadata nativeServiceMetadata) (nativeMetadataRoot, error) { + if metadata.Mode == nativeServiceModeSystem { + return openSystemNativeMetadataHome(metadata.HomeDir) + } + if err := os.MkdirAll(metadata.HomeDir, 0o700); err != nil { + return nil, fmt.Errorf("create service metadata home: %w", err) + } + root, err := os.OpenRoot(metadata.HomeDir) + if err != nil { + return nil, fmt.Errorf("open service metadata home: %w", err) + } + return &portableNativeMetadataRoot{root: root}, nil +} + +func (r *portableNativeMetadataRoot) Close() error { + return r.root.Close() +} + +func (r *portableNativeMetadataRoot) EntryMode(name string) (os.FileMode, error) { + info, err := r.root.Lstat(name) + if err != nil { + return 0, err + } + return info.Mode(), nil +} + +func (r *portableNativeMetadataRoot) Mkdir(name string, perm os.FileMode) error { + return r.root.Mkdir(name, perm) +} + +func (r *portableNativeMetadataRoot) OpenRoot(name string) (nativeMetadataRoot, error) { + root, err := r.root.OpenRoot(name) + if err != nil { + return nil, err + } + return &portableNativeMetadataRoot{root: root}, nil +} + +func (r *portableNativeMetadataRoot) OpenSelf() (*os.File, error) { + return r.root.Open(".") +} + +func (r *portableNativeMetadataRoot) CreateExclusive(name string, perm os.FileMode) (*os.File, error) { + return r.root.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, perm) +} + +func (r *portableNativeMetadataRoot) Rename(oldName, newName string) error { + return r.root.Rename(oldName, newName) +} + +func (r *portableNativeMetadataRoot) Remove(name string) error { + return r.root.Remove(name) +} diff --git a/apps/backend/internal/launcher/service_metadata_root_other.go b/apps/backend/internal/launcher/service_metadata_root_other.go new file mode 100644 index 0000000000..41cc4638d8 --- /dev/null +++ b/apps/backend/internal/launcher/service_metadata_root_other.go @@ -0,0 +1,9 @@ +//go:build !unix + +package launcher + +import "fmt" + +func openSystemNativeMetadataHome(string) (nativeMetadataRoot, error) { + return nil, fmt.Errorf("system service metadata confinement is unsupported on this platform") +} diff --git a/apps/backend/internal/launcher/service_metadata_root_unix.go b/apps/backend/internal/launcher/service_metadata_root_unix.go new file mode 100644 index 0000000000..b854f15d4e --- /dev/null +++ b/apps/backend/internal/launcher/service_metadata_root_unix.go @@ -0,0 +1,158 @@ +//go:build unix + +package launcher + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "golang.org/x/sys/unix" +) + +const nativeMetadataDirectoryOpenFlags = unix.O_RDONLY | unix.O_DIRECTORY | unix.O_NOFOLLOW | unix.O_CLOEXEC + +type unixNativeMetadataRoot struct { + dir *os.File +} + +func openSystemNativeMetadataHome(homeDir string) (nativeMetadataRoot, error) { + if !filepath.IsAbs(homeDir) { + return nil, fmt.Errorf("system service metadata home must be absolute") + } + clean := canonicalSystemMetadataHome(runtime.GOOS, homeDir) + rootFD, err := unix.Open(string(filepath.Separator), nativeMetadataDirectoryOpenFlags, 0) + if err != nil { + return nil, fmt.Errorf("open filesystem root: %w", err) + } + current := os.NewFile(uintptr(rootFD), string(filepath.Separator)) + if current == nil { + _ = unix.Close(rootFD) + return nil, fmt.Errorf("open filesystem root: invalid directory handle") + } + for _, component := range systemMetadataPathComponents(clean) { + next, err := openSystemMetadataDir(current, component) + if err != nil { + _ = current.Close() + return nil, err + } + _ = current.Close() + current = next + } + return &unixNativeMetadataRoot{dir: current}, nil +} + +func canonicalSystemMetadataHome(goos, homeDir string) string { + clean := filepath.Clean(homeDir) + if goos == goosDarwin && (clean == "/var" || strings.HasPrefix(clean, "/var/")) { + return "/private" + clean + } + return clean +} + +func systemMetadataPathComponents(path string) []string { + trimmed := strings.TrimPrefix(filepath.ToSlash(path), "/") + if trimmed == "" { + return nil + } + return strings.Split(trimmed, "/") +} + +func openSystemMetadataDir(parent *os.File, name string) (*os.File, error) { + fd, err := unix.Openat(int(parent.Fd()), name, nativeMetadataDirectoryOpenFlags, 0) + if errors.Is(err, unix.ENOENT) { + return nil, fmt.Errorf( + "system metadata home component %q does not exist; pre-create and chown the system home before installing", + name, + ) + } + if err != nil { + return nil, fmt.Errorf("open system metadata path component %q without symlinks: %w", name, err) + } + file := os.NewFile(uintptr(fd), name) + if file == nil { + _ = unix.Close(fd) + return nil, fmt.Errorf("open system metadata path component %q: invalid directory handle", name) + } + return file, nil +} + +func (r *unixNativeMetadataRoot) Close() error { + return r.dir.Close() +} + +func (r *unixNativeMetadataRoot) EntryMode(name string) (os.FileMode, error) { + var stat unix.Stat_t + if err := unix.Fstatat(int(r.dir.Fd()), name, &stat, unix.AT_SYMLINK_NOFOLLOW); err != nil { + return 0, &os.PathError{Op: "fstatat", Path: name, Err: err} + } + return unixStatMode(stat.Mode), nil +} + +func unixStatMode[T ~uint16 | ~uint32](rawMode T) os.FileMode { + modeBits := uint32(rawMode) + mode := os.FileMode(modeBits & 0o777) + switch modeBits & unix.S_IFMT { + case unix.S_IFDIR: + mode |= os.ModeDir + case unix.S_IFLNK: + mode |= os.ModeSymlink + } + return mode +} + +func (r *unixNativeMetadataRoot) Mkdir(name string, perm os.FileMode) error { + return unix.Mkdirat(int(r.dir.Fd()), name, uint32(perm.Perm())) +} + +func (r *unixNativeMetadataRoot) OpenRoot(name string) (nativeMetadataRoot, error) { + fd, err := unix.Openat(int(r.dir.Fd()), name, nativeMetadataDirectoryOpenFlags, 0) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(fd), name) + if file == nil { + _ = unix.Close(fd) + return nil, fmt.Errorf("open metadata directory %q: invalid directory handle", name) + } + return &unixNativeMetadataRoot{dir: file}, nil +} + +func (r *unixNativeMetadataRoot) OpenSelf() (*os.File, error) { + fd, err := unix.Openat(int(r.dir.Fd()), ".", nativeMetadataDirectoryOpenFlags, 0) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(fd), r.dir.Name()) + if file == nil { + _ = unix.Close(fd) + return nil, fmt.Errorf("duplicate metadata directory: invalid directory handle") + } + return file, nil +} + +func (r *unixNativeMetadataRoot) CreateExclusive(name string, perm os.FileMode) (*os.File, error) { + flags := unix.O_CREAT | unix.O_EXCL | unix.O_WRONLY | unix.O_NOFOLLOW | unix.O_CLOEXEC + fd, err := unix.Openat(int(r.dir.Fd()), name, flags, uint32(perm.Perm())) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(fd), name) + if file == nil { + _ = unix.Close(fd) + return nil, fmt.Errorf("create metadata file %q: invalid file handle", name) + } + return file, nil +} + +func (r *unixNativeMetadataRoot) Rename(oldName, newName string) error { + fd := int(r.dir.Fd()) + return unix.Renameat(fd, oldName, fd, newName) +} + +func (r *unixNativeMetadataRoot) Remove(name string) error { + return unix.Unlinkat(int(r.dir.Fd()), name, 0) +} diff --git a/apps/backend/internal/launcher/service_metadata_root_unix_test.go b/apps/backend/internal/launcher/service_metadata_root_unix_test.go new file mode 100644 index 0000000000..f271c390db --- /dev/null +++ b/apps/backend/internal/launcher/service_metadata_root_unix_test.go @@ -0,0 +1,53 @@ +//go:build unix + +package launcher + +import "testing" + +func TestCanonicalSystemMetadataHome(t *testing.T) { + tests := []struct { + name string + goos string + homeDir string + want string + }{ + { + name: "darwin var root", + goos: goosDarwin, + homeDir: "/var", + want: "/private/var", + }, + { + name: "darwin var descendant", + goos: goosDarwin, + homeDir: "/var/lib/kandev", + want: "/private/var/lib/kandev", + }, + { + name: "darwin non-prefix", + goos: goosDarwin, + homeDir: "/various/lib/kandev", + want: "/various/lib/kandev", + }, + { + name: "darwin cleaned traversal", + goos: goosDarwin, + homeDir: "/tmp/../var/lib/../lib/kandev", + want: "/private/var/lib/kandev", + }, + { + name: "linux identity", + goos: goosLinux, + homeDir: "/var/lib/../lib/kandev", + want: "/var/lib/kandev", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := canonicalSystemMetadataHome(tt.goos, tt.homeDir); got != tt.want { + t.Fatalf("canonicalSystemMetadataHome(%q, %q) = %q, want %q", tt.goos, tt.homeDir, got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/launcher/service_native_update_test.go b/apps/backend/internal/launcher/service_native_update_test.go new file mode 100644 index 0000000000..8e4e31298c --- /dev/null +++ b/apps/backend/internal/launcher/service_native_update_test.go @@ -0,0 +1,1046 @@ +package launcher + +//revive:disable:file-length-limit // Native update and privileged install regression coverage is scenario-heavy. + +import ( + "encoding/json" + "errors" + "os" + "os/user" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "testing" + "time" +) + +func TestNativeServiceRenderersIncludeManagedIdentity(t *testing.T) { + tests := []struct { + name string + text string + wants []string + }{ + { + name: "systemd", + text: renderSystemdUnit(nativeServiceUnitInput{ + Executable: "/usr/local/lib/node_modules/@kdlbs/runtime-linux-x64/bin/kandev", + HomeDir: "/home/alice/.kandev", + LogDir: "/home/alice/.kandev/logs", + }), + wants: []string{ + "Environment=KANDEV_RUNNING_AS_SERVICE=true", + "Environment=KANDEV_SERVICE_MODE=user", + "Environment=KANDEV_SERVICE_MANAGER=systemd", + "Environment=KANDEV_INSTALL_KIND=npm", + "Environment=KANDEV_SERVICE_METADATA=/home/alice/.kandev/service/install.json", + }, + }, + { + name: "launchd", + text: renderLaunchdPlist(nativeServiceUnitInput{ + Executable: "/opt/homebrew/Cellar/kandev/1.2.3/libexec/bin/kandev", + HomeDir: "/Users/alice/.kandev", + LogDir: "/Users/alice/.kandev/logs", + }), + wants: []string{ + "KANDEV_RUNNING_AS_SERVICE\n true", + "KANDEV_SERVICE_MODE\n user", + "KANDEV_SERVICE_MANAGER\n launchd", + "KANDEV_INSTALL_KIND\n homebrew", + "KANDEV_SERVICE_METADATA\n /Users/alice/.kandev/service/install.json", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, want := range tt.wants { + if !strings.Contains(tt.text, want) { + t.Fatalf("rendered service missing %q:\n%s", want, tt.text) + } + } + }) + } +} + +func TestInstallSystemdWritesOwnerOnlyNativeMetadata(t *testing.T) { + originalExecutablePath := executablePath + originalExecuteServiceCommand := executeServiceCommand + originalServicePrintln := servicePrintln + t.Cleanup(func() { + executablePath = originalExecutablePath + executeServiceCommand = originalExecuteServiceCommand + servicePrintln = originalServicePrintln + }) + + executable := "/opt/homebrew/Cellar/kandev/1.2.3/libexec/bin/kandev" + executablePath = func() (string, error) { return executable, nil } + executeServiceCommand = func(string, ...string) error { return nil } + servicePrintln = func(string) {} + currentUser, err := user.Current() + if err != nil { + t.Fatal(err) + } + t.Setenv("SUDO_USER", currentUser.Username) + + tmp := t.TempDir() + homeDir := filepath.Join(tmp, "home") + if err := os.Mkdir(homeDir, 0o700); err != nil { + t.Fatal(err) + } + unitPath := filepath.Join(tmp, "kandev.service") + code := installSystemd( + serviceArgs{ + Action: actionInstall, + System: true, + HomeDir: homeDir, + Port: 38429, + NoBootStart: true, + }, + BuildInfo{Version: "1.2.3"}, + unitPath, + ) + if code != 0 { + t.Fatalf("installSystemd() = %d, want 0", code) + } + + metadataPath := filepath.Join(homeDir, "service", "install.json") + assertNativeInstallMetadata(t, metadataPath, map[string]interface{}{ + "version": float64(1), + "manager": "systemd", + "mode": "system", + "kind": "homebrew", + "home_dir": homeDir, + "log_dir": filepath.Join(homeDir, "logs"), + "service_path": unitPath, + "launcher_path": executable, + "bundle_dir": "/opt/homebrew/Cellar/kandev/1.2.3/libexec", + "launcher_version": "1.2.3", + "port": float64(38429), + "system_user": currentUser.Username, + "no_boot_start": true, + }) +} + +func TestInstallLaunchdWritesNativeMetadata(t *testing.T) { + originalExecutablePath := executablePath + originalExecuteServiceCommand := executeServiceCommand + originalServicePrintln := servicePrintln + t.Cleanup(func() { + executablePath = originalExecutablePath + executeServiceCommand = originalExecuteServiceCommand + servicePrintln = originalServicePrintln + }) + + executable := "/Users/alice/.npm/_npx/abc/node_modules/@kdlbs/runtime-darwin-arm64/bin/kandev" + executablePath = func() (string, error) { return executable, nil } + executeServiceCommand = func(name string, args ...string) error { + if name == "launchctl" && len(args) > 0 && args[0] == "print" { + return errors.New("not loaded") + } + return nil + } + servicePrintln = func(string) {} + + tmp := t.TempDir() + homeDir := filepath.Join(tmp, "home") + plistPath := filepath.Join(tmp, "com.kdlbs.kandev.plist") + code := installLaunchd( + serviceArgs{Action: actionInstall, HomeDir: homeDir, NoBootStart: true}, + BuildInfo{Version: "1.2.3"}, + plistPath, + "gui/501/com.kdlbs.kandev", + "gui/501", + ) + if code != 0 { + t.Fatalf("installLaunchd() = %d, want 0", code) + } + + assertNativeInstallMetadata(t, filepath.Join(homeDir, "service", "install.json"), map[string]interface{}{ + "version": float64(1), + "manager": "launchd", + "mode": "user", + "kind": "npx", + "home_dir": homeDir, + "log_dir": filepath.Join(homeDir, "logs"), + "service_path": plistPath, + "launcher_path": executable, + "bundle_dir": filepath.Dir(filepath.Dir(executable)), + "launcher_version": "1.2.3", + "no_boot_start": true, + }) +} + +func TestInstallLaunchdWaitsForBootoutAndRetriesBootstrap(t *testing.T) { + originalExecutablePath := executablePath + originalExecuteServiceCommand := executeServiceCommand + originalServicePrintln := servicePrintln + originalLaunchctlCommand := launchctlCommand + originalLaunchctlSleep := launchctlSleep + t.Cleanup(func() { + executablePath = originalExecutablePath + executeServiceCommand = originalExecuteServiceCommand + servicePrintln = originalServicePrintln + launchctlCommand = originalLaunchctlCommand + launchctlSleep = originalLaunchctlSleep + }) + + executablePath = func() (string, error) { return "/opt/kandev/bin/kandev", nil } + executeServiceCommand = func(string, ...string) error { return nil } + servicePrintln = func(string) {} + printCalls := 0 + bootstrapCalls := 0 + var order []string + launchctlSleep = func(time.Duration) {} + launchctlCommand = func(args ...string) error { + order = append(order, args[0]) + switch args[0] { + case "print": + printCalls++ + if printCalls >= 3 { + return errors.New("not loaded") + } + case "bootstrap": + bootstrapCalls++ + if bootstrapCalls < 3 { + return errors.New("Bootstrap failed: 5: Input/output error") + } + } + return nil + } + + tmp := t.TempDir() + code := installLaunchd( + serviceArgs{Action: actionInstall, HomeDir: filepath.Join(tmp, "home")}, + BuildInfo{Version: "test"}, + filepath.Join(tmp, "com.kdlbs.kandev.plist"), + "gui/501/com.kdlbs.kandev", + "gui/501", + ) + if code != 0 { + t.Fatalf("installLaunchd() = %d, want 0; order=%v", code, order) + } + if printCalls != 3 || bootstrapCalls != 3 { + t.Fatalf("print calls=%d bootstrap calls=%d, want 3 each; order=%v", printCalls, bootstrapCalls, order) + } + firstBootstrap := slices.Index(order, "bootstrap") + lastPrint := firstBootstrap - 1 + if firstBootstrap < 0 || lastPrint < 0 || order[lastPrint] != "print" { + t.Fatalf("bootstrap started before teardown polling completed: %v", order) + } +} + +func TestReloadLaunchdServiceStopsWhenBootoutTimesOut(t *testing.T) { + originalLaunchctlCommand := launchctlCommand + originalLaunchctlSleep := launchctlSleep + t.Cleanup(func() { + launchctlCommand = originalLaunchctlCommand + launchctlSleep = originalLaunchctlSleep + }) + + bootstrapCalls := 0 + launchctlSleep = func(time.Duration) {} + launchctlCommand = func(args ...string) error { + if args[0] == "bootstrap" { + bootstrapCalls++ + } + return nil + } + + err := reloadLaunchdService( + "gui/501/com.kdlbs.kandev", + "gui/501", + "/Users/alice/Library/LaunchAgents/com.kdlbs.kandev.plist", + ) + if err == nil || !strings.Contains(err.Error(), "timed out waiting for launchctl bootout") { + t.Fatalf("reloadLaunchdService() error = %v, want bootout timeout", err) + } + if bootstrapCalls != 0 { + t.Fatalf("bootstrap called %d times after bootout timeout, want 0", bootstrapCalls) + } +} + +func TestNativeServiceSelfUpdateRunsSupportedPlans(t *testing.T) { + originalExecuteServiceCommand := executeServiceCommand + t.Cleanup(func() { executeServiceCommand = originalExecuteServiceCommand }) + + tests := []struct { + name string + install map[string]interface{} + expected []string + }{ + { + name: "homebrew", + install: map[string]interface{}{ + "manager": "launchd", "mode": "user", "kind": "homebrew", + "home_dir": "/home/alice/.kandev", "launcher_path": "/opt/homebrew/Cellar/kandev/1.2.2/libexec/bin/kandev", + "port": 38429, "no_boot_start": true, + }, + expected: []string{ + "brew upgrade kandev", + "kandev service install --home-dir /home/alice/.kandev --port 38429 --no-boot-start", + "launchctl kickstart -k gui/" + strconv.Itoa(os.Getuid()) + "/com.kdlbs.kandev", + }, + }, + { + name: "npm", + install: map[string]interface{}{ + "manager": "systemd", "mode": "user", "kind": "npm", + "home_dir": "/home/alice/.kandev", + "launcher_path": "/usr/local/lib/node_modules/@kdlbs/runtime-linux-x64/bin/kandev", + "no_boot_start": true, + }, + expected: []string{ + "npm install -g --prefix /usr/local kandev@1.2.3", + "/usr/local/lib/node_modules/@kdlbs/runtime-linux-x64/bin/kandev service install --home-dir /home/alice/.kandev --no-boot-start", + "systemctl --user restart kandev.service", + }, + }, + { + name: "npx", + install: map[string]interface{}{ + "manager": "systemd", "mode": "user", "kind": "npx", + "home_dir": "/home/alice/.kandev", + "launcher_path": "/home/alice/.npm/_npx/abc/node_modules/@kdlbs/runtime-linux-x64/bin/kandev", + }, + expected: []string{ + "npx -y kandev@1.2.3 service install --home-dir /home/alice/.kandev", + "systemctl --user restart kandev.service", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + intentPath := writeNativeUpdateIntent(t, tt.install) + var commands []string + executeServiceCommand = func(name string, args ...string) error { + commands = append(commands, name+" "+strings.Join(args, " ")) + return nil + } + + if code := runService([]string{"self-update", "--intent", intentPath}, BuildInfo{}); code != 0 { + t.Fatalf("runService(self-update) = %d, want 0", code) + } + if !slices.Equal(commands, tt.expected) { + t.Fatalf("commands = %#v, want %#v", commands, tt.expected) + } + }) + } +} + +func TestNativeServiceSelfUpdateClearsStaleLauncherEnvironment(t *testing.T) { + originalExecuteServiceCommand := executeServiceCommand + t.Cleanup(func() { executeServiceCommand = originalExecuteServiceCommand }) + t.Setenv("KANDEV_VERSION", "0.81.0") + t.Setenv("KANDEV_BUNDLE_DIR", "/old/kandev/libexec") + + tests := []struct { + name string + install map[string]interface{} + reinstallCommand string + }{ + { + name: "homebrew wrapper can provide fresh values", + install: map[string]interface{}{ + "manager": "systemd", "mode": "user", "kind": "homebrew", + "home_dir": "/home/alice/.kandev", "launcher_path": "/opt/homebrew/Cellar/kandev/0.81.0/libexec/bin/kandev", + }, + reinstallCommand: "kandev", + }, + { + name: "npm native launcher", + install: map[string]interface{}{ + "manager": "systemd", "mode": "user", "kind": "npm", + "home_dir": "/home/alice/.kandev", + "launcher_path": "/usr/local/lib/node_modules/@kdlbs/runtime-linux-x64/bin/kandev", + }, + reinstallCommand: "/usr/local/lib/node_modules/@kdlbs/runtime-linux-x64/bin/kandev", + }, + { + name: "npx package launcher", + install: map[string]interface{}{ + "manager": "systemd", "mode": "user", "kind": "npx", + "home_dir": "/home/alice/.kandev", + "launcher_path": "/home/alice/.npm/_npx/abc/node_modules/@kdlbs/runtime-linux-x64/bin/kandev", + }, + reinstallCommand: "npx", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + intentPath := writeNativeUpdateIntent(t, tt.install) + var staleVersion, staleBundle string + executeServiceCommand = func(name string, _ ...string) error { + if name == tt.reinstallCommand { + staleVersion = os.Getenv("KANDEV_VERSION") + staleBundle = os.Getenv("KANDEV_BUNDLE_DIR") + } + return nil + } + + if code := runService([]string{"self-update", "--intent", intentPath}, BuildInfo{}); code != 0 { + t.Fatalf("runService(self-update) = %d, want 0", code) + } + if staleVersion != "" || staleBundle != "" { + t.Fatalf("reinstall inherited stale launcher env: version=%q bundle=%q", staleVersion, staleBundle) + } + if got := os.Getenv("KANDEV_VERSION"); got != "0.81.0" { + t.Fatalf("KANDEV_VERSION was not restored, got %q", got) + } + if got := os.Getenv("KANDEV_BUNDLE_DIR"); got != "/old/kandev/libexec" { + t.Fatalf("KANDEV_BUNDLE_DIR was not restored, got %q", got) + } + }) + } +} + +func TestInstallSystemdRejectsSymlinkedMetadataPathsBeforeReload(t *testing.T) { + currentUser, err := user.Current() + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + setup func(t *testing.T, homeDir, outside string) + }{ + { + name: "service directory", + setup: func(t *testing.T, homeDir, outside string) { + t.Helper() + if err := os.Symlink(outside, filepath.Join(homeDir, "service")); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "install json", + setup: func(t *testing.T, homeDir, outside string) { + t.Helper() + serviceDir := filepath.Join(homeDir, "service") + if err := os.Mkdir(serviceDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(outside, "escaped.json"), filepath.Join(serviceDir, "install.json")); err != nil { + t.Fatal(err) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + originalExecutablePath := executablePath + originalExecuteServiceCommand := executeServiceCommand + originalServicePrintln := servicePrintln + t.Cleanup(func() { + executablePath = originalExecutablePath + executeServiceCommand = originalExecuteServiceCommand + servicePrintln = originalServicePrintln + }) + + tmp := t.TempDir() + homeDir := filepath.Join(tmp, "home") + outside := filepath.Join(tmp, "outside") + if err := os.MkdirAll(homeDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(outside, 0o700); err != nil { + t.Fatal(err) + } + tt.setup(t, homeDir, outside) + t.Setenv("SUDO_USER", currentUser.Username) + + executablePath = func() (string, error) { return "/opt/kandev/bin/kandev", nil } + servicePrintln = func(string) {} + var managerCommands []string + executeServiceCommand = func(name string, args ...string) error { + managerCommands = append(managerCommands, name+" "+strings.Join(args, " ")) + return nil + } + + code := installSystemd( + serviceArgs{Action: actionInstall, System: true, HomeDir: homeDir}, + BuildInfo{Version: "test"}, + filepath.Join(tmp, "kandev.service"), + ) + if code == 0 { + t.Fatalf("installSystemd() = 0 for symlinked metadata path") + } + if len(managerCommands) != 0 { + t.Fatalf("manager commands ran after metadata failure: %v", managerCommands) + } + if _, err := os.Stat(filepath.Join(outside, "install.json")); !os.IsNotExist(err) { + t.Fatalf("service symlink escape was written, stat err=%v", err) + } + if _, err := os.Stat(filepath.Join(outside, "escaped.json")); !os.IsNotExist(err) { + t.Fatalf("install.json symlink escape was written, stat err=%v", err) + } + }) + } +} + +func TestInstallSystemdRejectsSymlinkedHomeBeforeMetadataMutation(t *testing.T) { + originalExecutablePath := executablePath + originalExecuteServiceCommand := executeServiceCommand + originalServicePrintln := servicePrintln + originalLookup := lookupNativeServiceOwner + originalChown := chownNativeServiceMetadata + t.Cleanup(func() { + executablePath = originalExecutablePath + executeServiceCommand = originalExecuteServiceCommand + servicePrintln = originalServicePrintln + lookupNativeServiceOwner = originalLookup + chownNativeServiceMetadata = originalChown + }) + + tmp := t.TempDir() + outside := filepath.Join(tmp, "outside") + outsideService := filepath.Join(outside, "service") + if err := os.MkdirAll(outsideService, 0o755); err != nil { + t.Fatal(err) + } + homeDir := filepath.Join(tmp, "linked-home") + if err := os.Symlink(outside, homeDir); err != nil { + t.Fatal(err) + } + + lookupNativeServiceOwner = func(string) (int, int, error) { return 1234, 5678, nil } + chownCalls := 0 + chownNativeServiceMetadata = func(*os.File, int, int) error { + chownCalls++ + return nil + } + executablePath = func() (string, error) { return "/opt/kandev/bin/kandev", nil } + servicePrintln = func(string) {} + var managerCommands []string + executeServiceCommand = func(name string, args ...string) error { + managerCommands = append(managerCommands, name+" "+strings.Join(args, " ")) + return nil + } + t.Setenv("SUDO_USER", "alice") + + code := installSystemd( + serviceArgs{Action: actionInstall, System: true, HomeDir: homeDir}, + BuildInfo{Version: "test"}, + filepath.Join(tmp, "kandev.service"), + ) + if code == 0 { + t.Fatalf("installSystemd() = 0 for symlinked home dir") + } + if len(managerCommands) != 0 { + t.Fatalf("manager commands ran after unsafe home rejection: %v", managerCommands) + } + if chownCalls != 0 { + t.Fatalf("ownership changed through unsafe home: chown calls=%d", chownCalls) + } + if mode := mustStat(t, outsideService).Mode().Perm(); mode != 0o755 { + t.Fatalf("outside service dir mode = %o, want unchanged 755", mode) + } + if _, err := os.Stat(filepath.Join(outsideService, "install.json")); !os.IsNotExist(err) { + t.Fatalf("metadata escaped through home symlink, stat err=%v", err) + } +} + +func TestInstallSystemdRejectsMissingSystemHomeWithoutCreatingIt(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("system service metadata is unsupported on Windows") + } + t.Run("home walker returns actionable error", func(t *testing.T) { + missingParent := filepath.Join(t.TempDir(), "missing-parent") + homeDir := filepath.Join(missingParent, "kandev-home") + root, err := openSystemNativeMetadataHome(homeDir) + if root != nil { + _ = root.Close() + } + if err == nil { + t.Fatalf("openSystemNativeMetadataHome() succeeded for missing home") + } + if !strings.Contains(err.Error(), "pre-create") || !strings.Contains(err.Error(), "chown") { + t.Fatalf("error = %q, want actionable pre-create/chown guidance", err) + } + if _, statErr := os.Stat(missingParent); !os.IsNotExist(statErr) { + t.Fatalf("missing system home path was partially created, stat err=%v", statErr) + } + }) + + t.Run("install stops before manager reload", func(t *testing.T) { + originalExecutablePath := executablePath + originalExecuteServiceCommand := executeServiceCommand + originalServicePrintln := servicePrintln + originalLookup := lookupNativeServiceOwner + originalChown := chownNativeServiceMetadata + t.Cleanup(func() { + executablePath = originalExecutablePath + executeServiceCommand = originalExecuteServiceCommand + servicePrintln = originalServicePrintln + lookupNativeServiceOwner = originalLookup + chownNativeServiceMetadata = originalChown + }) + + missingParent := filepath.Join(t.TempDir(), "missing-parent") + homeDir := filepath.Join(missingParent, "kandev-home") + executablePath = func() (string, error) { return "/opt/kandev/bin/kandev", nil } + servicePrintln = func(string) {} + lookupNativeServiceOwner = func(string) (int, int, error) { return 1234, 5678, nil } + chownNativeServiceMetadata = func(*os.File, int, int) error { return nil } + var managerCommands []string + executeServiceCommand = func(name string, args ...string) error { + managerCommands = append(managerCommands, name+" "+strings.Join(args, " ")) + return nil + } + t.Setenv("SUDO_USER", "alice") + + code := installSystemd( + serviceArgs{Action: actionInstall, System: true, HomeDir: homeDir}, + BuildInfo{Version: "test"}, + filepath.Join(t.TempDir(), "kandev.service"), + ) + if code == 0 { + t.Fatalf("installSystemd() = 0 for missing system home") + } + if len(managerCommands) != 0 { + t.Fatalf("manager commands ran after missing home rejection: %v", managerCommands) + } + if _, err := os.Stat(missingParent); !os.IsNotExist(err) { + t.Fatalf("missing system home path was partially created, stat err=%v", err) + } + }) +} + +func TestInstallSystemdPreflightPreservesDefinitionForUnsafeSystemHome(t *testing.T) { + originalExecutablePath := executablePath + originalExecuteServiceCommand := executeServiceCommand + originalServicePrintln := servicePrintln + originalLookup := lookupNativeServiceOwner + originalChown := chownNativeServiceMetadata + t.Cleanup(func() { + executablePath = originalExecutablePath + executeServiceCommand = originalExecuteServiceCommand + servicePrintln = originalServicePrintln + lookupNativeServiceOwner = originalLookup + chownNativeServiceMetadata = originalChown + }) + + tests := []struct { + name string + setup func(t *testing.T, tmp string) (homeDir, untouchedRoot string) + }{ + { + name: "missing home", + setup: func(t *testing.T, tmp string) (string, string) { + t.Helper() + missingRoot := filepath.Join(tmp, "missing-parent") + return filepath.Join(missingRoot, "home"), missingRoot + }, + }, + { + name: "symlinked home", + setup: func(t *testing.T, tmp string) (string, string) { + t.Helper() + outside := filepath.Join(tmp, "outside") + if err := os.Mkdir(outside, 0o700); err != nil { + t.Fatal(err) + } + homeDir := filepath.Join(tmp, "linked-home") + if err := os.Symlink(outside, homeDir); err != nil { + t.Fatal(err) + } + return homeDir, outside + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmp := t.TempDir() + homeDir, untouchedRoot := tt.setup(t, tmp) + unitPath := filepath.Join(tmp, "kandev.service") + originalDefinition := "# managed by kandev\noriginal systemd definition\n" + if err := os.WriteFile(unitPath, []byte(originalDefinition), 0o640); err != nil { + t.Fatal(err) + } + + executablePath = func() (string, error) { return "/opt/kandev/bin/kandev", nil } + servicePrintln = func(string) {} + lookupNativeServiceOwner = func(string) (int, int, error) { return 1234, 5678, nil } + chownNativeServiceMetadata = func(*os.File, int, int) error { return nil } + var managerCommands []string + executeServiceCommand = func(name string, args ...string) error { + managerCommands = append(managerCommands, name+" "+strings.Join(args, " ")) + return nil + } + t.Setenv("SUDO_USER", "alice") + + code := installSystemd( + serviceArgs{Action: actionInstall, System: true, HomeDir: homeDir}, + BuildInfo{Version: "test"}, + unitPath, + ) + if code == 0 { + t.Fatalf("installSystemd() = 0 for unsafe system home") + } + assertDefinitionUnchanged(t, unitPath, originalDefinition, 0o640) + if len(managerCommands) != 0 { + t.Fatalf("manager commands ran after preflight failure: %v", managerCommands) + } + if _, err := os.Stat(filepath.Join(untouchedRoot, "service", "install.json")); !os.IsNotExist(err) { + t.Fatalf("metadata was created through unsafe home, stat err=%v", err) + } + }) + } +} + +func TestInstallLaunchdPreflightPreservesDefinitionAndLogsForUnsafeSystemHome(t *testing.T) { + originalExecutablePath := executablePath + originalExecuteServiceCommand := executeServiceCommand + originalServicePrintln := servicePrintln + originalLookup := lookupNativeServiceOwner + originalChown := chownNativeServiceMetadata + t.Cleanup(func() { + executablePath = originalExecutablePath + executeServiceCommand = originalExecuteServiceCommand + servicePrintln = originalServicePrintln + lookupNativeServiceOwner = originalLookup + chownNativeServiceMetadata = originalChown + }) + + tests := []struct { + name string + setup func(t *testing.T, tmp string) (homeDir, untouchedRoot string) + }{ + { + name: "missing home", + setup: func(t *testing.T, tmp string) (string, string) { + t.Helper() + missingRoot := filepath.Join(tmp, "missing-parent") + return filepath.Join(missingRoot, "home"), missingRoot + }, + }, + { + name: "symlinked home", + setup: func(t *testing.T, tmp string) (string, string) { + t.Helper() + outside := filepath.Join(tmp, "outside") + if err := os.Mkdir(outside, 0o700); err != nil { + t.Fatal(err) + } + homeDir := filepath.Join(tmp, "linked-home") + if err := os.Symlink(outside, homeDir); err != nil { + t.Fatal(err) + } + return homeDir, outside + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmp := t.TempDir() + homeDir, untouchedRoot := tt.setup(t, tmp) + plistPath := filepath.Join(tmp, "com.kdlbs.kandev.plist") + originalDefinition := "\noriginal launchd definition\n" + if err := os.WriteFile(plistPath, []byte(originalDefinition), 0o640); err != nil { + t.Fatal(err) + } + + executablePath = func() (string, error) { return "/opt/kandev/bin/kandev", nil } + servicePrintln = func(string) {} + lookupNativeServiceOwner = func(string) (int, int, error) { return 1234, 5678, nil } + chownNativeServiceMetadata = func(*os.File, int, int) error { return nil } + var managerCommands []string + executeServiceCommand = func(name string, args ...string) error { + managerCommands = append(managerCommands, name+" "+strings.Join(args, " ")) + return nil + } + t.Setenv("SUDO_USER", "alice") + + code := installLaunchd( + serviceArgs{Action: actionInstall, System: true, HomeDir: homeDir}, + BuildInfo{Version: "test"}, + plistPath, + "system/com.kdlbs.kandev", + "system", + ) + if code == 0 { + t.Fatalf("installLaunchd() = 0 for unsafe system home") + } + assertDefinitionUnchanged(t, plistPath, originalDefinition, 0o640) + if len(managerCommands) != 0 { + t.Fatalf("manager commands ran after preflight failure: %v", managerCommands) + } + for _, escaped := range []string{ + filepath.Join(untouchedRoot, "logs"), + filepath.Join(untouchedRoot, "service", "install.json"), + } { + if _, err := os.Stat(escaped); !os.IsNotExist(err) { + t.Fatalf("path was created through unsafe home %q, stat err=%v", escaped, err) + } + } + }) + } +} + +func TestInstallLaunchdRejectsSymlinkedSystemLogsBeforeDefinitionWrite(t *testing.T) { + originalExecutablePath := executablePath + originalExecuteServiceCommand := executeServiceCommand + originalServicePrintln := servicePrintln + originalLookup := lookupNativeServiceOwner + originalChown := chownNativeServiceMetadata + t.Cleanup(func() { + executablePath = originalExecutablePath + executeServiceCommand = originalExecuteServiceCommand + servicePrintln = originalServicePrintln + lookupNativeServiceOwner = originalLookup + chownNativeServiceMetadata = originalChown + }) + + tmp := t.TempDir() + homeDir := filepath.Join(tmp, "home") + outside := filepath.Join(tmp, "outside") + if err := os.Mkdir(homeDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(outside, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(homeDir, "logs")); err != nil { + t.Fatal(err) + } + plistPath := filepath.Join(tmp, "com.kdlbs.kandev.plist") + originalDefinition := "\noriginal launchd definition\n" + if err := os.WriteFile(plistPath, []byte(originalDefinition), 0o640); err != nil { + t.Fatal(err) + } + + executablePath = func() (string, error) { return "/opt/kandev/bin/kandev", nil } + servicePrintln = func(string) {} + lookupNativeServiceOwner = func(string) (int, int, error) { return 1234, 5678, nil } + chownCalls := 0 + chownNativeServiceMetadata = func(*os.File, int, int) error { + chownCalls++ + return nil + } + var managerCommands []string + executeServiceCommand = func(name string, args ...string) error { + managerCommands = append(managerCommands, name+" "+strings.Join(args, " ")) + return nil + } + t.Setenv("SUDO_USER", "alice") + + code := installLaunchd( + serviceArgs{Action: actionInstall, System: true, HomeDir: homeDir}, + BuildInfo{Version: "test"}, + plistPath, + "system/com.kdlbs.kandev", + "system", + ) + if code == 0 { + t.Fatalf("installLaunchd() = 0 for symlinked system logs") + } + assertDefinitionUnchanged(t, plistPath, originalDefinition, 0o640) + if len(managerCommands) != 0 { + t.Fatalf("manager commands ran after log preflight failure: %v", managerCommands) + } + if chownCalls != 0 { + t.Fatalf("ownership changed through symlinked logs: chown calls=%d", chownCalls) + } + if mode := mustStat(t, outside).Mode().Perm(); mode != 0o700 { + t.Fatalf("outside directory mode = %o, want unchanged 700", mode) + } + if _, err := os.Stat(filepath.Join(homeDir, "service")); !os.IsNotExist(err) { + t.Fatalf("metadata path was partially created after log preflight failure, stat err=%v", err) + } +} + +func TestSystemMetadataOwnershipUsesResolvedServiceUser(t *testing.T) { + originalLookup := lookupNativeServiceOwner + originalChown := chownNativeServiceMetadata + t.Cleanup(func() { + lookupNativeServiceOwner = originalLookup + chownNativeServiceMetadata = originalChown + }) + + lookupNativeServiceOwner = func(username string) (int, int, error) { + if username != "alice" { + t.Fatalf("lookup username = %q, want alice", username) + } + return 1234, 5678, nil + } + var ownershipTargets []string + chownNativeServiceMetadata = func(file *os.File, uid, gid int) error { + if uid != 1234 || gid != 5678 { + t.Fatalf("ownership = %d:%d, want 1234:5678", uid, gid) + } + ownershipTargets = append(ownershipTargets, file.Name()) + return nil + } + + homeDir := t.TempDir() + err := writeNativeServiceMetadata(nativeServiceMetadata{ + Version: nativeServiceMetadataVersion, + Mode: nativeServiceModeSystem, + SystemUser: "alice", + HomeDir: homeDir, + ServicePath: filepath.Join(homeDir, "kandev.service"), + LauncherPath: "/opt/kandev/bin/kandev", + InstalledAt: "2026-07-25T00:00:00Z", + }) + if err != nil { + t.Fatal(err) + } + if len(ownershipTargets) != 2 { + t.Fatalf("ownership targets = %v, want service dir and install.json", ownershipTargets) + } + assertMetadataPermissions(t, filepath.Join(homeDir, "service", "install.json")) +} + +func TestSystemMetadataAtomicFailurePreservesExistingFile(t *testing.T) { + originalLookup := lookupNativeServiceOwner + originalChown := chownNativeServiceMetadata + t.Cleanup(func() { + lookupNativeServiceOwner = originalLookup + chownNativeServiceMetadata = originalChown + }) + + lookupNativeServiceOwner = func(string) (int, int, error) { return 1234, 5678, nil } + chownCalls := 0 + chownNativeServiceMetadata = func(*os.File, int, int) error { + chownCalls++ + if chownCalls == 2 { + return errors.New("injected file ownership failure") + } + return nil + } + + homeDir := t.TempDir() + serviceDir := filepath.Join(homeDir, "service") + if err := os.Mkdir(serviceDir, 0o700); err != nil { + t.Fatal(err) + } + metadataPath := filepath.Join(serviceDir, "install.json") + if err := os.WriteFile(metadataPath, []byte("existing metadata\n"), 0o600); err != nil { + t.Fatal(err) + } + err := writeNativeServiceMetadata(nativeServiceMetadata{ + Version: nativeServiceMetadataVersion, + Mode: nativeServiceModeSystem, + SystemUser: "alice", + HomeDir: homeDir, + ServicePath: filepath.Join(homeDir, "kandev.service"), + LauncherPath: "/opt/kandev/bin/kandev", + InstalledAt: "2026-07-25T00:00:00Z", + }) + if err == nil { + t.Fatalf("writeNativeServiceMetadata() succeeded after ownership failure") + } + data, readErr := os.ReadFile(metadataPath) + if readErr != nil { + t.Fatal(readErr) + } + if string(data) != "existing metadata\n" { + t.Fatalf("metadata = %q, want existing contents", data) + } + entries, readErr := os.ReadDir(serviceDir) + if readErr != nil { + t.Fatal(readErr) + } + if len(entries) != 1 || entries[0].Name() != "install.json" { + t.Fatalf("temporary metadata was not cleaned up: %v", entries) + } +} + +func TestNativeSelfUpdateRemainsHiddenFromServiceHelp(t *testing.T) { + if strings.Contains(serviceHelp, actionSelfUpdate) { + t.Fatalf("public service help exposes hidden action:\n%s", serviceHelp) + } +} + +func TestNativeServiceSelfUpdateRejectsUnsupportedKind(t *testing.T) { + intentPath := writeNativeUpdateIntent(t, map[string]interface{}{ + "manager": "systemd", "mode": "user", "kind": "local", + "home_dir": t.TempDir(), "launcher_path": "/tmp/kandev", + }) + if code := runService([]string{"self-update", "--intent", intentPath}, BuildInfo{}); code == 0 { + t.Fatalf("runService(self-update) = 0 for unsupported install kind") + } +} + +func assertNativeInstallMetadata(t *testing.T, path string, expected map[string]interface{}) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read metadata: %v", err) + } + var got map[string]interface{} + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal metadata: %v", err) + } + for key, want := range expected { + if got[key] != want { + t.Fatalf("metadata[%q] = %#v, want %#v; metadata=%v", key, got[key], want, got) + } + } + if installedAt, _ := got["installed_at"].(string); installedAt == "" { + t.Fatalf("metadata missing installed_at: %v", got) + } else if _, err := time.Parse(time.RFC3339Nano, installedAt); err != nil { + t.Fatalf("installed_at = %q: %v", installedAt, err) + } + assertMetadataPermissions(t, path) +} + +func assertMetadataPermissions(t *testing.T, path string) { + t.Helper() + if mode := mustStat(t, path).Mode().Perm(); mode != 0o600 { + t.Fatalf("metadata mode = %o, want 600", mode) + } + if mode := mustStat(t, filepath.Dir(path)).Mode().Perm(); mode != 0o700 { + t.Fatalf("metadata dir mode = %o, want 700", mode) + } +} + +func assertDefinitionUnchanged(t *testing.T, path, content string, mode os.FileMode) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != content { + t.Fatalf("definition changed:\n%s", data) + } + if got := mustStat(t, path).Mode().Perm(); got != mode { + t.Fatalf("definition mode = %o, want %o", got, mode) + } +} + +func writeNativeUpdateIntent(t *testing.T, install map[string]interface{}) string { + t.Helper() + intent := map[string]interface{}{ + "version": 1, + "target_tag": "v1.2.3", + "target_version": "1.2.3", + "install": install, + "created_at": "2026-07-25T00:00:00Z", + } + data, err := json.Marshal(intent) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "intent.json") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func mustStat(t *testing.T, path string) os.FileInfo { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + return info +} diff --git a/apps/backend/internal/launcher/service_self_update.go b/apps/backend/internal/launcher/service_self_update.go new file mode 100644 index 0000000000..7aa1bf069d --- /dev/null +++ b/apps/backend/internal/launcher/service_self_update.go @@ -0,0 +1,253 @@ +package launcher + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +type nativeUpdateIntent struct { + Version int `json:"version"` + TargetTag string `json:"target_tag"` + TargetVersion string `json:"target_version"` + Install nativeUpdateInstall `json:"install"` +} + +type nativeUpdateInstall struct { + Manager string `json:"manager"` + Mode string `json:"mode"` + Kind string `json:"kind"` + HomeDir string `json:"home_dir"` + LauncherPath string `json:"launcher_path"` + Port int `json:"port,omitempty"` + NoBootStart bool `json:"no_boot_start,omitempty"` +} + +type nativeUpdateCommand struct { + Command string `json:"command"` + Args []string `json:"args"` +} + +func runNativeSelfUpdate(args serviceArgs) error { + intent, err := readNativeUpdateIntent(args.Intent) + if err != nil { + return err + } + commands, err := planNativeSelfUpdate(intent) + if err != nil { + return err + } + if args.DryRun || os.Getenv("KANDEV_E2E_MOCK") == "true" { + return printNativeUpdatePlan(intent, commands, args.DryRun) + } + return withoutStaleLauncherEnvironment(func() error { + for _, step := range commands { + if err := runCommand(step.Command, step.Args...); err != nil { + return fmt.Errorf("%s failed: %w", formatNativeUpdateCommand(step), err) + } + } + return nil + }) +} + +func withoutStaleLauncherEnvironment(run func() error) (resultErr error) { + const versionKey = "KANDEV_VERSION" + const bundleKey = "KANDEV_BUNDLE_DIR" + saved := []savedEnvironmentVariable{ + captureEnvironmentVariable(versionKey), + captureEnvironmentVariable(bundleKey), + } + for _, variable := range saved { + if err := os.Unsetenv(variable.Name); err != nil { + _ = restoreEnvironmentVariables(saved) + return fmt.Errorf("clear stale %s: %w", variable.Name, err) + } + } + defer func() { + if err := restoreEnvironmentVariables(saved); resultErr == nil && err != nil { + resultErr = err + } + }() + return run() +} + +type savedEnvironmentVariable struct { + Name string + Value string + Present bool +} + +func captureEnvironmentVariable(name string) savedEnvironmentVariable { + value, present := os.LookupEnv(name) + return savedEnvironmentVariable{Name: name, Value: value, Present: present} +} + +func restoreEnvironmentVariables(saved []savedEnvironmentVariable) error { + for _, variable := range saved { + if variable.Present { + if err := os.Setenv(variable.Name, variable.Value); err != nil { + return fmt.Errorf("restore %s: %w", variable.Name, err) + } + continue + } + if err := os.Unsetenv(variable.Name); err != nil { + return fmt.Errorf("restore %s: %w", variable.Name, err) + } + } + return nil +} + +func readNativeUpdateIntent(path string) (nativeUpdateIntent, error) { + if strings.TrimSpace(path) == "" { + return nativeUpdateIntent{}, errors.New("kandev service self-update requires --intent ") + } + data, err := os.ReadFile(path) + if err != nil { + return nativeUpdateIntent{}, fmt.Errorf("read self-update intent: %w", err) + } + var intent nativeUpdateIntent + if err := json.Unmarshal(data, &intent); err != nil { + return nativeUpdateIntent{}, fmt.Errorf("parse self-update intent: %w", err) + } + if intent.Version != 1 { + return nativeUpdateIntent{}, fmt.Errorf("unsupported self-update intent version %d", intent.Version) + } + return intent, nil +} + +func planNativeSelfUpdate(intent nativeUpdateIntent) ([]nativeUpdateCommand, error) { + install := intent.Install + if err := validateNativeUpdateInstall(install); err != nil { + return nil, err + } + target := strings.TrimPrefix(intent.TargetVersion, "v") + if target == "" { + target = strings.TrimPrefix(intent.TargetTag, "v") + } + if target == "" { + target = "latest" + } + installArgs := nativeServiceInstallArgs(install) + var commands []nativeUpdateCommand + switch install.Kind { + case nativeInstallKindHomebrew: + commands = append(commands, + nativeUpdateCommand{Command: "brew", Args: []string{"upgrade", "kandev"}}, + nativeUpdateCommand{Command: "kandev", Args: installArgs}, + ) + case nativeInstallKindNPM: + npmArgs := []string{"install", "-g"} + if prefix := nativeNPMPrefix(install.LauncherPath); prefix != "" { + npmArgs = append(npmArgs, "--prefix", prefix) + } + npmArgs = append(npmArgs, "kandev@"+target) + commands = append(commands, + nativeUpdateCommand{Command: "npm", Args: npmArgs}, + nativeUpdateCommand{Command: install.LauncherPath, Args: installArgs}, + ) + case nativeInstallKindNPX: + npxArgs := append([]string{"-y", "kandev@" + target}, installArgs...) + commands = append(commands, nativeUpdateCommand{Command: "npx", Args: npxArgs}) + default: + return nil, fmt.Errorf("unsupported install kind %q", install.Kind) + } + restart, err := nativeServiceRestartCommand(install) + if err != nil { + return nil, err + } + return append(commands, restart), nil +} + +func validateNativeUpdateInstall(install nativeUpdateInstall) error { + if install.Mode != nativeServiceModeUser && install.Mode != nativeServiceModeSystem { + return fmt.Errorf("unsupported service mode %q", install.Mode) + } + if install.Manager != nativeServiceManagerSystemd && install.Manager != nativeServiceManagerLaunchd { + return fmt.Errorf("unsupported service manager %q", install.Manager) + } + if !filepath.IsAbs(install.HomeDir) { + return errors.New("service metadata home_dir must be absolute") + } + if !filepath.IsAbs(install.LauncherPath) { + return errors.New("service metadata launcher_path must be absolute") + } + return nil +} + +func nativeServiceInstallArgs(install nativeUpdateInstall) []string { + args := []string{"service", "install"} + if install.Mode == nativeServiceModeSystem { + args = append(args, "--system") + } + args = append(args, "--home-dir", install.HomeDir) + if install.Port != 0 { + args = append(args, "--port", strconv.Itoa(install.Port)) + } + if install.NoBootStart { + args = append(args, "--no-boot-start") + } + return args +} + +func nativeServiceRestartCommand(install nativeUpdateInstall) (nativeUpdateCommand, error) { + switch install.Manager { + case nativeServiceManagerSystemd: + args := []string{"restart", serviceUnitName} + if install.Mode == nativeServiceModeUser { + args = append([]string{"--user"}, args...) + } + return nativeUpdateCommand{Command: "systemctl", Args: args}, nil + case nativeServiceManagerLaunchd: + domain := "system" + if install.Mode == nativeServiceModeUser { + domain = "gui/" + strconv.Itoa(os.Getuid()) + } + return nativeUpdateCommand{ + Command: "launchctl", + Args: []string{"kickstart", "-k", domain + "/com.kdlbs.kandev"}, + }, nil + default: + return nativeUpdateCommand{}, fmt.Errorf("unsupported service manager %q", install.Manager) + } +} + +func nativeNPMPrefix(launcherPath string) string { + slashPath := filepath.ToSlash(launcherPath) + const marker = "/lib/node_modules/" + index := strings.Index(slashPath, marker) + if index < 0 { + return "" + } + if index == 0 { + return string(filepath.Separator) + } + return filepath.FromSlash(slashPath[:index]) +} + +func printNativeUpdatePlan(intent nativeUpdateIntent, commands []nativeUpdateCommand, dryRun bool) error { + out := struct { + DryRun bool `json:"dry_run"` + Fake bool `json:"fake"` + TargetVersion string `json:"target_version"` + Commands []nativeUpdateCommand `json:"commands"` + }{ + DryRun: dryRun, + Fake: os.Getenv("KANDEV_E2E_MOCK") == "true", + TargetVersion: intent.TargetVersion, + Commands: commands, + } + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +func formatNativeUpdateCommand(command nativeUpdateCommand) string { + return strings.Join(append([]string{command.Command}, command.Args...), " ") +} diff --git a/apps/backend/internal/launcher/service_test.go b/apps/backend/internal/launcher/service_test.go index ce4603e4bb..2cbfc45c06 100644 --- a/apps/backend/internal/launcher/service_test.go +++ b/apps/backend/internal/launcher/service_test.go @@ -1,6 +1,7 @@ package launcher import ( + "errors" "os" "path/filepath" "runtime" @@ -250,7 +251,12 @@ func TestInstallLaunchdIncludesActiveNodeBinInPath(t *testing.T) { executablePath = func() (string, error) { return filepath.Join(tmp, ".npm", "_npx", "abc", "node_modules", "@kdlbs", "runtime-darwin-arm64", "bin", "kandev"), nil } - executeServiceCommand = func(string, ...string) error { return nil } + executeServiceCommand = func(name string, args ...string) error { + if name == "launchctl" && len(args) > 0 && args[0] == "print" { + return errors.New("not loaded") + } + return nil + } servicePrintln = func(string) {} plistPath := filepath.Join(tmp, "com.kdlbs.kandev.plist") @@ -314,6 +320,9 @@ func TestInstallLaunchdMessagesDistinguishBootStart(t *testing.T) { var messages []string executeServiceCommand = func(name string, args ...string) error { commands = append(commands, name+" "+strings.Join(args, " ")) + if name == "launchctl" && len(args) > 0 && args[0] == "print" { + return errors.New("not loaded") + } return nil } servicePrintln = func(message string) { diff --git a/apps/backend/internal/system/updates/apply.go b/apps/backend/internal/system/updates/apply.go index e70efc82fd..e96857fa40 100644 --- a/apps/backend/internal/system/updates/apply.go +++ b/apps/backend/internal/system/updates/apply.go @@ -135,14 +135,7 @@ func systemdSelfUpdateArgs(req applyRequest, unitName string) []string { for _, env := range selfUpdateEnvironment() { args = append(args, "--setenv="+env) } - return append(args, - req.Intent.Install.NodePath, - req.Intent.Install.CLIEntry, - "service", - "self-update", - "--intent", - req.IntentPath, - ) + return append(args, selfUpdateCommandArgs(req)...) } const selfUpdateHelperDirName = "self-update" @@ -220,14 +213,7 @@ func cleanupStaleLaunchdHelpers(ctx context.Context, dir, domain string) { // so the helper can resolve npm; stdout/stderr are captured to the log dir as a // fallback alongside the helper's own self-update-.log. func renderLaunchdHelperPlist(label string, req applyRequest) string { - args := []string{ - req.Intent.Install.NodePath, - req.Intent.Install.CLIEntry, - "service", - "self-update", - "--intent", - req.IntentPath, - } + args := selfUpdateCommandArgs(req) var argsXML strings.Builder for _, arg := range args { argsXML.WriteString(" " + escapeXML(arg) + "\n") @@ -266,6 +252,27 @@ func renderLaunchdHelperPlist(label string, req applyRequest) string { ` } +func selfUpdateCommandArgs(req applyRequest) []string { + install := req.Intent.Install + if install.LauncherPath != "" { + return []string{ + install.LauncherPath, + "service", + "self-update", + "--intent", + req.IntentPath, + } + } + return []string{ + install.NodePath, + install.CLIEntry, + "service", + "self-update", + "--intent", + req.IntentPath, + } +} + func selfUpdateEnvironment() []string { keys := []string{"PATH", "npm_config_prefix", "NPM_CONFIG_PREFIX"} env := make([]string, 0, len(keys)) diff --git a/apps/backend/internal/system/updates/apply_test.go b/apps/backend/internal/system/updates/apply_test.go index 3b9e6681e4..166b78bc69 100644 --- a/apps/backend/internal/system/updates/apply_test.go +++ b/apps/backend/internal/system/updates/apply_test.go @@ -77,6 +77,50 @@ func TestService_ApplyQueuesSelfUpdateJobAndWritesIntent(t *testing.T) { } } +func TestWriteApplyIntentPreservesNativeNoBootStart(t *testing.T) { + homeDir := t.TempDir() + metadataPath := filepath.Join(homeDir, "service", "install.json") + if err := os.MkdirAll(filepath.Dir(metadataPath), 0o700); err != nil { + t.Fatal(err) + } + data := []byte(`{ + "version": 1, + "manager": "systemd", + "mode": "user", + "kind": "npm", + "home_dir": "/home/alice/.kandev", + "log_dir": "/home/alice/.kandev/logs", + "service_path": "/home/alice/.config/systemd/user/kandev.service", + "launcher_path": "/usr/local/lib/node_modules/@kdlbs/runtime-linux-x64/bin/kandev", + "no_boot_start": true, + "installed_at": "2026-07-25T00:00:00Z" + }`) + if err := os.WriteFile(metadataPath, data, 0o600); err != nil { + t.Fatal(err) + } + metadata, err := readServiceMetadata(metadataPath) + if err != nil { + t.Fatal(err) + } + svc := NewService(newTestPool(t), "v1.0.0", nil, logger.Default(), WithHomeDir(homeDir)) + intentPath, _, err := svc.writeApplyIntent(UpdatesResponse{Latest: "v1.2.3"}, metadata) + if err != nil { + t.Fatal(err) + } + intentData, err := os.ReadFile(intentPath) + if err != nil { + t.Fatal(err) + } + var intent map[string]interface{} + if err := json.Unmarshal(intentData, &intent); err != nil { + t.Fatal(err) + } + install, _ := intent["install"].(map[string]interface{}) + if install["no_boot_start"] != true { + t.Fatalf("intent install.no_boot_start = %#v, want true; intent=%v", install["no_boot_start"], intent) + } +} + func TestService_ApplyRejectsUnsupportedInstall(t *testing.T) { pool := newTestPool(t) if err := persistence.WriteLatestVersion(pool.Writer(), "v1.0.1", "https://example/v1.0.1", time.Now().UTC()); err != nil { @@ -222,6 +266,34 @@ func TestSystemdSelfUpdateArgsPropagateUpdateEnvironment(t *testing.T) { } } +func TestSystemdSelfUpdateArgsUseNativeLauncherPath(t *testing.T) { + var req applyRequest + data := []byte(`{ + "intent_path": "/tmp/unused", + "intent": { + "install": { + "launcher_path": "/opt/homebrew/Cellar/kandev/1.2.3/libexec/bin/kandev" + } + } + }`) + if err := json.Unmarshal(data, &req); err != nil { + t.Fatal(err) + } + req.IntentPath = "/tmp/intent.json" + + got := systemdSelfUpdateArgs(req, "kandev-self-update-test") + wantTail := []string{ + "/opt/homebrew/Cellar/kandev/1.2.3/libexec/bin/kandev", + "service", + "self-update", + "--intent", + "/tmp/intent.json", + } + if len(got) < len(wantTail) || !stringSlicesEqual(got[len(got)-len(wantTail):], wantTail) { + t.Fatalf("args tail=%#v, want %#v; all args=%#v", got, wantTail, got) + } +} + func TestRenderLaunchdHelperPlistRunsOnceWithUpdateEnvironment(t *testing.T) { t.Setenv("PATH", "/opt/homebrew/bin:/usr/bin") t.Setenv("npm_config_prefix", "/tmp/npm-global") @@ -265,6 +337,27 @@ func TestRenderLaunchdHelperPlistRunsOnceWithUpdateEnvironment(t *testing.T) { } } +func TestRenderLaunchdHelperPlistUsesNativeLauncherPath(t *testing.T) { + var req applyRequest + data := []byte(`{ + "install": { + "launcher_path": "/opt/homebrew/Cellar/kandev/1.2.3/libexec/bin/kandev", + "log_dir": "/tmp/kandev/logs" + } + }`) + if err := json.Unmarshal(data, &req.Intent); err != nil { + t.Fatal(err) + } + req.IntentPath = "/tmp/intent.json" + + plist := renderLaunchdHelperPlist("com.kdlbs.kandev.self-update.test", req) + if !strings.Contains(plist, + "/opt/homebrew/Cellar/kandev/1.2.3/libexec/bin/kandev\n"+ + " service") { + t.Fatalf("plist does not invoke native launcher directly:\n%s", plist) + } +} + func TestLaunchdSelfUpdateDomain(t *testing.T) { user := applyRequest{Intent: updateIntent{Install: serviceInstallMetadata{Mode: installModeUser}}} if got := launchdSelfUpdateDomain(user, 501); got != "gui/501" { diff --git a/apps/backend/internal/system/updates/install_state.go b/apps/backend/internal/system/updates/install_state.go index 2590da0d2c..b9d725184b 100644 --- a/apps/backend/internal/system/updates/install_state.go +++ b/apps/backend/internal/system/updates/install_state.go @@ -38,12 +38,14 @@ type serviceInstallMetadata struct { HomeDir string `json:"home_dir"` LogDir string `json:"log_dir"` ServicePath string `json:"service_path"` + LauncherPath string `json:"launcher_path,omitempty"` NodePath string `json:"node_path"` CLIEntry string `json:"cli_entry"` BundleDir string `json:"bundle_dir,omitempty"` LauncherVersion string `json:"launcher_version,omitempty"` Port int `json:"port,omitempty"` SystemUser string `json:"system_user,omitempty"` + NoBootStart bool `json:"no_boot_start,omitempty"` InstalledAt string `json:"installed_at"` } @@ -93,7 +95,8 @@ func readServiceMetadata(metadataPath string) (*serviceInstallMetadata, error) { if metadata.Version != serviceMetadataVersion { return nil, fmt.Errorf("unsupported service metadata version %d", metadata.Version) } - if metadata.ServicePath == "" || metadata.NodePath == "" || metadata.CLIEntry == "" { + legacyLauncher := metadata.NodePath != "" && metadata.CLIEntry != "" + if metadata.ServicePath == "" || (metadata.LauncherPath == "" && !legacyLauncher) { return nil, errors.New("service metadata missing required paths") } return &metadata, nil @@ -130,7 +133,11 @@ func (s *Service) metadataMatchesService(state InstallStateResponse, metadata *s if !strings.Contains(text, envRunningAsService) || !strings.Contains(text, envServiceMetadata) { return false } - return strings.Contains(text, state.MetadataPath) || strings.Contains(text, escapeXML(state.MetadataPath)) + if strings.Contains(text, state.MetadataPath) || strings.Contains(text, escapeXML(state.MetadataPath)) { + return true + } + return state.Manager == serviceManagerSystemd && + strings.Contains(text, strings.ReplaceAll(state.MetadataPath, "%", "%%")) } func (s InstallStateResponse) applySupport() (bool, string) { diff --git a/apps/backend/internal/system/updates/install_state_test.go b/apps/backend/internal/system/updates/install_state_test.go index 2f13c87cd8..eaf046ff8a 100644 --- a/apps/backend/internal/system/updates/install_state_test.go +++ b/apps/backend/internal/system/updates/install_state_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/kandev/kandev/internal/common/logger" @@ -40,6 +41,87 @@ func TestService_GetManagedUserServiceSupportsApply(t *testing.T) { } } +func TestService_GetManagedNativeServiceSupportsApply(t *testing.T) { + homeDir := t.TempDir() + servicePath := filepath.Join(homeDir, "kandev.service") + metadataPath := filepath.Join(homeDir, "service", "install.json") + if err := os.MkdirAll(filepath.Dir(metadataPath), 0o700); err != nil { + t.Fatal(err) + } + metadata := map[string]interface{}{ + "version": serviceMetadataVersion, + "manager": serviceManagerSystemd, + "mode": installModeUser, + "kind": installKindHomebrew, + "home_dir": homeDir, + "log_dir": filepath.Join(homeDir, "logs"), + "service_path": servicePath, + "launcher_path": "/opt/homebrew/Cellar/kandev/1.2.3/libexec/bin/kandev", + "bundle_dir": "/opt/homebrew/Cellar/kandev/1.2.3/libexec", + "launcher_version": "1.2.3", + "installed_at": "2026-07-25T00:00:00Z", + } + data, err := json.Marshal(metadata) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(metadataPath, data, 0o600); err != nil { + t.Fatal(err) + } + serviceContent := managedMarkerText + "\n" + envRunningAsService + "\n" + + envServiceMetadata + "=" + metadataPath + "\n" + if err := os.WriteFile(servicePath, []byte(serviceContent), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(envRunningAsService, "true") + t.Setenv(envServiceMode, installModeUser) + t.Setenv(envServiceManager, serviceManagerSystemd) + t.Setenv(envInstallKind, installKindHomebrew) + t.Setenv(envServiceMetadata, metadataPath) + + svc := NewService(newTestPool(t), "v1.0.0", nil, logger.Default(), WithHomeDir(homeDir)) + resp, err := svc.Get() + if err != nil { + t.Fatalf("Get: %v", err) + } + if !resp.Install.ManagedService || !resp.ApplySupported { + t.Fatalf("native install state = %+v apply_supported=%v reason=%q", resp.Install, resp.ApplySupported, resp.ApplyUnsupportedReason) + } +} + +func TestService_GetManagedSystemdServiceSupportsApplyWithPercentInMetadataPath(t *testing.T) { + homeDir := filepath.Join(t.TempDir(), "home%dir") + metadataPath, servicePath := writeServiceInstallForTest(t, homeDir, serviceInstallMetadata{ + Manager: serviceManagerSystemd, + Mode: installModeUser, + Kind: installKindHomebrew, + HomeDir: homeDir, + LogDir: filepath.Join(homeDir, "logs"), + ServicePath: filepath.Join(homeDir, "kandev.service"), + LauncherPath: "/opt/homebrew/bin/kandev", + }) + serviceContent := managedMarkerText + "\n" + envRunningAsService + "\n" + + envServiceMetadata + "=" + strings.ReplaceAll(metadataPath, "%", "%%") + "\n" + if err := os.WriteFile(servicePath, []byte(serviceContent), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(envRunningAsService, "true") + t.Setenv(envServiceMode, installModeUser) + t.Setenv(envServiceManager, serviceManagerSystemd) + t.Setenv(envInstallKind, installKindHomebrew) + t.Setenv(envServiceMetadata, metadataPath) + + svc := NewService(newTestPool(t), "v1.0.0", nil, logger.Default(), WithHomeDir(homeDir)) + resp, err := svc.Get() + if err != nil { + t.Fatalf("Get: %v", err) + } + if !resp.Install.ManagedService || !resp.ApplySupported { + t.Fatalf("percent-escaped install state = %+v apply_supported=%v reason=%q", + resp.Install, resp.ApplySupported, resp.ApplyUnsupportedReason) + } +} + func TestService_GetSystemServiceDisablesApply(t *testing.T) { homeDir := t.TempDir() metadataPath, _ := writeServiceInstallForTest(t, homeDir, serviceInstallMetadata{ diff --git a/docs/public/cli.md b/docs/public/cli.md index 5ad94fce21..06614aea23 100644 --- a/docs/public/cli.md +++ b/docs/public/cli.md @@ -143,7 +143,7 @@ kandev service status kandev service logs --follow ``` -Supported actions are `install`, `uninstall`, `start`, `stop`, `restart`, `status`, `logs`, and `config`. Installation accepts `--system`, `--port`, `--home-dir`, and `--no-boot-start`. In the current native installer, `--port` is written as `KANDEV_SERVER_PORT`, but the supervising launcher overwrites that value with its own automatic port selection; the option therefore does not reliably pin a service listener today. The service normally prefers `38429` and falls back when it is busy. Windows service installation is not implemented. The published native service path also does not write the legacy update metadata consumed by the Settings-page package updater: upgrade with Homebrew/npm, rerun `kandev service install` with the same flags so versioned executable/bundle paths are refreshed, then restart the service. See [Run as a service](./run-as-a-service.md) for privileges, paths, upgrades, and recovery. +Supported actions are `install`, `uninstall`, `start`, `stop`, `restart`, `status`, `logs`, and `config`. Installation accepts `--system`, `--port`, `--home-dir`, and `--no-boot-start`. In the current native installer, `--port` is written as `KANDEV_SERVER_PORT`, but the supervising launcher overwrites that value with its own automatic port selection; the option therefore does not reliably pin a service listener today. The service normally prefers `38429` and falls back when it is busy. Windows service installation is not implemented. Managed user services write owner-only install metadata so **Settings > System > Updates** can offer the guarded Apply action; system services and failed guarded updates use the package manager followed by `kandev service install` and `kandev service restart`. See [Run as a service](./run-as-a-service.md) for privileges, paths, upgrades, and recovery. ## Ports and network exposure diff --git a/docs/public/operations.md b/docs/public/operations.md index f5e2e5ac7c..88bcda608d 100644 --- a/docs/public/operations.md +++ b/docs/public/operations.md @@ -15,7 +15,7 @@ Kandev does not currently provide a user-login boundary for the web application, | --- | --- | --- | --- | | Desktop | Launch or quit Kandev | `~/.kandev` by default | **Settings > System > Updates** uses the signed desktop updater when supported | | Interactive CLI | `kandev`, then `Ctrl-C` | `~/.kandev` by default | Upgrade the Homebrew or npm package, then restart | -| Managed service | `kandev service {start,stop,restart,status}` | `~/.kandev` for a user service, `/var/lib/kandev` for a system service, or the install-time `--home-dir` | Upgrade the package, reinstall the unit with the same flags, and restart; the current native installer does not enable in-app apply | +| Managed service | `kandev service {start,stop,restart,status}` | `~/.kandev` for a user service, `/var/lib/kandev` for a system service, or the install-time `--home-dir` | Use guarded in-app apply for a managed user service; otherwise upgrade the package, reinstall with the same flags, and restart | | Docker or Kubernetes | Container or workload manager | Mounted Kandev home plus any external database/provider state | Replace the image and recreate the container or pod | See [Desktop app](desktop-app.md), [CLI](cli.md), [Run as a service](run-as-a-service.md), [Docker](docker.md), and [Kubernetes](k8s.md) for mode-specific prerequisites and commands. @@ -66,7 +66,7 @@ Add `--system` to both commands for a system service. | `repos/` | Kandev-managed source clones | | `sessions/`, `quick-chat/`, `agent-sessions/` | Session history, ephemeral workspaces, and isolated agent homes when used | | `logs/` | Service and optional ACP debug logs | -| `service/` | Update/helper files when present; the current native service installer does not create `install.json` | +| `service/` | Owner-only managed-service install metadata plus update intents and helper files | | `lsp-servers/`, `runtime/`, `workspaces/` | Installed tools and feature-specific materialized state | Database snapshots do not contain Git worktrees, clones, the master key, service metadata, or provider-side objects. Native agent and `gh` login files also normally live in the service user's home outside `~/.kandev` (for example `~/.codex` and `~/.config/gh`). The official container instead sets `HOME=/data/home`, so those CLI credentials live on its mounted volume. @@ -244,7 +244,7 @@ Configure update alerts in **Settings > General > Notifications > Notification E Before any update, finish or stop active sessions, create and export a database backup plus its master key, preserve unpushed Git work, and read release notes. - Desktop: use **Settings > System > Updates** when signed updater assets are available; otherwise install the new desktop package. -- Managed service: the current native CLI installer neither writes `service/install.json` nor supplies the service/install metadata required by the guarded one-click updater, so its **Apply update** action is unavailable. Run `brew upgrade kandev` or `npm install -g kandev@latest`, rerun `kandev service install` with the same install flags, then `kandev service restart`. Backend support for metadata-bearing legacy services is implementation detail, not the current native installation path. +- Managed user service: use **Settings > System > Updates > Apply update**. Kandev enables it only after verifying the managed unit or plist and `/service/install.json`. If the guarded update is unavailable or fails, run `brew upgrade kandev` or `npm install -g kandev@latest`, rerun `kandev service install` with the same install flags, then `kandev service restart`. System services always use that terminal flow with the required privileges. - Unmanaged CLI: run `brew upgrade kandev` or `npm install -g kandev@latest`, then restart the process. - Transient npx: start the desired release with `npx -y kandev@latest`; this does not update a persistent package. - Docker/Kubernetes: replace the image and recreate the workload. Do not treat an in-container package install as a durable update. @@ -285,7 +285,7 @@ drawer mirrors it as the saved left sequence followed by the saved right sequenc | Restored data looks stale | Whether the backend was restarted immediately | Quit/restart; do not keep using the old open database connections | | Logs page has no downloadable files | `logging.outputPath` and service/container logs | `stdout` is the default; use in-memory tail or configure a file sink | | Update check returns HTTP 429 | Time since last **Check now** | Wait at least 30 seconds; background checks retry every six hours | -| **Apply update** is absent | Install method shown on the Updates page | Expected for current native services; upgrade the package, reinstall the service with the same flags, and restart | +| **Apply update** is absent | Install mode/method and `/service/install.json` | Expected for system, unmanaged, local-checkout, or invalid-metadata installs. A managed npm, npx, or Homebrew user service should offer Apply; reinstall it with the same flags to refresh its identity and metadata, or use the manual package-manager flow. | | Metrics show unavailable | OS support, disk path, executor connectivity | Select supported metrics and verify permissions/network; the collector reports errors per sample | | Disk total exceeds filesystem expectation | Separate `data` and `backups` rows | Backups are counted twice in the UI total; use volume metrics for capacity decisions | | Legacy `/tmp/kandev-agent/*` uses disk | Process inventory and open-file references for the exact directory | This is data from older Kandev versions, not a current Storage resource. Stop Kandev, confirm no live process references the target, then remove only the confirmed-inactive legacy directory through host administration. | diff --git a/docs/public/run-as-a-service.md b/docs/public/run-as-a-service.md index b3d66d3353..56b6a00062 100644 --- a/docs/public/run-as-a-service.md +++ b/docs/public/run-as-a-service.md @@ -159,9 +159,9 @@ sudo journalctl -u kandev.service -n 200 --no-pager ## Upgrade safely -The current native service implementation does not write the legacy install metadata used by the Settings update workflow. Consequently, **Settings → System → Updates cannot apply an update to this service**, including a user service. +For a user service installed by `kandev service install`, use **Settings → System → Updates → Apply update** when a newer release is available. Kandev verifies the managed unit or plist and its owner-only `/service/install.json` metadata before enabling this action. System services still require a terminal update because they need elevated privileges. -Upgrade the package, reinstall with the same mode and home flags so absolute paths and bundle metadata are refreshed, then restart explicitly: +If the Apply action is unavailable or fails, upgrade the package manually, reinstall with the same mode and home flags so absolute paths and bundle metadata are refreshed, then restart explicitly: ```bash # npm example; use `brew upgrade kandev` for Homebrew diff --git a/docs/specs/native-kandev-cli/spec.md b/docs/specs/native-kandev-cli/spec.md index 429235b546..c5562d0f30 100644 --- a/docs/specs/native-kandev-cli/spec.md +++ b/docs/specs/native-kandev-cli/spec.md @@ -143,6 +143,7 @@ Transitions: - **GIVEN** the backend has requested a restart through the restart adapter, **WHEN** the launcher receives the restart request, **THEN** only the `kandev __backend` child process is replaced. - **GIVEN** the user presses Ctrl-C while Kandev is running, **WHEN** the launcher handles the signal, **THEN** it terminates the backend child process before exiting. - **GIVEN** a new service install on Linux or macOS, **WHEN** the user runs `kandev service install`, **THEN** the generated service unit executes the public `kandev` launcher path. +- **GIVEN** a native user-service install on Linux or macOS, **WHEN** the service starts, **THEN** its environment and durable install metadata identify it as a kandev-managed service and the guarded System-page updater can launch the native self-update helper. - **GIVEN** Node is managed by nvm/fnm/asdf/volta/mise and `node`, `npm`, or `npx` resolve from that manager at install time, **WHEN** the user runs `kandev service install`, **THEN** the generated service environment includes the detected Node tool bin directory in `PATH`. - **GIVEN** a user runs `npx kandev@latest --help`, **WHEN** npm has installed the platform runtime package, **THEN** the npm shim execs the runtime package's native `bin/kandev` and the user sees the same public help output.