diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1839cf1..63cc78c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,13 @@ jobs: - name: Test run: go test ./... + # The service mode adds process-global mutable state (the installed + # runtime path model behind a lock, and the standard logger's output + # being redirected by the service host), so the race detector must run + # over the whole suite on the Linux runner. + - name: Test (race detector) + run: go test ./... -race + # The Go jobs never touch the Docker surface. Exercise the real entrypoint # image so PUID/PGID, the non-root drop, the --user path, symlink refusal, and # argument validation cannot regress silently. @@ -85,6 +92,7 @@ jobs: - name: Test selected-cache path locality run: go test ./internal/server -run '^TestCacheSelectionRejectsNonLocalRepoBeforeJoining$' -count=1 -v + # The Windows desktop host is a second binary of HFDesk. Its defining # property is the PE subsystem of the produced file: the console host # stays a console subsystem executable and the desktop host is a GUI @@ -135,3 +143,62 @@ jobs: # skip themselves on a machine that cannot host them. - name: Test the desktop host lifecycle run: go test ./internal/desktop -count=1 -v + + # The service tests skip cleanly when the runner is not elevated, so the + # SCM registration itself is only exercised where the runner allows it. + - name: Test service package + run: go test ./internal/service -v + + # The command layer is platform independent, so it is exercised on + # Windows too: a check of the command line that only holds on Unix + # would otherwise stay unnoticed until a Windows installation. + - name: Test command layer + run: go test ./cmd/hfdesk -v + + # The launchd manager and the property list rendering are darwin-only code, + # so they need a macOS runner. The run executes the plist validation + # (plutil -lint) and the launchctl integration cycle where the runner + # allows it. + # + # The repository has never run its full suite on macOS, so the job covers + # the darwin-only code only: the launchd manager, the property list + # rendering, the plutil validation and the launchctl lifecycle. Widening + # it to the full suite is a separate, deliberate step. + go-macos: + name: Go checks (macOS) + runs-on: macos-latest + # A macOS runner is slow and a stalled lifecycle test would occupy it + # until the default limit of the platform. The limit is comfortably + # above the duration of the job. + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.25" + cache: true + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + + + # The service package covers the launchd manager, the plutil -lint + # validation of the generated property list and the launchctl + # lifecycle where the runner allows it. + - name: Test service package + run: go test ./internal/service -v + + + # The command layer is platform independent, so it is exercised on + # macOS too: a check of the command line that only holds on another + # platform would otherwise stay unnoticed until a macOS installation. + - name: Test command layer + run: go test ./cmd/hfdesk -v + diff --git a/AGENTS.md b/AGENTS.md index c9713e82..476cab97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,8 @@ browser-only presentation belongs in `internal/assets/static`. | `internal/server/` | HTTP API, jobs, settings, cache browser, websocket state | | `internal/assets/` | embedded frontend assets | | `internal/assets/static/` | browser UI files | +| `internal/service/` | service mode: scopes, lifecycle, and the systemd/launchd/SCM managers | +| `internal/runtimepaths/` | runtime path model for the config file, state root and log dir | | `pkg/hfdownloader/` | Hugging Face scan/download/cache/mirror logic | | `pkg/smartdl/` | model analysis and selectable download helpers | | `docs/` | API docs and screenshots | @@ -52,6 +54,7 @@ Read relevant Markdown before changing behavior: - General project behavior: `README.md` - API contracts: `docs/API.md` +- Service mode reference: `docs/SERVICE.md` - Contribution and commit conventions: `CONTRIBUTING.md` - Release history and user-visible changes: `CHANGELOG.md` - Current project notes: `TODO.md` @@ -83,10 +86,13 @@ change is documentation-only. ## Runtime entry points - Main process: `cmd/hfdesk/main.go` +- Service command family: `cmd/hfdesk/service.go` - Server routes: `internal/server/server.go`, `internal/server/api.go` - Job manager: `internal/server/jobs.go` - WebSocket hub: `internal/server/websocket.go` - Config persistence: `internal/server/config.go` +- Service layer: `internal/service/service.go`, `internal/service/manager_linux.go`, `internal/service/manager_darwin.go` +- Runtime paths: `internal/runtimepaths/runtimepaths.go` - Frontend app: `internal/assets/static/js/app.js` - Frontend styles: `internal/assets/static/css/style.css` - Downloader: `pkg/hfdownloader/downloader.go` @@ -187,6 +193,22 @@ Downloader bugs often produce silent corruption, so safety beats clever resume. - Exact matching and filter behavior must remain predictable for GGUF quants, sharded files, mmproj companions, safetensors, and datasets. +## Service mode rules + +- The supervisor owns the lifecycle: HFDesk stays a foreground process, with + no self-daemonization, no PID file, no shell wrapper, and no startup + shortcut. +- The service is headless: it never opens a browser and never shows a tray + icon. +- Service mode binds loopback by default; a non-loopback address must be + requested explicitly, and `install` and `status` report it. +- Credentials never enter a supervisor registration, a command line, or a unit + environment; they live in the service-owned config file. +- `uninstall` removes the registration only and never deletes models, cache, + config, jobs/history, or logs. +- Platform-specific supervisor code stays in `internal/service`; the shared + layer must not branch on the operating system. + ## Testing expectations - Downloader changes: run `go test ./pkg/hfdownloader` at minimum. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d92ff4f..39e7dd9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,14 @@ ### Added + +- Add a service mode: `hfdesk service install|uninstall|start|stop|restart|status` registers HFDesk with the native supervisor of the platform (systemd on Linux, launchd on macOS, the Windows Service Control Manager on Windows). The supervisor owns the lifecycle and HFDesk stays a headless foreground process, the instance resolves deterministic runtime paths of its scope (its own config file, state root and log directory), and it binds loopback by default; exposing it to the network needs an explicit `--listen`. Credentials stay in the service-owned config file and are never written into a supervisor registration (#142). + - A second Windows artifact, `hfdesk-desktop_windows_amd64_v1.3.0.exe`, is now built and published next to the console binary: it is a GUI program without a console window that runs the same HFDesk server in-process behind a notification-area icon and a small Runtime / Logs window showing the server status, the active job count and the live log records. A left click on the icon opens that window, the tray menu offers Open HFDesk, Runtime / Logs and Quit HFDesk (a graceful shutdown), and the host is single-instance per user and session: a second launch detects the running instance and opens the web UI of it. The Runtime / Logs window needs the Microsoft Edge WebView2 Evergreen runtime, and a missing runtime produces an actionable message while the server and the tray keep working. The desktop binary is a separate download and the console binary is unchanged (#141). - A structured logging pipeline with the new `--log-file` and `--log-level` options: every record carries a level, a timestamp, the component that produced it and structured attributes, the console sink writes to standard error so redirecting standard output keeps working, and the file sink rotates. The desktop host always writes a log file, because a GUI program has no console, and it lands in `/HFDesk/logs/hfdesk.log` unless `--log-file` names another path (#141). - Local-only runtime and log endpoints: `GET /api/runtime` reports the version, the status, the URL, the active job counts, the run id of the server and the log cursor, `GET /api/runtime/logs` returns a bounded page of the retained records, `GET /api/runtime/logs/stream` streams them as Server-Sent Events, and `POST /api/runtime/open-logs` and `POST /api/runtime/open-ui` open the log folder and the web UI of the running instance. They take no path and no URL from the caller, and three rules keep them local: the peer address has to be a loopback address, an `Origin` header of another site is rejected, and the `Host` header has to name the local machine, so a name of another site that is pointed at `127.0.0.1` cannot read the runtime data of the local machine. A request that breaks a rule is rejected with `403` and never reaches the handler (#141). - The requests of the runtime endpoints are written to the log at `debug` level and not at `info` level, so the polling of the Runtime / Logs window (every four seconds) does not fill the bounded log history that the window exists to show. Every other request keeps the `info` level, including a request that answers with an error (#141). + - Add a read-only Community feature for repository Discussions and Pull Requests: `GET /api/community/{owner}/{repo}` lists both types with paging and type/status filters, while `?num=N` returns a thread with sanitized comments and human-readable event summaries. The Model Details view now has Overview and Community tabs; the read-only Community tab is lazy-loaded, lists Discussions and Pull Requests, opens a thread with comments and events, and supports model and dataset repositories. The proxy uses the configured endpoint, proxy, and HF token without exposing the token to the browser (#136). - Report gated Hugging Face repositories as a dedicated access state instead of a partial analysis: the API returns HTTP 403 with error code `gated_repo`, the repository type, an accept link for the configured endpoint, and boolean-only token state, and the Model Details view shows configured-token and repository-agreement guidance without exposing the token. - Model Details now opens README, lineage, and related-download repository links in-place, with Back/Forward history across models, datasets, and revisions (#135). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44f892d8..020d8788 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,17 @@ hfdesk --port 9090 hfdesk --token hf_xxx ``` +**install the service** — working directory `.`; evidence: `README.md` + +```bash +hfdesk service install +hfdesk service status +``` + +The `hfdesk service` command family (`install`, `uninstall`, `start`, `stop`, +`restart`, `status`) registers HFDesk with the native supervisor of the +platform. See `docs/SERVICE.md` for the full reference. + ## Before Submitting **run all tests** — working directory `.`; evidence: `README.md` @@ -167,6 +178,8 @@ Components and important paths observed in the repository: - **hfdesk binary** — Compiled Go binary built in Dockerfile (paths: `Dockerfile`; evidence: `Dockerfile`) - **cmd/hfdesk/main.go** — Application entrypoint CLI/server (paths: `cmd/hfdesk/main.go`; evidence: `README.md`) - **internal/server package** — HTTP API, job scheduling, settings handling (paths: `internal/server`; evidence: `AGENTS.md`) +- **internal/service package** — Service mode: installation scopes, lifecycle, and the systemd/launchd/Windows SCM managers (paths: `internal/service`; evidence: `AGENTS.md`) +- **internal/runtimepaths package** — Runtime path model for the config file, state root, and log directory (paths: `internal/runtimepaths`; evidence: `AGENTS.md`) - **pkg/hfdownloader package** — Hugging Face cache scan and resumable download logic (paths: `pkg/hfdownloader`; evidence: `AGENTS.md`) - **pkg/smartdl package** — Model analysis and download recommendation utilities (paths: `pkg/smartdl`; evidence: `AGENTS.md`) diff --git a/README.md b/README.md index 920de81f..ae33347c 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,24 @@ Proxy example: } ``` +## Service mode + +HFDesk can also run as a supervised background service instead of an +interactive process: systemd on Linux, launchd on macOS, the Windows Service +Control Manager on Windows. The supervisor owns the lifecycle, and HFDesk stays +a headless foreground process inside it. + +```bash +hfdesk service install +hfdesk service status +``` + +Service mode binds loopback by default (`127.0.0.1:8080`). Exposing the +dashboard to the network needs an explicit `--listen 0.0.0.0:8080`. + +See [docs/SERVICE.md](docs/SERVICE.md) for the full reference: scopes, runtime +paths, credentials, logs, and per-platform notes. + ## API HFDesk's web UI is backed by a local JSON API. See [docs/API.md](docs/API.md) for endpoints, request shapes, and response formats. diff --git a/cmd/hfdesk/main.go b/cmd/hfdesk/main.go index c01338a0..cc17230a 100644 --- a/cmd/hfdesk/main.go +++ b/cmd/hfdesk/main.go @@ -20,6 +20,14 @@ import ( var Version = "dev" func main() { + // The service command family is a command of its own. It is dispatched + // before the interactive startup, so "hfdesk" without a "service" token + // keeps its exact behaviour: same flags, same defaults, same browser + // launch. + if isServiceCommand(os.Args[1:]) { + os.Exit(runServiceCommand(os.Args[2:], os.Stdout, os.Stderr)) + } + if err := run(); err != nil { os.Exit(1) } diff --git a/cmd/hfdesk/service.go b/cmd/hfdesk/service.go new file mode 100644 index 00000000..db980313 --- /dev/null +++ b/cmd/hfdesk/service.go @@ -0,0 +1,1285 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "net" + "os" + "os/signal" + "os/user" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" + "github.com/bashrusakh/hfdesk/internal/service" +) + +// The service command family is a command of its own: "hfdesk" without a +// "service" token keeps the interactive behaviour of the CLI untouched. +// +// The family is built with the standard library flag package. Every +// subcommand owns its own flag set, so a flag of one subcommand is never +// accepted by another one, and no CLI framework is introduced. +// +// Everything platform specific stays in internal/service: the command +// family only describes what the user asked for and reports what the +// supervisor answered. The command line never compares the operating +// system itself: the scope and the account of an instance are facts of the +// platform, and the service layer owns them. + +const ( + // serviceUsageExitCode is the exit code of a command line that could + // not be understood: a missing subcommand, an unknown subcommand or an + // invalid flag. + serviceUsageExitCode = 2 + + // serviceFailureExitCode is the exit code of an operation that failed. + serviceFailureExitCode = 1 + + // cacheRootNotRecorded is the wording of a cache root that no source + // names. + // + // The default of the scope and the personal cache of the invoking user + // are never reported instead: an instance that does not name a cache + // root does not use them, so naming them would send the reader to the + // wrong directory. + cacheRootNotRecorded = "not recorded (unknown)" + + // windowsServiceAccount is the identity of a machine-wide instance on + // Windows. + // + // The service control manager registers the built-in LocalService + // account, which has no name of its own: the value is the name the + // registration carries, so the summary names the account the instance + // really runs as and not the one a command line asked for. + windowsServiceAccount = `NT AUTHORITY\LocalService` + + // userScopeFlag is the name of the flag that selects the per-user + // scope. + // + // The flag is written in one place, so the hint of the summary names + // the same scope selector the parser of the command line accepts. + userScopeFlag = "user" + + // systemScopeFlag is the name of the flag that selects the + // machine-wide scope. + systemScopeFlag = "system" +) + +// serviceSubcommands lists the subcommands of the family in the order the +// usage block prints them. +var serviceSubcommands = []string{ + "install", + "uninstall", + "start", + "stop", + "restart", + "status", + "run", +} + +// errHelpRequested reports that the user asked for the help of a command. It +// is not a failure: the command exits with 0. +var errHelpRequested = errors.New("help requested") + +// serviceUsageError reports a command line that could not be understood. +// +// The type is the marker of the exit code 2: the family answers a command +// line it cannot understand with the exit code 2 and an operation that was +// understood but failed with the exit code 1. The kind of the failure +// travels with the error, so the classification is the same for every +// subcommand and never depends on the call site that reports the error. +type serviceUsageError struct { + err error +} + +// newServiceUsageError marks an error as a command line that could not be +// understood. +func newServiceUsageError(err error) error { + if err == nil { + return nil + } + return &serviceUsageError{err: err} +} + +// Error renders the message of the wrapped error. +func (e *serviceUsageError) Error() string { return e.err.Error() } + +// Unwrap gives access to the wrapped error. +func (e *serviceUsageError) Unwrap() error { return e.err } + +// isServiceUsageError reports whether the error reports a command line that +// could not be understood. +func isServiceUsageError(err error) bool { + var usage *serviceUsageError + return errors.As(err, &usage) +} + +// newServiceManager opens the manager of the platform supervisor of a scope. +// +// The variable is the seam of the tests: the command family is exercised +// without touching the system of the test run. +var newServiceManager = service.New + +// versionLikeSegment matches a "digit.digit" segment of a file name. +// +// The heuristic is deliberately simple: a release artifact carries its +// version in its name ("hfdesk-1.2.3-linux-amd64", "hfdesk_0.9.1") and the +// next release replaces the file. A plain "hfdesk" does not match. +var versionLikeSegment = regexp.MustCompile(`\d+\.\d+`) + +// isServiceCommand reports whether the arguments select the service command +// family. +// +// The check is exact: only the bare token "service" selects the family, so +// every other invocation keeps the interactive behaviour of the CLI. +func isServiceCommand(args []string) bool { + return len(args) > 0 && args[0] == "service" +} + +// runServiceCommand runs the "hfdesk service ..." command family and +// returns the exit code of the process. +// +// The arguments are the ones after the "service" token. The output streams +// are arguments as well, so the whole family can be exercised without +// starting a process. +func runServiceCommand(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + code := serviceCommandResult(stderr, newServiceUsageError(errors.New("a subcommand is required"))) + writeServiceUsage(stderr) + return code + } + + sub, rest := args[0], args[1:] + + switch sub { + case "install": + return serviceCommandResult(stderr, serviceInstall(rest, stdout)) + case "uninstall": + return serviceCommandResult(stderr, serviceUninstall(rest, stdout)) + case "start": + return serviceCommandResult(stderr, serviceStart(rest, stdout)) + case "stop": + return serviceCommandResult(stderr, serviceStop(rest, stdout)) + case "restart": + return serviceCommandResult(stderr, serviceRestart(rest, stdout)) + case "status": + return serviceStatus(rest, stdout, stderr) + case "run": + return serviceCommandResult(stderr, serviceRun(rest, stdout)) + case "help", "-h", "--help": + writeServiceUsage(stdout) + return 0 + default: + code := serviceCommandResult(stderr, newServiceUsageError(fmt.Errorf("unknown subcommand %q", sub))) + fmt.Fprintf(stderr, "Valid subcommands: %s\n", strings.Join(serviceSubcommands, ", ")) + writeServiceUsage(stderr) + return code + } +} + +// serviceCommandResult prints the error of a failed command and returns the +// exit code of the process. +// +// The exit code is the kind of the failure and nothing else: a command line +// that could not be understood exits with 2, an operation that was +// understood but failed exits with 1. The kind travels with the error, so +// every subcommand classifies a failure the same way. +func serviceCommandResult(stderr io.Writer, err error) int { + switch { + case err == nil: + return 0 + case errors.Is(err, errHelpRequested): + return 0 + } + + fmt.Fprintf(stderr, "hfdesk service: %s\n", serviceErrorText(err)) + if isServiceUsageError(err) { + return serviceUsageExitCode + } + return serviceFailureExitCode +} + +// serviceErrorText renders an error of the service layer for the command +// line. The package prefixes its errors with "service: ", which the command +// line already carries in its own prefix, so it is removed once. +func serviceErrorText(err error) string { + return strings.TrimPrefix(err.Error(), "service: ") +} + +// writeServiceUsage prints the usage block of the service command family. +// +// An option of a single subcommand is listed with the subcommand that owns +// it: a family level option list would offer a flag that the other +// subcommands reject. +func writeServiceUsage(w io.Writer) { + fmt.Fprint(w, "Usage: hfdesk service [options]\n\n") + fmt.Fprint(w, "Subcommands:\n") + for _, line := range []struct{ name, summary string }{ + {"install", "register the service with the platform supervisor"}, + {"uninstall", "remove the registration (the user data is kept)"}, + {"start", "start the registered service"}, + {"stop", "stop the registered service"}, + {"restart", "stop and start the registered service"}, + {"status", "report the scope, the state and the paths of the instance"}, + {"run", "run the headless host (internal: the supervisor invokes it)"}, + } { + fmt.Fprintf(w, " %-10s %s\n", line.name, line.summary) + } + fmt.Fprintf(w, ` +Scope: + --user per-user installation (the default on Linux and macOS) + --system machine-wide installation (the default on Windows) + +Options of the "install" subcommand: + --listen HOST:PORT + bind address of the instance (default %s) + --service-user ACCOUNT + account a machine-wide instance runs as + (the machine-wide scope of Linux and macOS; the + account cannot be chosen on Windows; default: the + service account of the platform) + --executable PATH + absolute path of the binary to register + (default: the invoked binary) + +Exit codes: + 0 the operation succeeded + 1 the operation failed: the service is not installed, the elevation is + missing, the supervisor refused the operation + 2 the command line could not be understood: an unknown subcommand or + flag, a relative path, an unusable --executable, an unparsable --listen + +"hfdesk service status" exits with 0 when the service is running, stopped, +starting or stopping, and with 1 when it is not installed, broken, failed or +in an unknown state, so a script can branch on it. + +Run "hfdesk service --help" for the flags of a subcommand. +`, service.DefaultListen) +} + +// writeSubcommandUsage prints the usage of a subcommand: its summary and +// the flags it registers. +func writeSubcommandUsage(w io.Writer, fs *flag.FlagSet, summary string) { + fmt.Fprintf(w, "Usage: %s [options]\n\n%s\n\nOptions:\n", fs.Name(), summary) + fs.SetOutput(w) + fs.PrintDefaults() + fmt.Fprint(w, "\nExit codes: 0 the operation succeeded, 1 the operation failed, 2 the command line could not be understood.\n") +} + +// writeField writes one "Key: value" line of a summary block. The keys are +// padded to a common width, so the values of a block line up. +func writeField(w io.Writer, key, value string) { + if value == "" { + value = "unknown" + } + fmt.Fprintf(w, "%-14s %s\n", key+":", value) +} + +// platformFacts are the facts of the platform that the command line decides +// with. +// +// The facts come from the service layer: it owns everything that depends on +// the operating system, so the command line never compares one itself. +type platformFacts struct { + // defaultScope is the scope of an installation that does not ask for + // one. + defaultScope service.Scope + // supportsUserScope reports whether a per-user installation exists. + supportsUserScope bool + // supportsNamedServiceAccount reports whether the account of a + // machine-wide instance can be named. + supportsNamedServiceAccount bool +} + +// hostPlatform holds the facts of the platform the command line runs on. +// +// The variable is the seam of the tests: the decisions of the command line +// that depend on the platform (the default scope, the scope of an +// installation, the account of an instance) are checked on every platform, +// so the behaviour of a Windows instance is covered by a test that runs on +// Linux as well. +var hostPlatform = platformFactsOfTheHost() + +// platformFactsOfTheHost returns the facts of the platform of the process. +func platformFactsOfTheHost() platformFacts { + return platformFacts{ + defaultScope: service.DefaultScope(), + supportsUserScope: service.SupportsUserScope(), + supportsNamedServiceAccount: service.SupportsNamedServiceAccount(), + } +} + +// defaultScope returns the scope of an installation that does not ask for +// one. +// +// The default scope is a fact of the platform: the service layer owns it. +func defaultScope() service.Scope { + return hostPlatform.defaultScope +} + +// resolveScope turns the scope selector of the command line into the scope +// of the installation. +// +// The selectors are mutually exclusive: two scopes are two installations, +// so an ambiguous command line is rejected instead of picking one of them. +// +// A rejected selector is a command line that could not be understood, so the +// error carries that kind. +func resolveScope(user, system bool) (service.Scope, error) { + switch { + case user && system: + return "", newServiceUsageError(errors.New("--user and --system are mutually exclusive, choose one scope")) + case user: + if !hostPlatform.supportsUserScope { + return "", newServiceUsageError(errors.New("--user is not available on windows: the service control manager registers machine-wide services only, use --system")) + } + return service.ScopeUser, nil + case system: + return service.ScopeSystem, nil + default: + return defaultScope(), nil + } +} + +// scopeFlags holds the scope selector of a command. +type scopeFlags struct { + user bool + system bool +} + +// registerScopeFlags registers the scope selector on a flag set. +func registerScopeFlags(fs *flag.FlagSet, flags *scopeFlags) { + fs.BoolVar(&flags.user, userScopeFlag, false, "per-user installation (the default on Linux and macOS)") + fs.BoolVar(&flags.system, systemScopeFlag, false, "machine-wide installation (the default on Windows)") +} + +// pathFlag is one path flag of the command line. +type pathFlag struct { + name string + value string +} + +// validateAbsolutePaths rejects a relative path. +// +// A supervised process must not resolve a path against the working +// directory of the supervisor, so a relative path is a mistake and not a +// default. +// +// A rejected path is a command line that could not be understood, so the +// error carries that kind. +func validateAbsolutePaths(paths []pathFlag) error { + for _, p := range paths { + if p.value == "" { + continue + } + if !filepath.IsAbs(p.value) { + return newServiceUsageError(fmt.Errorf("%s %q must be an absolute path", p.name, p.value)) + } + } + return nil +} + +// validateListen validates a bind address of the command line and returns +// its canonical form. +// +// The rules are the rules of the service layer: the address must name a +// host and a port, and an empty host is rejected because it binds every +// interface. +// +// A rejected address is a command line that could not be understood, so the +// error carries that kind. +func validateListen(listen string) (string, error) { + host, port, err := service.ParseListen(listen) + if err != nil { + return "", newServiceUsageError(err) + } + return net.JoinHostPort(host, strconv.Itoa(port)), nil +} + +// resolveExecutable returns the absolute path of the binary the +// registration points at. +// +// An explicit path wins: the user names the stable path of the +// installation, which may differ from the binary they happen to run. +// +// Without an explicit path the invocation path is preferred: the path the +// user typed is the name the installation is known by, and a symbolic link +// on that path keeps its name in the registration. The invocation path is +// only usable when it names a file: a bare name ("hfdesk") comes from the +// search path of the shell and must not be resolved against the working +// directory of the install command. +// +// The resolved path of the running binary (os.Executable) is the fallback. +// It is always a real file, but it is the target of a symbolic link, so a +// registration that uses it points at a version-stamped release artifact +// instead of at the stable name of the installation. +// +// A named path that cannot be registered is a command line that could not be +// understood, so its error carries that kind. A binary of the process that +// cannot be located is not a mistake of the command line: the operation +// failed, so its error keeps the kind of a failed operation. +func resolveExecutable(explicit, invocation string) (string, error) { + if explicit != "" { + return validateExecutablePath(explicit) + } + if path, ok := invocationExecutable(invocation); ok { + return path, nil + } + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("the path of the running binary is unknown: %w", err) + } + abs, err := filepath.Abs(exe) + if err != nil { + return "", fmt.Errorf("the path of the running binary %q is not absolute: %w", exe, err) + } + return abs, nil +} + +// invocationExecutable returns the absolute path of the invoked binary when +// the invocation path is usable. +// +// The invocation path is usable when it names a path (it contains a +// separator), so a bare name from the search path of the shell is never +// resolved against the working directory, and when it is an existing +// regular file. +func invocationExecutable(invocation string) (string, bool) { + if invocation == "" || !hasPathSeparator(invocation) { + return "", false + } + abs, err := filepath.Abs(invocation) + if err != nil { + return "", false + } + info, err := os.Stat(abs) + if err != nil || !info.Mode().IsRegular() { + return "", false + } + return abs, true +} + +// hasPathSeparator reports whether the path names a file instead of a +// command of the search path. +// +// Both separators are accepted, so a path written with the separator of the +// other platform is not mistaken for a bare name. +func hasPathSeparator(path string) bool { + return strings.ContainsRune(path, '/') || strings.ContainsRune(path, filepath.Separator) +} + +// validateExecutablePath checks that the path can be registered as the +// program of the instance and returns it. +// +// The path must be absolute (a supervised process must not resolve a +// relative path against the working directory of the supervisor), it must +// exist and it must be a regular file. +// +// The execute bit is not a check of the command line: it is a fact of the +// platform, and the platform manager owns it. Windows reports a regular +// file without any execute bit, so a check of the permission bits would +// refuse every binary of a Windows installation. The managers of the +// platforms that require the bit enforce it themselves. +// +// A rejected path is a command line that could not be understood, so the +// error carries that kind. +func validateExecutablePath(path string) (string, error) { + if !filepath.IsAbs(path) { + return "", newServiceUsageError(fmt.Errorf("the executable %q must be an absolute path", path)) + } + info, err := os.Stat(path) + if err != nil { + return "", newServiceUsageError(fmt.Errorf("the executable %q is not usable: %w", path, err)) + } + if !info.Mode().IsRegular() { + return "", newServiceUsageError(fmt.Errorf("the executable %q is not a regular file", path)) + } + return path, nil +} + +// installFlags holds the flags of the "service install" command. +type installFlags struct { + scopeFlags + listen string + config string + stateDir string + logDir string + cacheDir string + hubDir string + localDir string + serviceUser string + executable string +} + +// buildInstallOptions assembles the options of an installation from the +// scope, the flags of the command line and the path of the binary. +// +// The default runtime paths of the scope are the base and an explicit path +// flag overrides the matching default. The config file keeps its own +// directory: the parent of the config file is the directory the +// installation prepares. +// +// The cache directories are derived from the runtime paths, so they are +// resolved after the overrides: a machine-wide instance keeps its cache +// inside its own state root even when the state root was overridden. +// +// The cache root and the hub directory are one layout, so an explicit cache +// root moves the hub directory with it: a registration that names a cache +// root and the hub of another tree would write the blobs of the instance +// into a directory its own cache root does not contain. A hub directory that +// was named explicitly is honoured as given: it separates the two locations +// on purpose, the same independence HF_HOME and HF_HUB_CACHE have. +// +// The service account belongs to the machine-wide scope only: a user scope +// instance runs as the user itself, so naming an account there is a +// mistake of the command line and not a default. +// +// A named account of a user scope instance is a command line that could not +// be understood, so the error carries that kind. +// +// The account of a machine-wide instance cannot be chosen on Windows: the +// service control manager registers the instance as the built-in +// LocalService account, and a service under a user account would need a +// credential, which must never reach the command line of an installation. +// A named account there is a command line that could not be understood as +// well, so its error carries that kind too. +func buildInstallOptions(scope service.Scope, flags installFlags, executable string) (service.InstallOptions, error) { + if flags.serviceUser != "" { + if scope == service.ScopeUser { + return service.InstallOptions{}, newServiceUsageError(errors.New("--service-user applies to a machine-wide installation only: a user scope instance runs as the user itself, drop the option or use --system")) + } + if !hostPlatform.supportsNamedServiceAccount { + return service.InstallOptions{}, newServiceUsageError(fmt.Errorf("--service-user is not available on windows: the machine-wide instance runs as the built-in %s account and the account cannot be chosen on this platform (a service under a user account needs a credential, and a credential must never reach a command line), drop the option", windowsServiceAccount)) + } + } + + paths, err := service.DefaultPaths(scope) + if err != nil { + return service.InstallOptions{}, err + } + if flags.config != "" { + paths.ConfigFile = flags.config + } + if flags.stateDir != "" { + paths.StateDir = flags.stateDir + } + if flags.logDir != "" { + paths.LogDir = flags.logDir + } + + cacheRoot, hubDir := service.DefaultCacheDirs(scope, paths) + if flags.cacheDir != "" { + cacheRoot = flags.cacheDir + if flags.hubDir == "" { + hubDir = filepath.Join(cacheRoot, "hub") + } + } + if flags.hubDir != "" { + hubDir = flags.hubDir + } + + listen := flags.listen + if listen == "" { + listen = service.DefaultListen + } + + return service.InstallOptions{ + Scope: scope, + Executable: executable, + Listen: listen, + Paths: paths, + CacheDir: cacheRoot, + HubDir: hubDir, + LocalDir: flags.localDir, + ServiceUser: flags.serviceUser, + }, nil +} + +// serviceInstall registers the service with the platform supervisor of the +// scope. +// +// The command validates its own arguments before it touches the system. It +// never starts the instance itself: the supervisor may start a registration +// that asks for it (a LaunchAgent with RunAtLoad starts it), so the summary +// reports the state that was reached instead of a fixed claim. +func serviceInstall(args []string, stdout io.Writer) error { + flags := installFlags{} + fs := flag.NewFlagSet("hfdesk service install", flag.ContinueOnError) + fs.SetOutput(io.Discard) + registerScopeFlags(fs, &flags.scopeFlags) + fs.StringVar(&flags.listen, "listen", service.DefaultListen, "host:port the instance binds") + fs.StringVar(&flags.config, "config", "", "absolute path of the config file the instance owns") + fs.StringVar(&flags.stateDir, "state-dir", "", "absolute path of the state directory of the instance") + fs.StringVar(&flags.logDir, "log-dir", "", "absolute path of the log directory of the instance") + fs.StringVar(&flags.cacheDir, "cache-dir", "", "absolute path of the Hugging Face cache root") + fs.StringVar(&flags.hubDir, "hub-dir", "", "absolute path of the Hugging Face hub cache directory") + fs.StringVar(&flags.localDir, "local-dir", "", "absolute path of the flat output directory") + fs.StringVar(&flags.serviceUser, "service-user", "", "account the machine-wide instance runs as (the machine-wide scope of Linux and macOS; the account cannot be chosen on Windows)") + fs.StringVar(&flags.executable, "executable", "", "absolute path of the binary to register (default: the invoked binary)") + + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + writeSubcommandUsage(stdout, fs, "Register HFDesk with the platform supervisor of the scope.\n\nThe registration is written. The summary reports the state the supervisor\nreached: a registration that starts the instance starts it.") + return errHelpRequested + } + return newServiceUsageError(fmt.Errorf("invalid command line: %w", err)) + } + if rest := fs.Args(); len(rest) > 0 { + return newServiceUsageError(fmt.Errorf("unexpected argument %q, the command takes flags only", rest[0])) + } + + scope, err := resolveScope(flags.user, flags.system) + if err != nil { + return err + } + listen, err := validateListen(flags.listen) + if err != nil { + return err + } + if err := validateAbsolutePaths([]pathFlag{ + {"--config", flags.config}, + {"--state-dir", flags.stateDir}, + {"--log-dir", flags.logDir}, + {"--cache-dir", flags.cacheDir}, + {"--hub-dir", flags.hubDir}, + {"--local-dir", flags.localDir}, + }); err != nil { + return err + } + flags.listen = listen + + // The registration points at the path the user knows the installation + // by: the invoked binary, or the path the user named explicitly. + executable, err := resolveExecutable(flags.executable, os.Args[0]) + if err != nil { + return err + } + + opts, err := buildInstallOptions(scope, flags, executable) + if err != nil { + return err + } + + mgr, err := newServiceManager(scope) + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := mgr.Install(ctx, opts); err != nil { + return err + } + + writeInstallSummary(stdout, ctx, mgr, scope, opts) + return nil +} + +// writeInstallSummary prints what was registered. +// +// The path of the registration and the state of the instance are known only +// after the installation, so both are read back from the supervisor. +// +// The scope of the instance is carried into the hint that starts it: the +// hint has to start the instance that was registered and not the one of +// the default scope of the platform. +// +// The cache root and the hub directory are the effective ones: the config +// file of the instance wins over the registered values, because the cache +// directory is changed in the settings of the instance and the change has to +// survive a restart. +func writeInstallSummary(w io.Writer, ctx context.Context, mgr service.Manager, scope service.Scope, opts service.InstallOptions) { + status, statusErr := mgr.Status(ctx) + + cacheRoot, hubDir := effectiveCacheDirs(opts) + + writeField(w, "Service", service.ServiceName) + writeField(w, "Scope", string(scope)) + writeField(w, "Account", installAccount(scope, opts.ServiceUser)) + writeField(w, "Registration", registrationPath(status)) + writeField(w, "Executable", opts.Executable) + writeField(w, "Listen", opts.Listen) + writeField(w, "Config file", opts.Paths.ConfigFile) + writeField(w, "State dir", opts.Paths.StateDir) + writeField(w, "Log dir", opts.Paths.LogDir) + writeField(w, "Cache root", cacheRoot) + writeField(w, "Hub dir", hubDir) + + if warning := nonLoopbackWarning(opts.Listen); warning != "" { + fmt.Fprintln(w) + fmt.Fprint(w, warning) + } + if warning := upgradeWarning(opts.Executable); warning != "" { + fmt.Fprintln(w) + fmt.Fprint(w, warning) + } + + fmt.Fprintln(w) + fmt.Fprintln(w, "The service was registered.") + writeInstallState(w, scope, status, statusErr) +} + +// installAccount returns the account the registered instance runs as. +// +// A user scope instance runs as the user itself: it keeps the identity of +// the session and the access to the own cache of the user. +// +// A machine-wide instance runs as the dedicated service account of the +// platform: the account the user named, or the default of the platform. +// Windows registers the built-in LocalService account, which has no name +// of its own: there the account of the registration is the one that is +// reported, and an account of the command line is never reported, because +// the registration does not use it. +func installAccount(scope service.Scope, serviceUser string) string { + switch { + case scope == service.ScopeUser: + return invokingUser() + case !hostPlatform.supportsNamedServiceAccount: + return windowsServiceAccount + case serviceUser != "": + return serviceUser + default: + return service.DefaultServiceUser() + } +} + +// invokingUser returns the name of the user that runs the command. +// +// The name is empty when it cannot be determined: a summary that names no +// account says "unknown" instead of guessing one. +func invokingUser() string { + if u, err := user.Current(); err == nil && u.Username != "" { + return u.Username + } + if name := os.Getenv("USER"); name != "" { + return name + } + return "" +} + +// effectiveCacheDirs returns the cache root and the hub directory the +// instance uses. +// +// The rule is the one the headless host applies to the running instance: a +// config file that defines a cache root owns the whole cache layout of the +// instance, so the hub directory is the "hub" child of that root. Without a +// cache root from the config file the registration keeps its hub directory, +// and a registration without one falls back to the "hub" child of the +// effective cache root. +// +// The two directories are reported as one pair: a summary that named a cache +// root without its hub directory would hide a layout that sends the blobs of +// the instance somewhere else. +func effectiveCacheDirs(opts service.InstallOptions) (string, string) { + cacheRoot := service.EffectiveCacheDir(opts.Paths.ConfigFile, opts.CacheDir) + + // The registered value is empty when the config file of the instance + // defines a cache root of its own: the config file then owns the whole + // layout of the instance. + if configured := service.EffectiveCacheDir(opts.Paths.ConfigFile, ""); configured != "" { + return configured, filepath.Join(configured, "hub") + } + + hubDir := opts.HubDir + if hubDir == "" && cacheRoot != "" { + hubDir = filepath.Join(cacheRoot, "hub") + } + return cacheRoot, hubDir +} + +// writeInstallState prints the state the instance reached after the +// registration and the hint that starts it. +// +// The state is read back from the supervisor: a registration of some +// platforms starts the instance, so the summary cannot claim that the +// instance was not started. A state the supervisor did not report is +// reported as unknown, and the successful registration is reported +// nevertheless. +// +// The hint names the scope of the instance that was registered: the +// default scope of the platform is not the scope of every installation, +// so a hint without the scope would start another instance than the one +// that was just registered. +func writeInstallState(w io.Writer, scope service.Scope, status service.Status, statusErr error) { + switch { + case statusErr != nil: + writeField(w, "State", fmt.Sprintf("unknown (the state could not be determined: %s)", serviceErrorText(statusErr))) + case status.State == "": + writeField(w, "State", "unknown (the supervisor did not report a state)") + default: + writeField(w, "State", string(status.State)) + } + + // The hint is printed for every state that is not an active one. The + // instance then has to be started by the user, and a state that could + // not be read back at all does not say that the instance runs. + if status.State != service.StateRunning && status.State != service.StateStarting { + fmt.Fprintf(w, "Start it with: %s\n", startCommand(scope)) + } +} + +// startCommand returns the command that starts the instance of the scope. +// +// The scope selector is part of the command: the default scope of the +// platform is the user scope on Linux and macOS and the machine-wide scope +// on Windows, so a command without the selector would start another +// instance than the one that was registered. +// +// A scope the family does not know has no selector: the command then +// starts the default scope of the platform. +func startCommand(scope service.Scope) string { + command := "hfdesk service start" + switch scope { + case service.ScopeUser: + return command + " --" + userScopeFlag + case service.ScopeSystem: + return command + " --" + systemScopeFlag + default: + return command + } +} + +// registrationPath returns the path of the supervisor registration, or +// "unknown" when the supervisor did not report one. +func registrationPath(status service.Status) string { + if status.UnitPath == "" { + return "unknown" + } + return status.UnitPath +} + +// serviceUninstall removes the registration of the service. +// +// The registration is removed and nothing else: the models, the cache, the +// config file, the job history and the logs of the instance stay where +// they are. +func serviceUninstall(args []string, stdout io.Writer) error { + mgr, scope, err := newLifecycleManager("hfdesk service uninstall", args, stdout, + "Remove the registration of the service.\n\nThe user data is kept: models, cache, config, jobs/history and logs are not touched.") + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // The paths of the registration are read before it is removed, so the + // report can name the directories the data stays in. The state of the + // scope is read as well, so the report can state what the removal did. + paths, cacheRoot, registered := instanceDataPaths(ctx, mgr, scope) + + if err := mgr.Uninstall(ctx); err != nil { + return err + } + + writeUninstallSummary(stdout, scope, paths, cacheRoot, registered) + return nil +} + +// writeUninstallSummary prints what was removed and what was kept. +// +// The summary states what the uninstallation did: a registration that was +// there is removed, and a scope without a registration has nothing to +// remove. A summary that always claims a removal hides the difference +// between the two. +// +// The directories of the kept data are named, so the reader knows where the +// models, the cache, the config file, the job history and the logs of the +// instance stay. +func writeUninstallSummary(w io.Writer, scope service.Scope, paths runtimepaths.Paths, cacheRoot string, registered bool) { + if registered { + fmt.Fprintf(w, "The registration of %s (%s scope) was removed.\n", service.ServiceName, scope) + } else { + fmt.Fprintf(w, "There was no registration of %s (%s scope) to remove.\n", service.ServiceName, scope) + } + fmt.Fprintln(w, "The user data was kept: models, cache, config, jobs/history and logs are still there.") + writeField(w, "Config file", paths.ConfigFile) + writeField(w, "State dir", paths.StateDir) + writeField(w, "Log dir", paths.LogDir) + writeField(w, "Cache root", cacheRoot) +} + +// instanceDataPaths returns the runtime paths and the cache root of the +// instance of the scope, and whether the scope had a registration. +// +// The registration is read first, so the report names the directories the +// data of the registration stays in. A registration that cannot be read +// falls back to the default paths of the scope. +// +// The cache root is the effective one of the instance: the default of the +// scope and the personal cache of the invoking user are never reported +// instead, because the instance does not use them. +// +// A scope without a registration is reported as such: the supervisor answers +// it with the state "not-installed", and the removal of such a scope has +// nothing to remove. A scope whose state the supervisor did not report +// counts as a registration: the removal succeeded, so whatever was +// registered is gone. +func instanceDataPaths(ctx context.Context, mgr service.Manager, scope service.Scope) (runtimepaths.Paths, string, bool) { + paths, err := service.DefaultPaths(scope) + if err != nil { + paths = runtimepaths.Paths{} + } + cacheRoot := cacheRootNotRecorded + registered := true + if status, statusErr := mgr.Status(ctx); statusErr == nil { + registered = status.State != service.StateNotInstalled + if status.Paths.ConfigFile != "" { + paths.ConfigFile = status.Paths.ConfigFile + } + if status.Paths.StateDir != "" { + paths.StateDir = status.Paths.StateDir + } + if status.Paths.LogDir != "" { + paths.LogDir = status.Paths.LogDir + } + cacheRoot = effectiveCacheRoot(status) + } + return paths, cacheRoot, registered +} + +// effectiveCacheRoot returns the Hugging Face cache root the instance uses. +// +// The supervisor reports the cache root of the registration, and the config +// file of the instance wins over it: the cache directory is changed in the +// settings of the instance, and the changed value has to survive a restart +// of the instance. +// +// A cache root that neither source names is not recorded. The default of +// the scope and the personal cache of the invoking user are never reported +// instead: the instance does not use them, so naming them would send the +// reader to the wrong directory. +func effectiveCacheRoot(status service.Status) string { + if dir := service.EffectiveCacheDir(status.Paths.ConfigFile, status.CacheDir); dir != "" { + return dir + } + return cacheRootNotRecorded +} + +// newLifecycleManager prepares a lifecycle command: it parses the scope +// selector and opens the manager of the scope. +// +// A command line the parser rejects is a command line that could not be +// understood, so its error carries that kind. An error of the platform +// manager is a failed operation and keeps the kind of one. +func newLifecycleManager(name string, args []string, stdout io.Writer, summary string) (service.Manager, service.Scope, error) { + flags := scopeFlags{} + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(io.Discard) + registerScopeFlags(fs, &flags) + + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + writeSubcommandUsage(stdout, fs, summary) + return nil, "", errHelpRequested + } + return nil, "", newServiceUsageError(fmt.Errorf("invalid command line: %w", err)) + } + if rest := fs.Args(); len(rest) > 0 { + return nil, "", newServiceUsageError(fmt.Errorf("unexpected argument %q, the command takes flags only", rest[0])) + } + + scope, err := resolveScope(flags.user, flags.system) + if err != nil { + return nil, "", err + } + mgr, err := newServiceManager(scope) + if err != nil { + return nil, "", err + } + return mgr, scope, nil +} + +// serviceStart starts the registered service. +func serviceStart(args []string, stdout io.Writer) error { + mgr, _, err := newLifecycleManager("hfdesk service start", args, stdout, + "Start the registered service.") + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := mgr.Start(ctx); err != nil { + return err + } + fmt.Fprintf(stdout, "The %s service was started.\n", service.ServiceName) + writeLifecycleState(ctx, mgr, stdout) + return nil +} + +// serviceStop stops the registered service. +func serviceStop(args []string, stdout io.Writer) error { + mgr, _, err := newLifecycleManager("hfdesk service stop", args, stdout, + "Stop the registered service.") + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := mgr.Stop(ctx); err != nil { + return err + } + fmt.Fprintf(stdout, "The %s service was stopped.\n", service.ServiceName) + writeLifecycleState(ctx, mgr, stdout) + return nil +} + +// serviceRestart stops and starts the registered service. +func serviceRestart(args []string, stdout io.Writer) error { + mgr, _, err := newLifecycleManager("hfdesk service restart", args, stdout, + "Stop and start the registered service.") + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if err := mgr.Restart(ctx); err != nil { + return err + } + fmt.Fprintf(stdout, "The %s service was restarted.\n", service.ServiceName) + writeLifecycleState(ctx, mgr, stdout) + return nil +} + +// writeLifecycleState prints the state of the instance after a lifecycle +// command, when the supervisor can report it. +func writeLifecycleState(ctx context.Context, mgr service.Manager, w io.Writer) { + status, err := mgr.Status(ctx) + if err != nil || status.State == "" { + return + } + writeField(w, "State", string(status.State)) +} + +// serviceStatus prints the state of the registered service. +// +// The exit code tells a script whether the instance is in a usable state: +// 0 for a running, stopped, starting or stopping instance, 1 for an +// instance that is not installed, broken, failed or unknown. +func serviceStatus(args []string, stdout, stderr io.Writer) int { + mgr, scope, err := newLifecycleManager("hfdesk service status", args, stdout, + "Report the scope, the state and the paths of the instance.\n\n"+ + "The exit code tells a script whether the instance is in a usable state:\n"+ + "0 for a running, stopped, starting or stopping instance, 1 for an\n"+ + "instance that is not installed, broken, failed or in an unknown state.") + if err != nil { + return serviceCommandResult(stderr, err) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + status, statusErr := mgr.Status(ctx) + renderStatus(stdout, scope, status) + if statusErr != nil { + return serviceCommandResult(stderr, statusErr) + } + return statusExitCode(status.State) +} + +// statusExitCode maps the state of an instance onto the exit code of the +// status command. +func statusExitCode(state service.State) int { + switch state { + case service.StateRunning, service.StateStopped, service.StateStarting, service.StateStopping: + return 0 + default: + return serviceFailureExitCode + } +} + +// renderStatus prints the state of an instance as a "Key: value" block. +func renderStatus(w io.Writer, scope service.Scope, status service.Status) { + name := status.Name + if name == "" { + name = service.ServiceName + } + reportedScope := status.Scope + if reportedScope == "" { + reportedScope = scope + } + + // A registration that does not exist carries no paths; the defaults of + // the scope are the paths such an instance would use. + paths := status.Paths + if paths.ConfigFile == "" && paths.StateDir == "" && paths.LogDir == "" { + if defaults, err := service.DefaultPaths(scope); err == nil { + paths = defaults + } + } + // The cache root is not derived from the paths above: the defaults of + // the scope describe the paths such an instance would use, not the + // cache root it uses. The effective cache root of the instance is + // reported instead. + cacheRoot := effectiveCacheRoot(status) + + writeField(w, "Service", name) + writeField(w, "Scope", string(reportedScope)) + writeField(w, "State", string(status.State)) + writeField(w, "Registration", status.UnitPath) + writeField(w, "Executable", status.Executable) + writeField(w, "Listen", status.Listen) + writeField(w, "Config file", paths.ConfigFile) + writeField(w, "State dir", paths.StateDir) + writeField(w, "Log dir", paths.LogDir) + writeField(w, "Cache root", cacheRoot) + if status.Detail != "" { + writeField(w, "Detail", status.Detail) + } + + if warning := nonLoopbackWarning(status.Listen); warning != "" { + fmt.Fprintln(w) + fmt.Fprint(w, warning) + } +} + +// nonLoopbackWarning returns the warning for a bind address that is not a +// loopback address, or an empty string for a loopback address. +// +// A supervised instance binds loopback by default. An instance that binds +// another address is reachable from the network, so the exposure must be +// visible instead of silent. +func nonLoopbackWarning(listen string) string { + listen = strings.TrimSpace(listen) + if listen == "" { + return "" + } + if _, _, err := service.ParseListen(listen); err != nil { + return "" + } + if service.IsLoopbackListen(listen) { + return "" + } + return fmt.Sprintf("WARNING: %s is not a loopback address.\n"+ + " The HFDesk dashboard is reachable from the network: everyone\n"+ + " who can reach this machine can reach the dashboard.\n", listen) +} + +// upgradeWarning returns the warning for an executable whose path does not +// survive an upgrade, or an empty string for a stable path. +// +// The registration points at one exact path. Two shapes of path break that +// promise: +// +// - a temporary directory: an unpacked archive or a downloaded artifact +// is removed by the operating system or by the next download. +// - a version-stamped name: a release artifact carries its version in its +// name ("hfdesk-1.2.3-linux-amd64") and the next release replaces the +// file. +// +// The heuristic is deliberately simple: a name with a "digit.digit" segment +// counts as version-stamped. It only produces a warning, so a false +// positive costs a hint and never a broken installation. +func upgradeWarning(executable string) string { + if executable == "" { + return "" + } + reason := "" + switch { + case isTemporaryPath(executable): + reason = "it lives in a temporary directory" + case isVersionStampedName(filepath.Base(executable)), + isVersionStampedName(filepath.Base(filepath.Dir(executable))): + reason = "its name carries a version" + default: + return "" + } + return fmt.Sprintf("WARNING: the registration points at %s, and %s.\n"+ + " The registration points at that exact path: a version-stamped\n"+ + " release binary is replaced by the next upgrade and a temporary\n"+ + " path is removed by the operating system, so the service breaks.\n"+ + " Install the binary at a stable path (for example\n"+ + " /usr/local/bin/hfdesk) and run \"hfdesk service install\" again.\n", + executable, reason) +} + +// isTemporaryPath reports whether the path lives in a temporary directory. +// +// The markers are the ones the runtime path model of the repository already +// uses to skip an unpacked archive: the temporary directory of the operating +// system and the markers of a WinRAR extraction. +func isTemporaryPath(path string) bool { + clean := filepath.Clean(path) + temp := filepath.Clean(os.TempDir()) + if strings.HasPrefix(strings.ToLower(clean), strings.ToLower(temp)) { + return true + } + lower := strings.ToLower(clean) + return strings.Contains(lower, "rar$") || strings.Contains(lower, ".rartemp") +} + +// isVersionStampedName reports whether the name of a file carries a +// version-like segment. +func isVersionStampedName(name string) bool { + return versionLikeSegment.MatchString(name) +} + +// serviceRun runs the headless host of an instance. +// +// The mode is internal: the supervisor invokes it with the canonical +// argument vector of the registration. It never opens a browser and never +// creates a tray icon. +func serviceRun(args []string, stdout io.Writer) error { + fs := flag.NewFlagSet("hfdesk service run", flag.ContinueOnError) + fs.SetOutput(io.Discard) + // The flags of the subcommand are registered so its help lists them. + // The parsing itself is done by the service layer, which owns the + // canonical argument vector of a registered instance. + registerRunFlags(fs) + + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + writeSubcommandUsage(stdout, fs, "Run the headless host of an instance.\n\nThis mode is internal: the supervisor invokes it. It never opens a browser\nand never creates a tray icon.") + return errHelpRequested + } + return newServiceUsageError(fmt.Errorf("invalid command line: %w", err)) + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + return runServiceHost(ctx, args) +} + +// registerRunFlags registers the flags of the "service run" subcommand. +// +// The list comes from the service layer, so the help of the command and the +// parser of the layer never drift apart. +func registerRunFlags(fs *flag.FlagSet) { + for _, f := range service.RunArgsHelp() { + fs.String(f.Name, "", f.Usage) + } +} + +// runServiceHost parses the arguments of the host mode and runs the +// instance. +// +// The host never opens a browser and never creates a tray icon: the +// instance is supervised and there is no desktop session to interact with. +// +// An argument vector the parser rejects is a command line that could not be +// understood, so its error carries that kind. A host that cannot run is a +// failed operation and keeps the kind of one. +func runServiceHost(ctx context.Context, args []string) error { + opts, err := service.ParseRunArgs(args) + if err != nil { + return newServiceUsageError(err) + } + return service.RunProcess(ctx, opts) +} diff --git a/cmd/hfdesk/service_test.go b/cmd/hfdesk/service_test.go new file mode 100644 index 00000000..9a229324 --- /dev/null +++ b/cmd/hfdesk/service_test.go @@ -0,0 +1,2526 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "go/ast" + "go/parser" + "go/token" + "net" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/bashrusakh/hfdesk/internal/app" + "github.com/bashrusakh/hfdesk/internal/runtimepaths" + "github.com/bashrusakh/hfdesk/internal/service" +) + +// fakeManager is a service.Manager that answers with a fixed status. +// +// The command family is exercised without touching the system of the test +// run: the manager of the platform supervisor is replaced by this one. +type fakeManager struct { + status service.Status + err error + // opErr is the failure of every lifecycle operation. It is the + // answer of a supervisor that refuses the operation, e.g. a service + // that is not installed. + opErr error +} + +func (m fakeManager) Install(context.Context, service.InstallOptions) error { return m.opErr } + +func (m fakeManager) Uninstall(context.Context) error { return m.opErr } + +func (m fakeManager) Start(context.Context) error { return m.opErr } + +func (m fakeManager) Stop(context.Context) error { return m.opErr } + +func (m fakeManager) Restart(context.Context) error { return m.opErr } + +func (m fakeManager) Status(context.Context) (service.Status, error) { + return m.status, m.err +} + +// useFakeManager replaces the manager of the platform supervisor for the +// duration of the test. +// +// The command family never touches the system of the test run: the manager +// of the platform is the fake one, so a lifecycle command can be driven +// through every answer of a supervisor. +func useFakeManager(t *testing.T, mgr service.Manager) { + t.Helper() + previous := newServiceManager + newServiceManager = func(service.Scope) (service.Manager, error) { return mgr, nil } + t.Cleanup(func() { newServiceManager = previous }) +} + +// platformFactsByName holds the facts of the platforms of the service +// layer. +// +// The facts are the ones the service layer defines per platform: the test +// injects them, so the behaviour of a Windows instance is covered by a test +// that runs on Linux as well. The guard that keeps them in step with the +// service layer is TestServicePerPlatformFacts. +var platformFactsByName = map[string]platformFacts{ + "linux": { + defaultScope: service.ScopeUser, + supportsUserScope: true, + supportsNamedServiceAccount: true, + }, + "darwin": { + defaultScope: service.ScopeUser, + supportsUserScope: true, + supportsNamedServiceAccount: true, + }, + "windows": { + defaultScope: service.ScopeSystem, + supportsUserScope: false, + supportsNamedServiceAccount: false, + }, +} + +// usePlatform makes the command line behave as on the given platform for +// the duration of the test. +// +// The platform is the seam of the tests: a decision of the command line +// about Windows is checked on every platform, so the behaviour of a +// Windows instance is covered by a test that runs on Linux as well. +func usePlatform(t *testing.T, goos string) { + t.Helper() + facts, ok := platformFactsByName[goos] + if !ok { + t.Fatalf("the test does not know the facts of the platform %q", goos) + } + previous := hostPlatform + hostPlatform = facts + t.Cleanup(func() { hostPlatform = previous }) +} + +// scopeOfPlatform returns the scope an installation uses on the given +// platform. +// +// The scope is resolved by the command line itself, so a test states the +// scope of a platform the same way an installation does: the user scope on +// Linux and macOS, the machine-wide scope on Windows. +func scopeOfPlatform(t *testing.T, goos string) service.Scope { + t.Helper() + + usePlatform(t, goos) + scope, err := resolveScope(false, false) + if err != nil { + t.Fatalf("the default scope of %s must resolve: %v", goos, err) + } + return scope +} + +// personalCacheRoot returns the Hugging Face cache of the user that runs +// the test. +// +// It is the cache a user scope instance used to be reported with, even when +// the instance used another one, so it is the value the reports must never +// name. +func personalCacheRoot(t *testing.T) string { + t.Helper() + root, _ := service.DefaultCacheDirs(service.ScopeUser, runtimepaths.Paths{}) + return root +} + +// writeConfigFile writes the config file of an instance with the given +// cache root. +func writeConfigFile(t *testing.T, path, cacheRoot string) { + t.Helper() + data, err := json.Marshal(map[string]string{"cache-dir": cacheRoot}) + if err != nil { + t.Fatalf("encode the config file: %v", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write the config file: %v", err) + } +} + +// summaryField returns the value of a "Key: value" line of a summary block. +// +// The value is read from the line of the key itself, so a value that is the +// prefix of another value of the same block cannot satisfy the check. +func summaryField(out, key string) string { + for _, line := range strings.Split(out, "\n") { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, key+":") { + continue + } + return strings.TrimSpace(strings.TrimPrefix(trimmed, key+":")) + } + return "" +} + +// usageOptionOwner returns the heading that introduces the option block the +// given option belongs to. +func usageOptionOwner(out, option string) string { + lines := strings.Split(out, "\n") + for i, line := range lines { + if !strings.HasPrefix(strings.TrimSpace(line), option) { + continue + } + for j := i - 1; j >= 0; j-- { + heading := strings.TrimSpace(lines[j]) + if heading == "" { + continue + } + return heading + } + } + return "" +} + +// usageOptionText returns the description of an option in a usage block: +// the line that names the option and the lines below it that belong to it. +// +// The option is named with the dashes it is written with, so the option of +// the family ("--service-user") and the option of the flag set of a +// subcommand ("-service-user") are two different names. +func usageOptionText(out, option string) string { + lines := strings.Split(out, "\n") + for i, line := range lines { + if !strings.HasPrefix(strings.TrimSpace(line), option) { + continue + } + indent := len(line) - len(strings.TrimLeft(line, " ")) + text := strings.TrimSpace(line) + for j := i + 1; j < len(lines); j++ { + next := lines[j] + if strings.TrimSpace(next) == "" { + break + } + if len(next)-len(strings.TrimLeft(next, " ")) <= indent { + break + } + text += " " + strings.TrimSpace(next) + } + return text + } + return "" +} + +func TestServiceUsageListsEverySubcommand(t *testing.T) { + var buf bytes.Buffer + writeServiceUsage(&buf) + + out := buf.String() + for _, name := range serviceSubcommands { + if !strings.Contains(out, name) { + t.Errorf("the usage block does not mention the %q subcommand:\n%s", name, out) + } + } +} + +// TestServiceUsageNamesTheOwnerOfTheListenOption checks that the usage +// block of the family does not offer a bind address as an option of the +// family: the option is listed with the subcommand that accepts it, because +// the other subcommands reject it. +func TestServiceUsageNamesTheOwnerOfTheListenOption(t *testing.T) { + var buf bytes.Buffer + writeServiceUsage(&buf) + out := buf.String() + + owner := usageOptionOwner(out, "--listen") + if owner == "" { + // The family block does not offer the option at all: nothing it + // advertises is rejected by a subcommand. + return + } + if !strings.Contains(owner, "install") { + t.Errorf("the usage block offers --listen without naming the subcommand that accepts it (%q):\n%s", owner, out) + } +} + +// TestServiceLifecycleSubcommandsRejectTheListenOption checks that the bind +// address is an option of the install subcommand only. +func TestServiceLifecycleSubcommandsRejectTheListenOption(t *testing.T) { + for _, sub := range []string{"uninstall", "start", "stop", "restart", "status"} { + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{sub, "--listen", "127.0.0.1:9090"}, &stdout, &stderr) + if code != serviceUsageExitCode { + t.Errorf("the %q subcommand must reject --listen with the exit code %d, got %d", sub, serviceUsageExitCode, code) + } + if !strings.Contains(stderr.String(), "listen") { + t.Errorf("the error of the %q subcommand does not name the rejected option:\n%s", sub, stderr.String()) + } + } +} + +// TestServiceLifecycleSubcommandsRejectTheInstallOnlyOptions checks that +// the options of the install subcommand are options of that subcommand +// only. +// +// The account of a machine-wide instance and the binary the registration +// points at belong to the installation: a lifecycle command has nothing to +// do with them and must not accept them. +func TestServiceLifecycleSubcommandsRejectTheInstallOnlyOptions(t *testing.T) { + for _, sub := range []string{"uninstall", "start", "stop", "restart", "status", "run"} { + for _, option := range [][]string{ + {"--service-user", "hfdesk"}, + {"--executable", "/usr/local/bin/hfdesk"}, + } { + var stdout, stderr bytes.Buffer + code := runServiceCommand(append([]string{sub}, option...), &stdout, &stderr) + if code != serviceUsageExitCode { + t.Errorf("the %q subcommand must reject %s with the exit code %d, got %d", sub, option[0], serviceUsageExitCode, code) + } + // The parser names the option without its leading dashes. + name := strings.TrimPrefix(option[0], "--") + if !strings.Contains(stderr.String(), name) { + t.Errorf("the error of the %q subcommand does not name the rejected option %s:\n%s", sub, option[0], stderr.String()) + } + } + } +} + +// TestServiceUserIsRejectedForTheUserScope checks that the account of a +// machine-wide instance cannot be named for a user scope instance. +// +// A user scope instance runs as the user itself, so an account name would +// be a promise the installation cannot keep. +// +// The scope itself does not exist on the platform that has no per-user +// installation: the service control manager of Windows registers +// machine-wide services only, so the scope is refused there before the +// account is looked at. +func TestServiceUserIsRejectedForTheUserScope(t *testing.T) { + for _, tc := range []struct { + goos string + userScope bool + }{ + {goos: "linux", userScope: true}, + {goos: "darwin", userScope: true}, + {goos: "windows"}, + } { + t.Run(tc.goos, func(t *testing.T) { + usePlatform(t, tc.goos) + + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"install", "--user", "--service-user", "hfdesk"}, &stdout, &stderr) + if code != serviceUsageExitCode { + t.Fatalf("the user scope must reject --service-user with the exit code %d, got %d", serviceUsageExitCode, code) + } + + errText := stderr.String() + if !tc.userScope { + if !strings.Contains(errText, "--user") { + t.Errorf("the error does not name the rejected selector:\n%s", errText) + } + return + } + if !strings.Contains(errText, "--service-user") { + t.Errorf("the error does not name the rejected option:\n%s", errText) + } + if !strings.Contains(errText, "user") { + t.Errorf("the error does not explain the scope of the option:\n%s", errText) + } + }) + } +} + +// TestServiceUserIsRejectedOnWindows checks that the account of a +// machine-wide instance cannot be chosen on Windows. +// +// The service control manager registers the instance as the built-in +// LocalService account, and a service under a user account would need a +// credential, which must never reach the command line of an installation: +// the account is not a choice of the command line on this platform, so a +// command line that names one is refused. +func TestServiceUserIsRejectedOnWindows(t *testing.T) { + usePlatform(t, "windows") + + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"install", "--system", "--service-user", "hfdesk-svc"}, &stdout, &stderr) + if code != serviceUsageExitCode { + t.Fatalf("the machine-wide installation must refuse --service-user with the exit code %d, got %d:\n%s", + serviceUsageExitCode, code, stderr.String()) + } + + errText := stderr.String() + if !strings.Contains(errText, "--service-user") { + t.Errorf("the error does not name the rejected option:\n%s", errText) + } + if !strings.Contains(errText, windowsServiceAccount) { + t.Errorf("the error does not name the account the instance really runs as (%q):\n%s", windowsServiceAccount, errText) + } + if !strings.Contains(errText, "cannot be chosen") { + t.Errorf("the error does not say that the account cannot be chosen:\n%s", errText) + } + if stdout.Len() != 0 { + t.Errorf("a refused installation must not report a registration:\n%s", stdout.String()) + } +} + +// TestServiceUserIsRejectedForTheUserScopeOnWindows checks that a user scope +// installation with a named account is refused on Windows. +// +// The user scope does not exist on Windows, so the scope itself is refused +// before the account is looked at: the command line is refused either way. +func TestServiceUserIsRejectedForTheUserScopeOnWindows(t *testing.T) { + usePlatform(t, "windows") + + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"install", "--user", "--service-user", "hfdesk-svc"}, &stdout, &stderr) + if code != serviceUsageExitCode { + t.Fatalf("the user scope must be refused with the exit code %d, got %d:\n%s", + serviceUsageExitCode, code, stderr.String()) + } + if stdout.Len() != 0 { + t.Errorf("a refused installation must not report a registration:\n%s", stdout.String()) + } +} + +// TestBuildInstallOptionsCarriesTheServiceUser checks that the account +// reaches the options of the installation. +// +// The account is empty when the command line does not name one: the default +// of the platform is resolved by the platform manager, which knows the +// accounts of the machine. +// +// The account reaches the options only on the platforms that have a named +// service account: on Windows the machine-wide instance runs as the built-in +// LocalService account, so an account of the command line is refused there +// with the message that names the account the instance really runs as. +func TestBuildInstallOptionsCarriesTheServiceUser(t *testing.T) { + const executable = "/usr/local/bin/hfdesk" + + for _, tc := range []struct { + goos string + namedUser bool + }{ + {goos: "linux", namedUser: true}, + {goos: "darwin", namedUser: true}, + {goos: "windows"}, + } { + t.Run(tc.goos, func(t *testing.T) { + usePlatform(t, tc.goos) + + opts, err := buildInstallOptions(service.ScopeSystem, installFlags{}, executable) + if err != nil { + t.Fatalf("build the install options: %v", err) + } + if opts.ServiceUser != "" { + t.Errorf("the service account is %q, want it to be unset", opts.ServiceUser) + } + + opts, err = buildInstallOptions(service.ScopeSystem, installFlags{serviceUser: "hfdesk-svc"}, executable) + if !tc.namedUser { + if err == nil { + t.Fatalf("the machine-wide scope must refuse a named account on %s", tc.goos) + } + if !isServiceUsageError(err) { + t.Errorf("the refusal must be a command line that could not be understood: %v", err) + } + if !strings.Contains(err.Error(), windowsServiceAccount) { + t.Errorf("the refusal does not name the account the instance really runs as (%q): %v", windowsServiceAccount, err) + } + return + } + if err != nil { + t.Fatalf("build the install options: %v", err) + } + if opts.ServiceUser != "hfdesk-svc" { + t.Errorf("the service account is %q, want %q", opts.ServiceUser, "hfdesk-svc") + } + }) + } +} + +// TestBuildInstallOptionsRejectsTheServiceUserForTheUserScope checks that +// a user scope instance never carries a service account. +// +// The refusal does not depend on the platform: a user scope instance runs as +// the user itself, so an account name would be a promise the installation +// cannot keep. +func TestBuildInstallOptionsRejectsTheServiceUserForTheUserScope(t *testing.T) { + if _, err := buildInstallOptions(service.ScopeUser, installFlags{serviceUser: "hfdesk"}, "/usr/local/bin/hfdesk"); err == nil { + t.Error("the user scope must reject a service account") + } +} + +// TestBuildInstallOptionsRejectsTheServiceUserOnWindows checks that a +// machine-wide installation never carries a named account on Windows. +// +// The registration of a Windows instance runs as the built-in LocalService +// account, so an account in the options would be a promise the +// registration cannot keep. +func TestBuildInstallOptionsRejectsTheServiceUserOnWindows(t *testing.T) { + usePlatform(t, "windows") + + _, err := buildInstallOptions(service.ScopeSystem, installFlags{serviceUser: "hfdesk-svc"}, "/usr/local/bin/hfdesk") + if err == nil { + t.Fatal("the machine-wide scope must reject a service account on windows") + } + if !isServiceUsageError(err) { + t.Errorf("the refusal must be a command line that could not be understood: %v", err) + } + if !strings.Contains(err.Error(), windowsServiceAccount) { + t.Errorf("the refusal does not name the account the instance really runs as (%q): %v", windowsServiceAccount, err) + } +} + +// TestBuildInstallOptionsKeepsTheServiceUserOnLinuxAndMacOS checks that the +// account of a machine-wide instance is honoured on the platforms that have +// a named service account. +func TestBuildInstallOptionsKeepsTheServiceUserOnLinuxAndMacOS(t *testing.T) { + for _, goos := range []string{"linux", "darwin"} { + t.Run(goos, func(t *testing.T) { + usePlatform(t, goos) + + opts, err := buildInstallOptions(service.ScopeSystem, installFlags{serviceUser: "hfdesk-svc"}, "/usr/local/bin/hfdesk") + if err != nil { + t.Fatalf("build the install options: %v", err) + } + if opts.ServiceUser != "hfdesk-svc" { + t.Errorf("the service account is %q, want %q", opts.ServiceUser, "hfdesk-svc") + } + }) + } +} + +// TestBuildInstallOptionsRejectsTheServiceUserForTheUserScopeEverywhere +// checks that a user scope instance never carries a service account, on +// every platform. +// +// A user scope instance runs as the user itself, so an account name would +// be a promise the installation cannot keep. +func TestBuildInstallOptionsRejectsTheServiceUserForTheUserScopeEverywhere(t *testing.T) { + for _, goos := range []string{"linux", "darwin", "windows"} { + t.Run(goos, func(t *testing.T) { + usePlatform(t, goos) + + if _, err := buildInstallOptions(service.ScopeUser, installFlags{serviceUser: "hfdesk"}, "/usr/local/bin/hfdesk"); err == nil { + t.Error("the user scope must reject a service account") + } + }) + } +} + +// TestInstallSummaryReportsTheAccountOfTheInstance checks that the summary +// names the account the instance really runs as. +// +// A machine-wide instance runs as the account the command line named on the +// platforms that have a named service account, and as the built-in +// LocalService account on Windows, where the account of the command line +// cannot be chosen and is never reported. +// +// A user scope instance runs as the invoking user, on the platforms that +// have the scope at all. +func TestInstallSummaryReportsTheAccountOfTheInstance(t *testing.T) { + for _, tc := range []struct { + goos string + namedUser bool + userScope bool + }{ + {goos: "linux", namedUser: true, userScope: true}, + {goos: "darwin", namedUser: true, userScope: true}, + {goos: "windows"}, + } { + t.Run(tc.goos, func(t *testing.T) { + usePlatform(t, tc.goos) + + opts := service.InstallOptions{ + Scope: service.ScopeSystem, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + ServiceUser: "hfdesk-svc", + } + + var buf bytes.Buffer + mgr := fakeManager{status: service.Status{State: service.StateRunning}} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeSystem, opts) + + out := buf.String() + want := "hfdesk-svc" + if !tc.namedUser { + want = windowsServiceAccount + } + if got := summaryField(out, "Account"); got != want { + t.Errorf("the account of the summary is %q, want %q:\n%s", got, want, out) + } + if !tc.namedUser && strings.Contains(out, "hfdesk-svc") { + t.Errorf("the summary reports the account %q the instance does not run as:\n%s", "hfdesk-svc", out) + } + + if !tc.userScope { + return + } + buf.Reset() + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeUser, opts) + + out = buf.String() + want = invokingUser() + if want == "" { + want = "unknown" + } + if got := summaryField(out, "Account"); got != want { + t.Errorf("the account of the summary is %q, want the invoking user %q:\n%s", got, want, out) + } + }) + } +} + +// TestInstallSummaryReportsTheDefaultAccountOfThePlatform checks that the +// summary names the account of the platform when the command line does not +// name one. +func TestInstallSummaryReportsTheDefaultAccountOfThePlatform(t *testing.T) { + opts := service.InstallOptions{ + Scope: service.ScopeSystem, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + } + + var buf bytes.Buffer + mgr := fakeManager{status: service.Status{State: service.StateRunning}} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeSystem, opts) + + out := buf.String() + got := summaryField(out, "Account") + if got == "" { + t.Fatalf("the summary does not name an account:\n%s", out) + } + if want := service.DefaultServiceUser(); want != "" && got != want { + t.Errorf("the account of the summary is %q, want the default account of the platform %q:\n%s", got, want, out) + } +} + +// TestServiceUsageDescribesTheServiceUserOption checks that the usage +// block of the family says where the account of a machine-wide instance +// can be named. +// +// The option is not available on every platform: the account of a Windows +// instance cannot be chosen, so a usage block that offers it everywhere +// promises more than the command line can keep. +func TestServiceUsageDescribesTheServiceUserOption(t *testing.T) { + var buf bytes.Buffer + writeServiceUsage(&buf) + + out := buf.String() + text := usageOptionText(out, "--service-user") + if text == "" { + t.Fatalf("the usage block does not describe --service-user:\n%s", out) + } + for _, want := range []string{"Linux", "macOS", "Windows"} { + if !strings.Contains(text, want) { + t.Errorf("the description of --service-user does not mention %q:\n%s", want, text) + } + } +} + +// TestServiceInstallHelpDescribesTheServiceUserOption checks that the help +// of the install subcommand says where the account of a machine-wide +// instance can be named. +func TestServiceInstallHelpDescribesTheServiceUserOption(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"install", "--help"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("the help of the install subcommand must succeed, got exit code %d:\n%s", code, stderr.String()) + } + + out := stdout.String() + text := usageOptionText(out, "-service-user") + if text == "" { + t.Fatalf("the help of the install subcommand does not describe --service-user:\n%s", out) + } + for _, want := range []string{"Linux", "macOS", "Windows"} { + if !strings.Contains(text, want) { + t.Errorf("the description of --service-user does not mention %q:\n%s", want, text) + } + } +} + +// TestWindowsServiceAccountIsTheNameOfTheRegistration checks that the +// account the command line names for a Windows instance is the account the +// registration uses. +// +// The service control manager registers the instance with the built-in +// LocalService account under its full name (the platform manager of the +// service layer carries the same name). A summary that names another +// account describes an identity the registration does not have. +func TestWindowsServiceAccountIsTheNameOfTheRegistration(t *testing.T) { + const want = `NT AUTHORITY\LocalService` + if windowsServiceAccount != want { + t.Errorf("the account of a Windows instance is %q, want %q", windowsServiceAccount, want) + } +} + +// TestInstallSummaryReportsTheWindowsAccount checks that the summary of a +// machine-wide installation names the account the registration uses on +// Windows. +// +// The registration runs as the built-in LocalService account, so the +// summary names that account and never the one a command line named: a +// summary that reports an account the instance does not run as sends the +// reader to the wrong identity. +func TestInstallSummaryReportsTheWindowsAccount(t *testing.T) { + usePlatform(t, "windows") + + for _, tc := range []struct { + name string + serviceUser string + }{ + {name: "without a named account"}, + {name: "with a named account", serviceUser: "hfdesk-svc"}, + } { + t.Run(tc.name, func(t *testing.T) { + opts := service.InstallOptions{ + Scope: service.ScopeSystem, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + ServiceUser: tc.serviceUser, + } + + var buf bytes.Buffer + mgr := fakeManager{status: service.Status{State: service.StateRunning}} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeSystem, opts) + + out := buf.String() + if got := summaryField(out, "Account"); got != windowsServiceAccount { + t.Errorf("the account of the summary is %q, want %q:\n%s", got, windowsServiceAccount, out) + } + if tc.serviceUser != "" && strings.Contains(out, tc.serviceUser) { + t.Errorf("the summary reports the account %q the registration does not use:\n%s", tc.serviceUser, out) + } + }) + } +} + +// TestInstallSummaryReportsTheNamedAccountOnLinuxAndMacOS checks that the +// summary names the account of the command line on the platforms that +// honour it. +func TestInstallSummaryReportsTheNamedAccountOnLinuxAndMacOS(t *testing.T) { + for _, goos := range []string{"linux", "darwin"} { + t.Run(goos, func(t *testing.T) { + usePlatform(t, goos) + + opts := service.InstallOptions{ + Scope: service.ScopeSystem, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + ServiceUser: "hfdesk-svc", + } + + var buf bytes.Buffer + mgr := fakeManager{status: service.Status{State: service.StateRunning}} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeSystem, opts) + + out := buf.String() + if got := summaryField(out, "Account"); got != "hfdesk-svc" { + t.Errorf("the account of the summary is %q, want %q:\n%s", got, "hfdesk-svc", out) + } + }) + } +} + +// TestInstallSummaryReportsTheInvokingUserForTheUserScope checks that the +// summary of a user scope installation names the user the instance runs as. +// +// A user scope instance runs as the user itself, so the summary names that +// user and never an account of a command line. +func TestInstallSummaryReportsTheInvokingUserForTheUserScope(t *testing.T) { + usePlatform(t, "linux") + + opts := service.InstallOptions{ + Scope: service.ScopeUser, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + ServiceUser: "hfdesk-svc", + } + + var buf bytes.Buffer + mgr := fakeManager{status: service.Status{State: service.StateRunning}} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeUser, opts) + + out := buf.String() + got := summaryField(out, "Account") + want := invokingUser() + if want == "" { + want = "unknown" + } + if got != want { + t.Errorf("the account of the summary is %q, want the invoking user %q:\n%s", got, want, out) + } + if strings.Contains(out, "hfdesk-svc") { + t.Errorf("the summary reports an account the instance does not run as:\n%s", out) + } +} + +func TestServiceHelpSucceeds(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"--help"}, &stdout, &stderr) + if code != 0 { + t.Errorf("the help of the service command must succeed, got exit code %d", code) + } + if !strings.Contains(stdout.String(), "install") { + t.Errorf("the help does not list the subcommands:\n%s", stdout.String()) + } + if stderr.Len() != 0 { + t.Errorf("the help must not write to stderr:\n%s", stderr.String()) + } +} + +func TestServiceUnknownSubcommand(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"frobnicate"}, &stdout, &stderr) + if code != serviceUsageExitCode { + t.Errorf("an unknown subcommand must exit with %d, got %d", serviceUsageExitCode, code) + } + + errText := stderr.String() + if !strings.Contains(errText, "frobnicate") { + t.Errorf("the error does not name the unknown subcommand:\n%s", errText) + } + for _, name := range serviceSubcommands { + if !strings.Contains(errText, name) { + t.Errorf("the error does not name the valid subcommand %q:\n%s", name, errText) + } + } +} + +func TestServiceWithoutSubcommand(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runServiceCommand(nil, &stdout, &stderr) + if code != serviceUsageExitCode { + t.Errorf("a missing subcommand must exit with %d, got %d", serviceUsageExitCode, code) + } + for _, name := range serviceSubcommands { + if !strings.Contains(stderr.String(), name) { + t.Errorf("the usage block does not name the valid subcommand %q:\n%s", name, stderr.String()) + } + } +} + +func TestServiceScopeSelectorsAreExclusive(t *testing.T) { + if _, err := resolveScope(true, true); err == nil { + t.Error("--user together with --system must be rejected") + } + + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"install", "--user", "--system"}, &stdout, &stderr) + if code != serviceUsageExitCode { + t.Errorf("an ambiguous scope must exit with %d, got %d", serviceUsageExitCode, code) + } + if !strings.Contains(stderr.String(), "--user") || !strings.Contains(stderr.String(), "--system") { + t.Errorf("the error does not name both selectors:\n%s", stderr.String()) + } +} + +// TestServiceScopeSelectorsAreExclusiveEverywhere checks that every +// subcommand that carries the scope selector rejects an ambiguous one. +// +// Two scopes are two installations, so an ambiguous command line is a +// command line that could not be understood, on every subcommand that +// carries the selector. +func TestServiceScopeSelectorsAreExclusiveEverywhere(t *testing.T) { + for _, sub := range []string{"install", "uninstall", "start", "stop", "restart", "status"} { + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{sub, "--user", "--system"}, &stdout, &stderr) + if code != serviceUsageExitCode { + t.Errorf("the %q subcommand must reject two scopes with the exit code %d, got %d", + sub, serviceUsageExitCode, code) + } + if !strings.Contains(stderr.String(), "--user") || !strings.Contains(stderr.String(), "--system") { + t.Errorf("the error of the %q subcommand does not name both selectors:\n%s", sub, stderr.String()) + } + } +} + +// TestServiceRunRejectsAnInvalidVector checks that an argument vector the +// host mode cannot parse is a command line that could not be understood. +// +// The vector of the host mode is the one the supervisor invokes, so a +// broken vector is a mistake of the invocation and not a failed operation. +func TestServiceRunRejectsAnInvalidVector(t *testing.T) { + for _, args := range [][]string{ + {"run", "--config", "relative/hfdesk.json"}, + {"run", "--config", "/tmp/hfdesk.json"}, + } { + var stdout, stderr bytes.Buffer + code := runServiceCommand(args, &stdout, &stderr) + if code != serviceUsageExitCode { + t.Errorf("%v must exit with %d, got %d", args, serviceUsageExitCode, code) + } + if stderr.Len() == 0 { + t.Errorf("%v is rejected without a reason", args) + } + } +} + +// TestServiceScopeDefaults checks that the default scope of an installation +// is the scope of the platform. +// +// The user scope is the default on Linux and macOS, the system scope on +// Windows: the service control manager of Windows registers machine-wide +// services only. The user scope does not exist there, so asking for it is +// refused instead of answered. +func TestServiceScopeDefaults(t *testing.T) { + for _, tc := range []struct { + goos string + scope service.Scope + userScope bool + }{ + {goos: "linux", scope: service.ScopeUser, userScope: true}, + {goos: "darwin", scope: service.ScopeUser, userScope: true}, + {goos: "windows", scope: service.ScopeSystem}, + } { + t.Run(tc.goos, func(t *testing.T) { + usePlatform(t, tc.goos) + + scope, err := resolveScope(false, false) + if err != nil { + t.Fatalf("the default scope must resolve: %v", err) + } + if scope != tc.scope { + t.Errorf("the default scope on %s is %q, want %q", tc.goos, scope, tc.scope) + } + + system, err := resolveScope(false, true) + if err != nil { + t.Fatalf("the system scope must resolve: %v", err) + } + if system != service.ScopeSystem { + t.Errorf("the system scope is %q, want %q", system, service.ScopeSystem) + } + + user, err := resolveScope(true, false) + if !tc.userScope { + if err == nil { + t.Fatalf("the user scope must be refused on %s", tc.goos) + } + if !isServiceUsageError(err) { + t.Errorf("the refusal must be a command line that could not be understood: %v", err) + } + if !strings.Contains(err.Error(), "--user") { + t.Errorf("the refusal does not name the rejected selector: %v", err) + } + return + } + if err != nil { + t.Fatalf("the user scope must resolve: %v", err) + } + if user != service.ScopeUser { + t.Errorf("the user scope is %q, want %q", user, service.ScopeUser) + } + }) + } +} + +func TestServiceListenValidation(t *testing.T) { + if _, err := validateListen(":8080"); err == nil { + t.Error("an empty host must be rejected, it binds every interface") + } + if _, err := validateListen("127.0.0.1:0"); err == nil { + t.Error("port 0 must be rejected") + } + if _, err := validateListen("127.0.0.1:70000"); err == nil { + t.Error("a port out of range must be rejected") + } + + listen, err := validateListen("127.0.0.1:9090") + if err != nil { + t.Fatalf("a loopback address must be accepted: %v", err) + } + if listen != "127.0.0.1:9090" { + t.Errorf("the listen address is %q, want %q", listen, "127.0.0.1:9090") + } +} + +func TestServiceRelativePathsAreRejected(t *testing.T) { + for _, args := range [][]string{ + {"install", "--state-dir", "relative/state"}, + {"install", "--config", "relative/hfdesk.json"}, + {"install", "--log-dir", "relative/logs"}, + {"install", "--cache-dir", "relative/cache"}, + {"install", "--hub-dir", "relative/hub"}, + {"install", "--local-dir", "relative/models"}, + } { + var stdout, stderr bytes.Buffer + code := runServiceCommand(args, &stdout, &stderr) + if code != serviceUsageExitCode { + t.Errorf("%v must exit with %d, got %d", args, serviceUsageExitCode, code) + } + if !strings.Contains(stderr.String(), "absolute") { + t.Errorf("%v is not reported as a relative path:\n%s", args, stderr.String()) + } + } +} + +// TestBuildInstallOptionsUsesTheDefaultsOfTheScope checks that the options +// of an installation carry the defaults of its scope. +// +// The scope is the scope of the platform: the user scope on Linux and macOS, +// the machine-wide scope on Windows. The defaults themselves belong to the +// service layer of the host, so the test states that the command line uses +// the defaults of the scope and not which paths these are. +// +// A scope the host does not have has no defaults to carry: the service layer +// refuses it, so the options of an instance of that scope cannot be built. +func TestBuildInstallOptionsUsesTheDefaultsOfTheScope(t *testing.T) { + const executable = "/usr/local/bin/hfdesk" + + for _, tc := range []struct { + goos string + scope service.Scope + }{ + {goos: "linux", scope: service.ScopeUser}, + {goos: "darwin", scope: service.ScopeUser}, + {goos: "windows", scope: service.ScopeSystem}, + } { + t.Run(tc.goos, func(t *testing.T) { + scope := scopeOfPlatform(t, tc.goos) + if scope != tc.scope { + t.Fatalf("the scope of %s is %q, want %q", tc.goos, scope, tc.scope) + } + + defaults, err := service.DefaultPaths(scope) + if err != nil { + if _, err := buildInstallOptions(scope, installFlags{}, executable); err == nil { + t.Fatalf("the scope %q does not exist on this host, so the options of an instance of it cannot be built", scope) + } + return + } + cacheRoot, hubDir := service.DefaultCacheDirs(scope, defaults) + + opts, err := buildInstallOptions(scope, installFlags{}, executable) + if err != nil { + t.Fatalf("build the install options: %v", err) + } + + if opts.Scope != scope { + t.Errorf("the scope is %q, want %q", opts.Scope, scope) + } + if opts.Executable != executable { + t.Errorf("the executable is %q, want %q", opts.Executable, executable) + } + if opts.Listen != service.DefaultListen { + t.Errorf("the listen address is %q, want %q", opts.Listen, service.DefaultListen) + } + if opts.Paths != defaults { + t.Errorf("the paths are %+v, want %+v", opts.Paths, defaults) + } + if opts.CacheDir != cacheRoot { + t.Errorf("the cache root is %q, want %q", opts.CacheDir, cacheRoot) + } + if opts.HubDir != hubDir { + t.Errorf("the hub dir is %q, want %q", opts.HubDir, hubDir) + } + if opts.LocalDir != "" { + t.Errorf("the local dir is %q, want it to be unset", opts.LocalDir) + } + }) + } +} + +func TestBuildInstallOptionsAppliesTheOverrides(t *testing.T) { + const executable = "/usr/local/bin/hfdesk" + + flags := installFlags{ + listen: "0.0.0.0:9090", + config: "/etc/hfdesk/custom.json", + stateDir: "/var/lib/hfdesk-custom", + logDir: "/var/log/hfdesk-custom", + cacheDir: "/srv/cache", + hubDir: "/srv/cache/hub", + localDir: "/srv/models", + } + + opts, err := buildInstallOptions(service.ScopeSystem, flags, executable) + if err != nil { + t.Fatalf("build the install options: %v", err) + } + + if opts.Scope != service.ScopeSystem { + t.Errorf("the scope is %q, want %q", opts.Scope, service.ScopeSystem) + } + if opts.Listen != "0.0.0.0:9090" { + t.Errorf("the listen address is %q, want %q", opts.Listen, "0.0.0.0:9090") + } + if opts.Paths.ConfigFile != "/etc/hfdesk/custom.json" { + t.Errorf("the config file is %q, want %q", opts.Paths.ConfigFile, "/etc/hfdesk/custom.json") + } + if opts.Paths.StateDir != "/var/lib/hfdesk-custom" { + t.Errorf("the state dir is %q, want %q", opts.Paths.StateDir, "/var/lib/hfdesk-custom") + } + if opts.Paths.LogDir != "/var/log/hfdesk-custom" { + t.Errorf("the log dir is %q, want %q", opts.Paths.LogDir, "/var/log/hfdesk-custom") + } + if opts.CacheDir != "/srv/cache" { + t.Errorf("the cache root is %q, want %q", opts.CacheDir, "/srv/cache") + } + if opts.HubDir != "/srv/cache/hub" { + t.Errorf("the hub dir is %q, want %q", opts.HubDir, "/srv/cache/hub") + } + if opts.LocalDir != "/srv/models" { + t.Errorf("the local dir is %q, want %q", opts.LocalDir, "/srv/models") + } +} + +// TestBuildInstallOptionsKeepsTheCacheLayoutTogether checks that the +// registered cache root and the registered hub directory are one layout: an +// explicit cache root moves the hub directory with it, and an explicit hub +// directory is honoured as given. +// +// A registration that names a cache root and the hub of another tree would +// write the blobs of the instance into a directory its own cache root does +// not contain. +// +// The layout is the one of the scope of the platform: the user scope on +// Linux and macOS, the machine-wide scope on Windows. A scope the host does +// not have has no layout to keep: the service layer refuses it, so the +// options of an instance of that scope cannot be built. +func TestBuildInstallOptionsKeepsTheCacheLayoutTogether(t *testing.T) { + const executable = "/usr/local/bin/hfdesk" + + for _, tc := range []struct { + goos string + scope service.Scope + }{ + {goos: "linux", scope: service.ScopeUser}, + {goos: "darwin", scope: service.ScopeUser}, + {goos: "windows", scope: service.ScopeSystem}, + } { + t.Run(tc.goos, func(t *testing.T) { + scope := scopeOfPlatform(t, tc.goos) + if scope != tc.scope { + t.Fatalf("the scope of %s is %q, want %q", tc.goos, scope, tc.scope) + } + + defaults, err := service.DefaultPaths(scope) + if err != nil { + if _, err := buildInstallOptions(scope, installFlags{}, executable); err == nil { + t.Fatalf("the scope %q does not exist on this host, so the options of an instance of it cannot be built", scope) + } + return + } + defaultRoot, defaultHub := service.DefaultCacheDirs(scope, defaults) + + for _, c := range []struct { + name string + flags installFlags + wantRoot string + wantHub string + }{ + { + name: "a cache root alone moves the hub directory with it", + flags: installFlags{cacheDir: "/srv/cache"}, + wantRoot: "/srv/cache", + wantHub: filepath.Join("/srv/cache", "hub"), + }, + { + name: "a hub directory alone is honoured as given", + flags: installFlags{hubDir: "/mnt/hub"}, + wantRoot: defaultRoot, + wantHub: "/mnt/hub", + }, + { + name: "both directories are honoured as given", + flags: installFlags{cacheDir: "/srv/cache", hubDir: "/mnt/hub"}, + wantRoot: "/srv/cache", + wantHub: "/mnt/hub", + }, + { + name: "without an override the pair of the scope is kept", + flags: installFlags{}, + wantRoot: defaultRoot, + wantHub: defaultHub, + }, + } { + t.Run(c.name, func(t *testing.T) { + opts, err := buildInstallOptions(scope, c.flags, executable) + if err != nil { + t.Fatalf("build the install options: %v", err) + } + if opts.CacheDir != c.wantRoot { + t.Errorf("the cache root is %q, want %q", opts.CacheDir, c.wantRoot) + } + if opts.HubDir != c.wantHub { + t.Errorf("the hub dir is %q, want %q", opts.HubDir, c.wantHub) + } + }) + } + }) + } +} + +func TestRenderStatusReportsTheInstance(t *testing.T) { + status := service.Status{ + State: service.StateRunning, + Scope: service.ScopeSystem, + Name: service.ServiceName, + UnitPath: "/etc/systemd/system/hfdesk.service", + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + } + + var buf bytes.Buffer + renderStatus(&buf, service.ScopeSystem, status) + + out := buf.String() + for _, want := range []string{ + service.ServiceName, + "running", + "127.0.0.1:8080", + "/etc/systemd/system/hfdesk.service", + "/usr/local/bin/hfdesk", + "/etc/hfdesk/hfdesk.json", + "/var/lib/hfdesk", + "/var/log/hfdesk", + } { + if !strings.Contains(out, want) { + t.Errorf("the status does not report %q:\n%s", want, out) + } + } +} + +// TestRenderStatusReportsTheCacheRootOfTheInstance checks that the status +// reports the cache root the instance uses, and not the cache of the user +// that runs the command. +func TestRenderStatusReportsTheCacheRootOfTheInstance(t *testing.T) { + status := service.Status{ + State: service.StateRunning, + Scope: service.ScopeUser, + Name: service.ServiceName, + UnitPath: "/etc/systemd/user/hfdesk.service", + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + CacheDir: "/srv/instance-cache", + } + + var buf bytes.Buffer + renderStatus(&buf, service.ScopeUser, status) + + out := buf.String() + if !strings.Contains(out, "/srv/instance-cache") { + t.Errorf("the status does not report the cache root of the instance:\n%s", out) + } + if personal := personalCacheRoot(t); personal != "" && strings.Contains(out, personal) { + t.Errorf("the status reports the personal cache of the invoking user %q:\n%s", personal, out) + } +} + +// TestRenderStatusFallsBackToTheConfigFileOfTheInstance checks that the +// status reports the cache root of the config file of the instance when the +// supervisor does not report one. +func TestRenderStatusFallsBackToTheConfigFileOfTheInstance(t *testing.T) { + dir := t.TempDir() + configFile := filepath.Join(dir, "hfdesk.json") + cacheRoot := filepath.Join(dir, "instance-cache") + writeConfigFile(t, configFile, cacheRoot) + + status := service.Status{ + State: service.StateRunning, + Scope: service.ScopeUser, + Paths: runtimepaths.Paths{ + ConfigFile: configFile, + StateDir: filepath.Join(dir, "state"), + LogDir: filepath.Join(dir, "logs"), + }, + } + + var buf bytes.Buffer + renderStatus(&buf, service.ScopeUser, status) + + out := buf.String() + if !strings.Contains(out, cacheRoot) { + t.Errorf("the status does not report the cache root %q of the config file:\n%s", cacheRoot, out) + } + if personal := personalCacheRoot(t); personal != "" && strings.Contains(out, personal) { + t.Errorf("the status reports the personal cache of the invoking user %q:\n%s", personal, out) + } +} + +// TestRenderStatusReportsAnUnrecordedCacheRoot checks that a cache root +// that no source names is reported as unknown instead of the default of the +// scope or the personal cache of the invoking user. +func TestRenderStatusReportsAnUnrecordedCacheRoot(t *testing.T) { + dir := t.TempDir() + status := service.Status{ + State: service.StateNotInstalled, + Scope: service.ScopeUser, + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(dir, "missing.json"), + StateDir: filepath.Join(dir, "state"), + LogDir: filepath.Join(dir, "logs"), + }, + } + + var buf bytes.Buffer + renderStatus(&buf, service.ScopeUser, status) + + out := buf.String() + if !strings.Contains(out, "not recorded") { + t.Errorf("the status does not report that the cache root is not recorded:\n%s", out) + } + if personal := personalCacheRoot(t); personal != "" && strings.Contains(out, personal) { + t.Errorf("the status reports the personal cache of the invoking user %q:\n%s", personal, out) + } +} + +// TestInstallSummaryReportsTheStateOfTheInstance checks that the summary of +// the installation reports the state the instance reached and that the hint +// that starts the instance is printed for every state that is not an active +// one. +func TestInstallSummaryReportsTheStateOfTheInstance(t *testing.T) { + opts := service.InstallOptions{ + Scope: service.ScopeUser, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + CacheDir: "/srv/cache", + } + + for _, tc := range []struct { + state service.State + wantHint bool + }{ + {service.StateRunning, false}, + {service.StateStarting, false}, + {service.StateStopped, true}, + {service.StateFailed, true}, + } { + var buf bytes.Buffer + mgr := fakeManager{status: service.Status{ + State: tc.state, + Scope: service.ScopeUser, + UnitPath: "/etc/systemd/user/hfdesk.service", + }} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeUser, opts) + + out := buf.String() + if !strings.Contains(out, "State:") || !strings.Contains(out, string(tc.state)) { + t.Errorf("the install summary does not report the state %q:\n%s", tc.state, out) + } + hint := strings.Contains(out, "Start it with: hfdesk service start") + if hint != tc.wantHint { + t.Errorf("the install summary prints the start hint for the state %q: %v, want %v:\n%s", + tc.state, hint, tc.wantHint, out) + } + if strings.Contains(out, "not started") { + t.Errorf("the install summary claims that the instance was not started:\n%s", out) + } + } +} + +// TestInstallSummaryNamesTheScopeInTheStartHint checks that the hint of +// the summary starts the instance that was registered. +// +// The default scope of the platform is the user scope on Linux and macOS +// and the machine-wide scope on Windows: a hint without the scope starts +// another instance than the one that was just registered. +func TestInstallSummaryNamesTheScopeInTheStartHint(t *testing.T) { + for _, tc := range []struct { + scope service.Scope + selector string + }{ + {scope: service.ScopeUser, selector: "--user"}, + {scope: service.ScopeSystem, selector: "--system"}, + } { + t.Run(string(tc.scope), func(t *testing.T) { + opts := service.InstallOptions{ + Scope: tc.scope, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + } + + var buf bytes.Buffer + mgr := fakeManager{status: service.Status{State: service.StateStopped}} + writeInstallSummary(&buf, context.Background(), mgr, tc.scope, opts) + + out := buf.String() + hint := startHint(out) + want := "hfdesk service start " + tc.selector + if hint != want { + t.Errorf("the hint of the summary is %q, want %q:\n%s", hint, want, out) + } + }) + } +} + +// startHint returns the command of the hint that starts the instance. +// +// The hint is the only line of a summary that names the command, so the +// line is found by its prefix. +func startHint(out string) string { + for _, line := range strings.Split(out, "\n") { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "Start it with:") { + continue + } + return strings.TrimSpace(strings.TrimPrefix(trimmed, "Start it with:")) + } + return "" +} + +// TestInstallSummaryReportsAFailedStatusQuery checks that a state that could +// not be determined is reported as such, and that the successful +// registration is reported nevertheless. +func TestInstallSummaryReportsAFailedStatusQuery(t *testing.T) { + opts := service.InstallOptions{ + Scope: service.ScopeUser, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: "/etc/hfdesk/hfdesk.json", + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + CacheDir: "/srv/cache", + } + + var buf bytes.Buffer + mgr := fakeManager{err: errors.New("the supervisor is not reachable")} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeUser, opts) + + out := buf.String() + if !strings.Contains(out, "The service was registered.") { + t.Errorf("the install summary does not report the successful registration:\n%s", out) + } + if !strings.Contains(out, "could not be determined") { + t.Errorf("the install summary does not report the failed status query:\n%s", out) + } + for _, state := range []string{"running", "stopped", "starting"} { + if strings.Contains(out, state) { + t.Errorf("the install summary claims the state %q it could not determine:\n%s", state, out) + } + } +} + +// TestInstallSummaryReportsTheEffectiveCacheRoot checks that the summary of +// the installation names the cache root of the config file of the instance +// when it defines one. +func TestInstallSummaryReportsTheEffectiveCacheRoot(t *testing.T) { + dir := t.TempDir() + configFile := filepath.Join(dir, "hfdesk.json") + cacheRoot := filepath.Join(dir, "instance-cache") + writeConfigFile(t, configFile, cacheRoot) + + opts := service.InstallOptions{ + Scope: service.ScopeUser, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: configFile, + StateDir: filepath.Join(dir, "state"), + LogDir: filepath.Join(dir, "logs"), + }, + CacheDir: "/srv/cache", + } + + var buf bytes.Buffer + mgr := fakeManager{status: service.Status{State: service.StateRunning}} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeUser, opts) + + out := buf.String() + if !strings.Contains(out, cacheRoot) { + t.Errorf("the install summary does not report the cache root %q of the config file:\n%s", cacheRoot, out) + } +} + +// TestInstallSummaryReportsTheCachePairOfTheInstance checks that the +// summary of the installation names the cache root and the hub directory of +// the pair the instance uses. +// +// A summary that names a cache root without its hub directory hides a layout +// that sends the blobs of the instance somewhere else. +func TestInstallSummaryReportsTheCachePairOfTheInstance(t *testing.T) { + opts := service.InstallOptions{ + Scope: service.ScopeUser, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(t.TempDir(), "hfdesk.json"), + StateDir: "/var/lib/hfdesk", + LogDir: "/var/log/hfdesk", + }, + CacheDir: "/srv/cache", + HubDir: "/srv/cache/hub", + } + + var buf bytes.Buffer + mgr := fakeManager{status: service.Status{State: service.StateRunning}} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeUser, opts) + + out := buf.String() + if got := summaryField(out, "Cache root"); got != "/srv/cache" { + t.Errorf("the cache root of the summary is %q, want %q:\n%s", got, "/srv/cache", out) + } + if got := summaryField(out, "Hub dir"); got != "/srv/cache/hub" { + t.Errorf("the hub dir of the summary is %q, want %q:\n%s", got, "/srv/cache/hub", out) + } +} + +// TestInstallSummaryReportsTheHubDirOfTheConfigFile checks that the summary +// names the hub directory of the cache root the config file of the instance +// defines: the config file owns the whole cache layout of the instance. +func TestInstallSummaryReportsTheHubDirOfTheConfigFile(t *testing.T) { + dir := t.TempDir() + configFile := filepath.Join(dir, "hfdesk.json") + cacheRoot := filepath.Join(dir, "instance-cache") + writeConfigFile(t, configFile, cacheRoot) + + opts := service.InstallOptions{ + Scope: service.ScopeUser, + Executable: "/usr/local/bin/hfdesk", + Listen: "127.0.0.1:8080", + Paths: runtimepaths.Paths{ + ConfigFile: configFile, + StateDir: filepath.Join(dir, "state"), + LogDir: filepath.Join(dir, "logs"), + }, + CacheDir: "/srv/cache", + HubDir: "/srv/cache/hub", + } + + var buf bytes.Buffer + mgr := fakeManager{status: service.Status{State: service.StateRunning}} + writeInstallSummary(&buf, context.Background(), mgr, service.ScopeUser, opts) + + out := buf.String() + if got := summaryField(out, "Cache root"); got != cacheRoot { + t.Errorf("the cache root of the summary is %q, want %q:\n%s", got, cacheRoot, out) + } + wantHub := filepath.Join(cacheRoot, "hub") + if got := summaryField(out, "Hub dir"); got != wantHub { + t.Errorf("the hub dir of the summary is %q, want %q:\n%s", got, wantHub, out) + } +} + +// TestUninstallSummaryNamesTheEffectiveCacheRoot checks that the summary of +// the uninstall names the cache root the instance uses. +func TestUninstallSummaryNamesTheEffectiveCacheRoot(t *testing.T) { + dir := t.TempDir() + configFile := filepath.Join(dir, "hfdesk.json") + cacheRoot := filepath.Join(dir, "instance-cache") + writeConfigFile(t, configFile, cacheRoot) + + for _, tc := range []struct { + name string + status service.Status + want string + }{ + { + name: "the cache root of the config file of the instance", + status: service.Status{ + Paths: runtimepaths.Paths{ + ConfigFile: configFile, + StateDir: filepath.Join(dir, "state"), + LogDir: filepath.Join(dir, "logs"), + }, + }, + want: cacheRoot, + }, + { + name: "the cache root of the registration", + status: service.Status{ + Paths: runtimepaths.Paths{ + ConfigFile: filepath.Join(dir, "missing.json"), + StateDir: filepath.Join(dir, "state"), + LogDir: filepath.Join(dir, "logs"), + }, + CacheDir: "/srv/registered-cache", + }, + want: "/srv/registered-cache", + }, + } { + t.Run(tc.name, func(t *testing.T) { + mgr := fakeManager{status: tc.status} + paths, reported, registered := instanceDataPaths(context.Background(), mgr, service.ScopeUser) + + var buf bytes.Buffer + writeUninstallSummary(&buf, service.ScopeUser, paths, reported, registered) + + out := buf.String() + if !strings.Contains(out, tc.want) { + t.Errorf("the uninstall summary does not name the cache root %q:\n%s", tc.want, out) + } + if personal := personalCacheRoot(t); personal != "" && strings.Contains(out, personal) { + t.Errorf("the uninstall summary reports the personal cache of the invoking user %q:\n%s", personal, out) + } + }) + } +} + +func TestNonLoopbackWarning(t *testing.T) { + warning := nonLoopbackWarning("0.0.0.0:8080") + if warning == "" { + t.Error("a non-loopback address must produce a warning") + } + if !strings.Contains(warning, "0.0.0.0:8080") { + t.Errorf("the warning does not name the address:\n%s", warning) + } + + if got := nonLoopbackWarning("127.0.0.1:8080"); got != "" { + t.Errorf("a loopback address must not produce a warning, got:\n%s", got) + } + if got := nonLoopbackWarning(""); got != "" { + t.Errorf("an unknown address must not produce a warning, got:\n%s", got) + } +} + +func TestUpgradeWarning(t *testing.T) { + if got := upgradeWarning("/usr/local/bin/hfdesk"); got != "" { + t.Errorf("a stable path must not produce a warning, got:\n%s", got) + } + if got := upgradeWarning("/tmp/hfdesk-1.2.3/hfdesk"); got == "" { + t.Error("a version-stamped path must produce a warning") + } + if got := upgradeWarning(filepath.Join(os.TempDir(), "hfdesk", "hfdesk")); got == "" { + t.Error("a temporary path must produce a warning") + } +} + +// TestUpgradeWarningDescribesTheRegisteredPath checks that the warning is +// about the path the registration points at. +// +// The registration keeps the name the installation is known by, so a stable +// name stays quiet even when the file behind it is a version-stamped +// release artifact: the next release replaces the file, the registration +// does not change. +func TestUpgradeWarningDescribesTheRegisteredPath(t *testing.T) { + if got := upgradeWarning("/usr/local/bin/hfdesk"); got != "" { + t.Errorf("a stable registered path must not produce a warning, got:\n%s", got) + } + if got := upgradeWarning("/opt/hfdesk-1.2.3/hfdesk"); got == "" { + t.Error("a version-stamped registered path must produce a warning") + } +} + +// TestServiceInstallRejectsAnUnusableExecutable checks that the +// installation is refused when the named binary cannot be registered. +// +// The registration points at that exact path, so a path that is not a +// program would break the instance at the first start. The checks of the +// command line are the ones that hold on every platform: the path is +// absolute, it exists and it is a regular file. +func TestServiceInstallRejectsAnUnusableExecutable(t *testing.T) { + dir := t.TempDir() + + for _, tc := range []struct { + name string + path string + want string + }{ + {"a relative path", "bin/hfdesk", "absolute"}, + {"a missing file", filepath.Join(dir, "missing"), "not usable"}, + {"a directory", dir, "regular file"}, + } { + t.Run(tc.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"install", "--system", "--executable", tc.path}, &stdout, &stderr) + if code != serviceUsageExitCode { + t.Fatalf("the installation must be refused with the exit code %d, got %d", serviceUsageExitCode, code) + } + if !strings.Contains(stderr.String(), tc.want) { + t.Errorf("the error does not report %q:\n%s", tc.want, stderr.String()) + } + if stdout.Len() != 0 { + t.Errorf("a refused installation must not report a registration:\n%s", stdout.String()) + } + }) + } +} + +// TestServiceInstallAcceptsAnExistingExecutable checks that a binary that +// exists is accepted by the command layer. +// +// The command layer checks what is meaningful on every platform: the path +// is absolute, it exists and it is a regular file. The execute bit is a +// fact of the platform and belongs to the platform manager: Windows +// reports a regular file without any execute bit, so a check of the +// permission bits would refuse every binary of a Windows installation. +func TestServiceInstallAcceptsAnExistingExecutable(t *testing.T) { + useFakeManager(t, fakeManager{status: service.Status{State: service.StateStopped}}) + + dir := t.TempDir() + program := filepath.Join(dir, "hfdesk") + // The file carries no execute bit: the command line does not look at + // the permission bits. + if err := os.WriteFile(program, []byte("not a program\n"), 0o644); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"install", "--system", "--executable", program}, &stdout, &stderr) + if code != 0 { + t.Fatalf("the installation must be accepted, got the exit code %d:\n%s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "The service was registered.") { + t.Errorf("the summary does not report the registration:\n%s", stdout.String()) + } +} + +// TestResolveExecutableKeepsTheStableNameOfTheInvocation checks that the +// registration points at the name the user invoked. +// +// os.Executable() resolves a symbolic link and returns its target, so a +// registration built from it points at the version-stamped release artifact +// instead of at the stable name of the installation. The invocation path +// keeps the name. +func TestResolveExecutableKeepsTheStableNameOfTheInvocation(t *testing.T) { + root := t.TempDir() + release := filepath.Join(root, "hfdesk-1.2.3", "hfdesk") + if err := os.MkdirAll(filepath.Dir(release), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(release, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + stable := filepath.Join(root, "bin", "hfdesk") + if err := os.MkdirAll(filepath.Dir(stable), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(release, stable); err != nil { + t.Fatal(err) + } + + got, err := resolveExecutable("", stable) + if err != nil { + t.Fatalf("resolveExecutable() = %v, want nil", err) + } + if got != stable { + t.Errorf("the registered executable is %q, want the invoked path %q", got, stable) + } +} + +// TestUpgradeWarningStaysQuietForAStableName checks the whole chain: a +// stable name that points at a version-stamped file does not produce a +// warning. +// +// The registration keeps the name the installation is known by, and the next +// release replaces the file behind that name. +func TestUpgradeWarningStaysQuietForAStableName(t *testing.T) { + root := treeOutsideTempDir(t) + release := filepath.Join(root, "hfdesk-1.2.3", "hfdesk") + if err := os.MkdirAll(filepath.Dir(release), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(release, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + stable := filepath.Join(root, "bin", "hfdesk") + if err := os.MkdirAll(filepath.Dir(stable), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(release, stable); err != nil { + t.Fatal(err) + } + + registered, err := resolveExecutable("", stable) + if err != nil { + t.Fatalf("resolveExecutable() = %v, want nil", err) + } + if registered != stable { + t.Fatalf("the registered executable is %q, want the invoked path %q", registered, stable) + } + if got := upgradeWarning(registered); got != "" { + t.Errorf("a stable registered path must not produce a warning, got:\n%s", got) + } + // The version-stamped path itself is a warning: it does not survive + // the next upgrade. + if got := upgradeWarning(release); got == "" { + t.Error("a version-stamped registered path must produce a warning") + } +} + +// treeOutsideTempDir returns a directory that is not inside the temporary +// directory of the process. +// +// The upgrade warning treats a path inside the temporary directory as a path +// that does not survive an upgrade, so a test of the warning needs a tree +// outside of it. +func treeOutsideTempDir(t *testing.T) string { + t.Helper() + base := t.TempDir() + // The temporary directory of the process moves next to the tree, so + // the tree is not inside it. + moved := filepath.Join(base, "tmp") + t.Setenv("TMPDIR", moved) + t.Setenv("TMP", moved) + t.Setenv("TEMP", moved) + return filepath.Join(base, "tree") +} + +// TestResolveExecutablePrefersTheInvocationPath checks that the path the +// user typed is the one the registration points at. +func TestResolveExecutablePrefersTheInvocationPath(t *testing.T) { + root := t.TempDir() + bin := filepath.Join(root, "hfdesk") + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + got, err := resolveExecutable("", bin) + if err != nil { + t.Fatalf("resolveExecutable() = %v, want nil", err) + } + if got != bin { + t.Errorf("the registered executable is %q, want the invoked path %q", got, bin) + } +} + +// TestResolveExecutableFallsBackToTheRunningBinary checks that a bare name +// from the search path of the shell is never resolved against the working +// directory. +// +// The name of a command of the search path says nothing about the file the +// user wants to register: it is resolved against the working directory of +// the install command, which is not where the binary lives. +func TestResolveExecutableFallsBackToTheRunningBinary(t *testing.T) { + root := t.TempDir() + // A file with the name of the command in the working directory. + if err := os.WriteFile(filepath.Join(root, "hfdesk"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + previous, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(previous) }) + + got, err := resolveExecutable("", "hfdesk") + if err != nil { + t.Fatalf("resolveExecutable() = %v, want nil", err) + } + if got == filepath.Join(root, "hfdesk") { + t.Errorf("the registered executable is %q, want the path of the running binary", got) + } + + running, err := os.Executable() + if err != nil { + t.Fatalf("the path of the running binary: %v", err) + } + want, err := filepath.Abs(running) + if err != nil { + t.Fatalf("the absolute path of the running binary: %v", err) + } + if got != want { + t.Errorf("the registered executable is %q, want the path of the running binary %q", got, want) + } +} + +// TestResolveExecutableRejectsAnUnusableExplicitPath checks that an +// explicit path has to be a file the registration can point at. +// +// The path must be absolute, it must exist and it must be a regular file. +// The execute bit is not checked here: it is a fact of the platform, and +// the platform manager owns it. +func TestResolveExecutableRejectsAnUnusableExplicitPath(t *testing.T) { + root := t.TempDir() + program := filepath.Join(root, "hfdesk") + if err := os.WriteFile(program, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + path string + want string + }{ + {"a relative path", "bin/hfdesk", "absolute"}, + {"a missing file", filepath.Join(root, "missing"), "not usable"}, + {"a directory", root, "regular file"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := resolveExecutable(tc.path, "") + if err == nil { + t.Fatalf("resolveExecutable(%q) = %q, want an error", tc.path, got) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("the error of resolveExecutable(%q) = %v, want it to mention %q", tc.path, err, tc.want) + } + }) + } +} + +// TestResolveExecutableAcceptsAnExistingFile checks that an existing +// regular file is accepted, whatever its permission bits say. +// +// The execute bit is a fact of the platform: Windows reports a regular +// file without any execute bit, so a check of the permission bits would +// refuse every binary of a Windows installation. The managers of the +// platforms that require the bit enforce it themselves. +func TestResolveExecutableAcceptsAnExistingFile(t *testing.T) { + root := t.TempDir() + plain := filepath.Join(root, "notes.txt") + if err := os.WriteFile(plain, []byte("notes\n"), 0o644); err != nil { + t.Fatal(err) + } + + got, err := resolveExecutable(plain, "") + if err != nil { + t.Fatalf("resolveExecutable(%q) = %v, want nil", plain, err) + } + if got != plain { + t.Errorf("the registered executable is %q, want %q", got, plain) + } +} + +// TestResolveExecutableUsesTheExplicitPath checks that an explicit path +// wins over the invoked binary. +func TestResolveExecutableUsesTheExplicitPath(t *testing.T) { + root := t.TempDir() + program := filepath.Join(root, "hfdesk") + if err := os.WriteFile(program, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + got, err := resolveExecutable(program, filepath.Join(root, "other")) + if err != nil { + t.Fatalf("resolveExecutable() = %v, want nil", err) + } + if got != program { + t.Errorf("the registered executable is %q, want the explicit path %q", got, program) + } +} + +func TestRunPathReturnsAfterTheContextIsCancelled(t *testing.T) { + dir := t.TempDir() + stateDir := filepath.Join(dir, "state") + + // The host installs the path model process wide; the interactive + // resolution is restored afterwards. + t.Cleanup(runtimepaths.Reset) + + // The host drops the token of the environment; it is restored so the + // rest of the test run keeps its environment. + token, hadToken := os.LookupEnv("HF_TOKEN") + t.Cleanup(func() { + if hadToken { + _ = os.Setenv("HF_TOKEN", token) + return + } + _ = os.Unsetenv("HF_TOKEN") + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + args := []string{ + "--config", filepath.Join(stateDir, "hfdesk.json"), + "--state-dir", stateDir, + "--log-dir", filepath.Join(stateDir, "logs"), + "--listen", net.JoinHostPort("127.0.0.1", strconv.Itoa(freeLoopbackPort(t))), + } + + done := make(chan error, 1) + go func() { done <- runServiceHost(ctx, args) }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("the host returned an error: %v", err) + } + case <-time.After(30 * time.Second): + t.Fatal("the host did not return after the context was cancelled") + } +} + +// freeLoopbackPort returns a port of the loopback interface that is free at +// the moment. +// +// The service layer rejects port 0, so the host needs a real port. The port +// is reserved and released again, so the instance binds a port of its own +// instead of a well known one. +func freeLoopbackPort(t *testing.T) int { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve a loopback port: %v", err) + } + defer func() { _ = listener.Close() }() + + addr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("the reserved address is %T, want a TCP address", listener.Addr()) + } + return addr.Port +} + +func TestRunPathRejectsAnUnknownFlag(t *testing.T) { + if err := runServiceHost(context.Background(), []string{"--unknown", "value"}); err == nil { + t.Error("an unknown flag must be rejected") + } +} + +// TestInteractiveFlagsAreUnchanged proves that the service mode did not +// change the interactive command line. +// +// The interactive host registers its flags through the shared application +// layer, so the flag set of this test is built the way the interactive path +// builds it: the names and the defaults are read from the registered flag +// set and not from a source file. A source scan only proves where a +// registration is written down, while the flag set proves what the command +// line really offers. +func TestInteractiveFlagsAreUnchanged(t *testing.T) { + var opts app.Options + fs := flag.NewFlagSet("hfdesk", flag.ContinueOnError) + app.RegisterFlags(fs, &opts) + + // The lookup reports a missing flag instead of dereferencing it, so a + // flag that is gone does not turn the defaults into a panic. A flag + // that is gone is reported once, not once per assertion about it. + reported := map[string]bool{} + lookup := func(name string) *flag.Flag { + f := fs.Lookup(name) + if f == nil && !reported[name] { + reported[name] = true + t.Errorf("the interactive command line does not register the %q flag any more", name) + } + return f + } + + // The original flags of the interactive command line. Their names are + // the contract with the scripts that call the host. + for _, name := range []string{"port", "cache-dir", "token", "local-dir", "stall-timeout", "open", "no-open"} { + lookup(name) + } + + // The defaults are the part of the contract that matters most: a host + // that is started without an argument has to behave exactly as it + // behaved before the service mode was added. + if err := fs.Parse(nil); err != nil { + t.Fatalf("parse the interactive flags: %v", err) + } + if opts.Port != 8080 { + t.Errorf("the default port is %d, want 8080", opts.Port) + } + if !opts.OpenUI { + t.Error("the default of the \"open\" flag is false, want true") + } + if opts.NoOpen { + t.Error("the default of the \"no-open\" flag is true, want false") + } + for _, c := range []struct { + name string + value string + }{ + {"cache-dir", opts.CacheDir}, + {"token", opts.Token}, + {"local-dir", opts.LocalDir}, + {"stall-timeout", opts.StallTimeout}, + } { + if c.value != "" { + t.Errorf("the default of the %q flag is %q, want an empty value", c.name, c.value) + } + } + + // The defaults of the registered flags themselves must be the very + // same values: the registration and the options are one contract. + if f := lookup("port"); f != nil && f.DefValue != "8080" { + t.Errorf("the registered default of the \"port\" flag is %q, want %q", f.DefValue, "8080") + } + if f := lookup("open"); f != nil && f.DefValue != "true" { + t.Errorf("the registered default of the \"open\" flag is %q, want %q", f.DefValue, "true") + } + if f := lookup("no-open"); f != nil && f.DefValue != "false" { + t.Errorf("the registered default of the \"no-open\" flag is %q, want %q", f.DefValue, "false") + } + for _, name := range []string{"cache-dir", "token", "local-dir", "stall-timeout"} { + if f := lookup(name); f != nil && f.DefValue != "" { + t.Errorf("the registered default of the %q flag is %q, want an empty value", name, f.DefValue) + } + } +} + +func TestServiceDispatchIsExact(t *testing.T) { + if !isServiceCommand([]string{"service", "status"}) { + t.Error("the token \"service\" must select the service command family") + } + // The token alone selects the family as well: the family itself + // reports the missing subcommand. + if !isServiceCommand([]string{"service"}) { + t.Error("the token \"service\" alone must select the service command family") + } + for _, args := range [][]string{ + nil, + {}, + {"--port", "9090"}, + {"--help"}, + {"services"}, + {"Service"}, + } { + if isServiceCommand(args) { + t.Errorf("%v must not select the service command family", args) + } + } +} + +// TestServiceUnknownFlagIsAUsageError checks that an unknown flag of every +// subcommand is a command line that could not be understood. +// +// The exit code 2 tells a script that the invocation was wrong. The exit +// code 1 would tell it that the operation failed, and the script would +// report a broken installation for a typo. +func TestServiceUnknownFlagIsAUsageError(t *testing.T) { + for _, sub := range serviceSubcommands { + t.Run(sub, func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{sub, "--bogus"}, &stdout, &stderr) + if code != serviceUsageExitCode { + t.Errorf("the %q subcommand must reject an unknown flag with the exit code %d, got %d", + sub, serviceUsageExitCode, code) + } + if !strings.Contains(stderr.String(), "bogus") { + t.Errorf("the error of the %q subcommand does not name the rejected flag:\n%s", sub, stderr.String()) + } + }) + } +} + +// TestServiceInvalidListenIsAUsageError checks that a bind address the +// command line cannot be parsed with is a command line that could not be +// understood. +// +// An empty host binds every interface, a port out of range names no port +// and a missing port is no address at all. +func TestServiceInvalidListenIsAUsageError(t *testing.T) { + for _, listen := range []string{ + ":8080", + "127.0.0.1:0", + "127.0.0.1:70000", + "127.0.0.1", + "localhost", + "", + } { + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"install", "--listen", listen}, &stdout, &stderr) + if code != serviceUsageExitCode { + t.Errorf("the bind address %q must exit with %d, got %d", listen, serviceUsageExitCode, code) + } + if stderr.Len() == 0 { + t.Errorf("the bind address %q is rejected without a reason", listen) + } + } +} + +// TestServiceOperationalFailureExitsWithOne checks that an operation that +// was understood but failed exits with 1. +// +// The failure of the supervisor is the case of the family: the service is +// not installed, the elevation is missing or the supervisor refuses. The +// exit code 1 keeps it apart from a command line that could not be +// understood. +func TestServiceOperationalFailureExitsWithOne(t *testing.T) { + useFakeManager(t, fakeManager{opErr: errors.New("service: hfdesk.service is not installed")}) + + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"start"}, &stdout, &stderr) + if code != serviceFailureExitCode { + t.Errorf("a failed operation must exit with %d, got %d", serviceFailureExitCode, code) + } + if !strings.Contains(stderr.String(), "not installed") { + t.Errorf("the error does not name the reason of the failure:\n%s", stderr.String()) + } +} + +// TestServiceStatusOfAnAbsentRegistrationExitsWithOne checks that the +// status of a service that is not installed is a failed operation. +// +// The state is not one a script can use, so the command reports it with the +// exit code 1 and not with the exit code 0 of a usable instance. +func TestServiceStatusOfAnAbsentRegistrationExitsWithOne(t *testing.T) { + useFakeManager(t, fakeManager{status: service.Status{State: service.StateNotInstalled}}) + + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"status"}, &stdout, &stderr) + if code != serviceFailureExitCode { + t.Errorf("the status of an absent registration must exit with %d, got %d", serviceFailureExitCode, code) + } +} + +// TestServiceSuccessfulCommandExitsWithZero checks that a command that did +// what it was asked to do exits with 0. +func TestServiceSuccessfulCommandExitsWithZero(t *testing.T) { + useFakeManager(t, fakeManager{status: service.Status{State: service.StateRunning}}) + + for _, sub := range []string{"start", "status"} { + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{sub}, &stdout, &stderr) + if code != 0 { + t.Errorf("the %q subcommand must exit with 0, got %d:\n%s", sub, code, stderr.String()) + } + if stderr.Len() != 0 { + t.Errorf("the %q subcommand must not write to stderr:\n%s", sub, stderr.String()) + } + } +} + +// TestUninstallSummaryStatesWhatTheRemovalDid checks that the summary of +// the uninstall states what actually happened. +// +// A summary that always claims a removal hides the difference between a +// scope that had a registration and a scope that had none. +func TestUninstallSummaryStatesWhatTheRemovalDid(t *testing.T) { + for _, tc := range []struct { + name string + state service.State + want string + notWant string + }{ + { + name: "a scope with a registration", + state: service.StateStopped, + want: "The registration of hfdesk (user scope) was removed.", + notWant: "There was no registration", + }, + { + name: "a scope without a registration", + state: service.StateNotInstalled, + want: "There was no registration of hfdesk (user scope) to remove.", + notWant: "was removed", + }, + } { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + writeUninstallSummary(&buf, service.ScopeUser, runtimepaths.Paths{}, cacheRootNotRecorded, + tc.state != service.StateNotInstalled) + + out := buf.String() + if !strings.Contains(out, tc.want) { + t.Errorf("the uninstall summary does not state %q:\n%s", tc.want, out) + } + if strings.Contains(out, tc.notWant) { + t.Errorf("the uninstall summary states %q as well:\n%s", tc.notWant, out) + } + if !strings.Contains(out, "The user data was kept") { + t.Errorf("the uninstall summary does not report the kept user data:\n%s", out) + } + }) + } +} + +// TestServiceUninstallReportsWhatTheRemovalDid drives the uninstall +// command through the manager of the test and checks that the summary +// states what the removal did. +func TestServiceUninstallReportsWhatTheRemovalDid(t *testing.T) { + for _, tc := range []struct { + name string + state service.State + want string + notWant string + }{ + { + name: "a registered service", + state: service.StateStopped, + want: fmt.Sprintf("The registration of %s (%s scope) was removed.", service.ServiceName, defaultScope()), + notWant: "There was no registration", + }, + { + name: "an unregistered service", + state: service.StateNotInstalled, + want: fmt.Sprintf("There was no registration of %s (%s scope) to remove.", service.ServiceName, defaultScope()), + notWant: "was removed", + }, + } { + t.Run(tc.name, func(t *testing.T) { + useFakeManager(t, fakeManager{status: service.Status{State: tc.state}}) + + var stdout, stderr bytes.Buffer + code := runServiceCommand([]string{"uninstall"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("the uninstall must exit with 0, got %d:\n%s", code, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, tc.want) { + t.Errorf("the uninstall summary does not state %q:\n%s", tc.want, out) + } + if strings.Contains(out, tc.notWant) { + t.Errorf("the uninstall summary states %q as well:\n%s", tc.notWant, out) + } + if !strings.Contains(out, "The user data was kept") { + t.Errorf("the uninstall summary does not report the kept user data:\n%s", out) + } + }) + } +} + +// TestServicePlatformFactsOfTheHost checks that the facts the service layer +// exposes are the facts of the platform the test runs on. +// +// The command line decides with these facts, so a fact that does not match +// the platform would make the command line decide about another platform. +func TestServicePlatformFactsOfTheHost(t *testing.T) { + want, ok := platformFactsByName[runtime.GOOS] + if !ok { + t.Skipf("the platform %q is not one of the platforms of the service layer", runtime.GOOS) + } + + if got := service.DefaultScope(); got != want.defaultScope { + t.Errorf("the default scope of %s is %q, want %q", runtime.GOOS, got, want.defaultScope) + } + if got := service.SupportsUserScope(); got != want.supportsUserScope { + t.Errorf("%s has a per-user scope: %v, want %v", runtime.GOOS, got, want.supportsUserScope) + } + if got := service.SupportsNamedServiceAccount(); got != want.supportsNamedServiceAccount { + t.Errorf("%s has a named service account: %v, want %v", runtime.GOOS, got, want.supportsNamedServiceAccount) + } +} + +// TestServicePerPlatformFacts checks the facts every platform of the +// service layer defines. +// +// The default scope of an installation is the user scope on Linux and +// macOS and the system scope on Windows, and the account of a machine-wide +// instance can be named on Linux and macOS but not on Windows. +// +// The facts of a platform live in the file of that platform, so the guard +// reads them from the source: a test that runs on one platform cannot call +// the code of another one. +func TestServicePerPlatformFacts(t *testing.T) { + for _, tc := range []struct { + file string + scope service.Scope + userScope bool + namedUser bool + }{ + {file: "serviceuser_linux.go", scope: service.ScopeUser, userScope: true, namedUser: true}, + {file: "serviceuser_darwin.go", scope: service.ScopeUser, userScope: true, namedUser: true}, + {file: "serviceuser_windows.go", scope: service.ScopeSystem, userScope: false, namedUser: false}, + {file: "serviceuser_other.go", scope: service.ScopeUser, userScope: true, namedUser: true}, + } { + t.Run(tc.file, func(t *testing.T) { + if got := serviceScopeOf(platformFactOf(t, tc.file, "defaultScope")); got != tc.scope { + t.Errorf("the default scope of %s is %q, want %q", tc.file, got, tc.scope) + } + if got := platformFactOf(t, tc.file, "supportsUserScope"); got != boolName(tc.userScope) { + t.Errorf("the per-user scope of %s is %q, want %q", tc.file, got, boolName(tc.userScope)) + } + if got := platformFactOf(t, tc.file, "supportsNamedServiceAccount"); got != boolName(tc.namedUser) { + t.Errorf("the named service account of %s is %q, want %q", tc.file, got, boolName(tc.namedUser)) + } + }) + } +} + +// TestServiceDefaultScopeFollowsThePlatformFacts checks that the default +// scope of an installation is the scope of the platform. +// +// The user scope is the default on Linux and macOS, the system scope on +// Windows: the service control manager of Windows registers machine-wide +// services only. +func TestServiceDefaultScopeFollowsThePlatformFacts(t *testing.T) { + for _, goos := range []string{"linux", "darwin", "windows"} { + t.Run(goos, func(t *testing.T) { + usePlatform(t, goos) + + scope, err := resolveScope(false, false) + if err != nil { + t.Fatalf("the default scope must resolve: %v", err) + } + if want := platformFactsByName[goos].defaultScope; scope != want { + t.Errorf("the default scope on %s is %q, want %q", goos, scope, want) + } + }) + } +} + +// TestServiceUserScopeRefusalFollowsThePlatformFacts checks that the +// per-user scope is refused where the platform does not have it. +// +// The service control manager of Windows registers machine-wide services +// only, so a per-user installation does not exist there. +func TestServiceUserScopeRefusalFollowsThePlatformFacts(t *testing.T) { + for _, tc := range []struct { + goos string + wantError bool + }{ + {goos: "linux"}, + {goos: "darwin"}, + {goos: "windows", wantError: true}, + } { + t.Run(tc.goos, func(t *testing.T) { + usePlatform(t, tc.goos) + + scope, err := resolveScope(true, false) + if !tc.wantError { + if err != nil { + t.Fatalf("the per-user scope must resolve on %s: %v", tc.goos, err) + } + if scope != service.ScopeUser { + t.Errorf("the scope on %s is %q, want %q", tc.goos, scope, service.ScopeUser) + } + return + } + if err == nil { + t.Fatal("the per-user scope must be refused where the platform does not have it") + } + if !isServiceUsageError(err) { + t.Errorf("the refusal must be a command line that could not be understood: %v", err) + } + }) + } +} + +// TestServiceUserRefusalKeepsItsMessage checks that the refusal of a named +// account on Windows keeps its message. +// +// The message names the account the instance really runs as and explains +// why the account cannot be chosen: a user of the platform has to know what +// to do instead of the option. +func TestServiceUserRefusalKeepsItsMessage(t *testing.T) { + usePlatform(t, "windows") + + _, err := buildInstallOptions(service.ScopeSystem, installFlags{serviceUser: "hfdesk-svc"}, "/usr/local/bin/hfdesk") + if err == nil { + t.Fatal("the machine-wide scope must reject a service account on windows") + } + want := "--service-user is not available on windows: the machine-wide instance runs as the built-in " + + windowsServiceAccount + " account and the account cannot be chosen on this platform " + + "(a service under a user account needs a credential, and a credential must never reach a command line), drop the option" + if err.Error() != want { + t.Errorf("the refusal is %q, want %q", err.Error(), want) + } +} + +// TestServiceCommandHasNoOperatingSystemComparison checks that the command +// family does not compare the operating system. +// +// The scope and the account of an instance are facts of the platform, and +// the service layer owns them: a comparison in the command line would put +// the decision about the platform back into a shared layer. +// +// The guard names the comparison and not the platform: the messages of the +// command line name the platform they talk about. +func TestServiceCommandHasNoOperatingSystemComparison(t *testing.T) { + source, err := os.ReadFile("service.go") + if err != nil { + t.Fatalf("read service.go: %v", err) + } + + text := string(source) + for _, forbidden := range []string{"runtime.GOOS", "platformGOOS", "isWindows"} { + if strings.Contains(text, forbidden) { + t.Errorf("the command family compares the operating system (%q):\n%s", forbidden, sourceLinesWith(text, forbidden)) + } + } +} + +// platformFactOf returns the value a per-platform function of the service +// layer returns. +// +// The facts of a platform live in the file of that platform, and the code +// of another platform cannot be called from a test that runs on this one: +// the guard reads the facts from the source of the platform file. +func platformFactOf(t *testing.T, file, name string) string { + t.Helper() + + path := filepath.Join("..", "..", "internal", "service", file) + parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + + for _, decl := range parsed.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name.Name != name || fn.Body == nil { + continue + } + for _, stmt := range fn.Body.List { + ret, ok := stmt.(*ast.ReturnStmt) + if !ok || len(ret.Results) != 1 { + continue + } + if value, ok := ret.Results[0].(*ast.Ident); ok { + return value.Name + } + } + } + + t.Fatalf("%s does not return a value from %s", path, name) + return "" +} + +// serviceScopeOf turns the name of a scope of the service layer into the +// scope it names. +func serviceScopeOf(name string) service.Scope { + switch name { + case "ScopeUser": + return service.ScopeUser + case "ScopeSystem": + return service.ScopeSystem + } + return service.Scope(name) +} + +// boolName returns the name a boolean literal has in the source of the +// service layer. +func boolName(value bool) string { + if value { + return "true" + } + return "false" +} + +// sourceLinesWith returns the lines of a source that hold the given text. +func sourceLinesWith(source, text string) string { + var lines []string + for _, line := range strings.Split(source, "\n") { + if strings.Contains(line, text) { + lines = append(lines, strings.TrimSpace(line)) + } + } + return strings.Join(lines, "\n") +} diff --git a/docs/API.md b/docs/API.md index a93b3bf8..2ae050a6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -819,6 +819,11 @@ describing H's derivation. Environment paths/defaults are captured at server startup, not re-read by getters or runners. Editing R with ENV H set changes friendly views, not Hub storage, and does not move existing files. +Service mode: the order above does not apply. The config file the instance owns +wins over the cache root recorded in its registration, because the settings an +administrator changes through the settings API of a supervised instance have to +survive a restart of the instance. + When `localDir` is set, downloads use real files under `//`, which matches LM Studio-style model roots. `localScanDirs` are independent model roots scanned as `/` folders diff --git a/docs/SERVICE.md b/docs/SERVICE.md new file mode 100644 index 00000000..1bf73db8 --- /dev/null +++ b/docs/SERVICE.md @@ -0,0 +1,610 @@ +# HFDesk Service Mode + +HFDesk can run as a supervised background service instead of an interactive +process. The platform supervisor owns the lifecycle — systemd on Linux, launchd +on macOS, the Windows Service Control Manager on Windows — and HFDesk stays a +plain foreground process inside it. + +The supervisor owns the lifecycle completely: + +- HFDesk stays a foreground process: no self-daemonization, no PID file, no + `nohup`, no shell wrapper, no Task Scheduler, and no startup-folder + shortcut. +- The service is headless: it never opens a browser and never shows a tray + icon. +- The supervisor starts the instance at boot of its scope and restarts it when + it fails. + +Service mode is a **separate command family**. `hfdesk` without a `service` +subcommand behaves exactly as before: same flags, same config precedence, same +browser launch. Nothing about an interactive install is relocated by a service +installation. + +## Quick start + +```bash +hfdesk service install # per-user service on Linux/macOS +hfdesk service status # where it is registered and what it binds +``` + +The dashboard is then served on . + +## Prerequisites + +- A HFDesk binary at a **stable, absolute path** (see + [Upgrades](#upgrades)). +- The platform supervisor: `systemd` on Linux, `launchd` on macOS, the Service + Control Manager on Windows. +- Administrator rights for a machine-wide (`--system`) installation, and for + the machine-wide `start`, `stop` and `restart`. HFDesk never elevates + itself. +- The **service account** of a machine-wide installation on Linux and macOS, + already created (see + [The identity of a machine-wide instance](#the-identity-of-a-machine-wide-instance)). + A user-scope installation needs no account: it runs as the user itself. + Windows needs none either: the instance runs as the built-in `LocalService` + account. +- A writable state root for the chosen scope (see + [Where the state lives](#where-the-state-lives)). + +The interactive CLI keeps its existing config precedence: a config file next to +the launch/executable directory first, then the per-user config directory. +Service mode does not relocate existing interactive installs and does not +change where an interactive run reads its config from. + +## Command surface + +``` +hfdesk service install [--user|--system] [--listen HOST:PORT] [--config PATH] [--state-dir DIR] [--log-dir DIR] [--cache-dir DIR] [--hub-dir DIR] [--local-dir DIR] [--service-user ACCOUNT] [--executable PATH] +hfdesk service uninstall [--user|--system] +hfdesk service start [--user|--system] +hfdesk service stop [--user|--system] +hfdesk service restart [--user|--system] +hfdesk service status [--user|--system] +hfdesk service run --config PATH --state-dir DIR --log-dir DIR [--listen HOST:PORT] [--cache-dir DIR] [--hub-dir DIR] [--local-dir DIR] +``` + +`service run` is the **internal host mode** the supervisor invokes. It is not +meant for interactive use: it requires a complete, absolute path model and it +never opens a browser. + +### Install flags + +| Flag | Meaning | +|---|---| +| `--user` | Per-user installation (the default on Linux and macOS). | +| `--system` | Machine-wide installation. Needs administrator rights. | +| `--listen HOST:PORT` | Bind address of the instance. Default `127.0.0.1:8080`. | +| `--config PATH` | Absolute path of the config file the instance owns. | +| `--state-dir DIR` | Absolute path of the state directory (jobs, history, mirror targets). | +| `--log-dir DIR` | Absolute path of the log directory. | +| `--cache-dir DIR` | Absolute path of the Hugging Face cache root. Without `--hub-dir`, the hub directory follows it: `/hub`. | +| `--hub-dir DIR` | Absolute path of the Hugging Face hub cache directory. Given explicitly, it separates the hub from the cache root on purpose. | +| `--local-dir DIR` | Absolute path of the flat output directory. | +| `--service-user ACCOUNT` | Account a **machine-wide** instance runs as on Linux and macOS. Default: the service account of the platform (`hfdesk` on Linux, `_hfdesk` on macOS). Rejected for the user scope, and rejected on Windows, where the instance always runs as the built-in `LocalService` account and the identity cannot be chosen. | +| `--executable PATH` | Absolute path of the binary the registration points at. Default: the binary that is invoked. | + +The path flags override the default locations of the scope. Every path of an +installation must be absolute; a relative path is rejected instead of being +resolved against the working directory of the supervisor. + +`--service-user` and `--executable` belong to `install` alone: they describe +the registration that is written, and a lifecycle command has no registration +to write them into. `--listen` is a flag of `install` and of the internal +`run` mode, because both of them bind the address of the instance. + +`--executable` names the program of the registration when it differs from the +binary that is run. The path must be absolute, must exist, must be a regular +file and must be executable. Without it the **invoked** path is used, so a +symbolic link keeps its name in the registration (see +[Upgrades](#upgrades)); a bare name that comes from the search path of the +shell names no file, so the resolved path of the running binary is registered +instead. + +`--service-user` names the account of a machine-wide instance (see +[The identity of a machine-wide instance](#the-identity-of-a-machine-wide-instance)). +It applies to the machine-wide scope on Linux and macOS: a user-scope instance +runs as the user itself, so the option is rejected there instead of being +ignored. Windows has no account to name: the instance always runs as the +built-in `LocalService` account, so the option is rejected there as well. + +The cache root and the hub directory are **one layout**: the cache root holds +the friendly views (`models/`, `datasets/`), the hub directory holds the +blobs. `--cache-dir` alone therefore moves the whole pair — the registered +hub directory becomes the `hub` child (`/hub`) of the registered +cache root, so a registration never names two different trees. `--hub-dir` +names the hub directory explicitly and separates the two locations on +purpose, the same independence `HF_HOME` and `HF_HUB_CACHE` have. The +summary of the installation reports both halves of the pair it registered. + +A cache or output directory recorded in the registration is the **fallback** +of the instance: the config file the instance owns wins over it (see +[The effective cache root of an instance](#the-effective-cache-root-of-an-instance)). + +`install` validates the installation before it writes anything: the +executable must exist, be a regular file and be executable, the bind address +must be usable, and the runtime paths must be absolute. A failed installation +restores the previous registration instead of leaving a half-written one +behind. + +The installation creates the directories of the instance — the config +directory, the state directory and the log directory — with owner-only +permissions (`0700`). A directory that **already exists** keeps its +permissions **and its ownership**: the installation never tightens a +directory the user named (for example with `--state-dir`), because that +directory may be shared with something else, and it never takes a directory +over from the owner it already has. A machine-wide instance grants the +service account only the directories it created itself; a directory that +already existed has to be writable by that account already (see +[The identity of a machine-wide instance](#the-identity-of-a-machine-wide-instance)). +The running instance follows the same rule for its log directory and its log +file: it creates them owner-only when they are missing and never changes the +mode of a directory or of a file that already exists. + +### Lifecycle flags + +`uninstall`, `start`, `stop`, `restart` and `status` take the scope selector +only (`--user` or `--system`). + +## Scopes + +| | `--user` | `--system` | +|---|---|---| +| Linux | systemd **user** unit (`systemctl --user`) | machine-wide systemd unit | +| macOS | per-user **LaunchAgent** | machine-wide **LaunchDaemon** | +| Windows | not available | machine-wide: the Windows Service Control Manager | + +`--user` is the default on Linux and macOS. Windows always uses the Windows +Service Control Manager, which registers machine-wide services only: there is +no `--user` mode there, and asking for one is rejected instead of silently +falling back to a machine-wide root. + +A user-scope instance and a system-scope instance never share a state root. + +## Where the state lives + +| Scope | Config file | State root (jobs, history, mirror targets) | Log dir | +|---|---|---|---| +| user (Linux/macOS) | `/HFDesk/service/hfdesk.json` | `/HFDesk/service` | `/HFDesk/service/logs` | +| system (Linux/macOS) | `/etc/hfdesk/hfdesk.json` | `/var/lib/hfdesk` | `/var/log/hfdesk` | +| Windows (machine) | `%ProgramData%\HFDesk\hfdesk.json` | `%ProgramData%\HFDesk` | `%ProgramData%\HFDesk\logs` | + +`` is the per-user HFDesk configuration directory of the +platform, for example `~/.config/HFDesk` on Linux and +`~/Library/Application Support/HFDesk` on macOS. + +The state root holds the mutable runtime state of the instance: the job list, +the download history and the mirror targets. The config file is the settings +file of the instance and holds its credentials. + +## The effective cache root of an instance + +An instance has two sources for its storage locations: the registration it +was installed with and the config file it owns. The **config file of the +instance wins**: + +- When the config file defines a `cache-dir`, that is the cache root the + instance uses. The cache root recorded in the registration is the fallback + for an instance whose config file does not define one. +- The same holds for the flat output directory (`local-dir`). +- The hub directory follows the cache root: it is the `hub` child + (`/hub`) of the cache root the config file of the instance + defines. Without a cache root from the config file, the hub directory + recorded in the registration is used, and a registration without one falls + back to the `hub` child of the effective cache root. + +By default the registration records the cache root of the scope: the +Hugging Face cache of the user (`HF_HOME`, otherwise `~/.cache/huggingface`) +for a user-scope instance, and `/cache` for a machine-wide one +(`/var/lib/hfdesk/cache` on Linux and macOS, `%ProgramData%\HFDesk\cache` on +Windows). The registered hub directory is the `hub` child of the registered +cache root, unless a hub directory was given explicitly (see +[Install flags](#install-flags)). + +**Consequence for the user:** changing the cache directory in the settings of +the service (`POST /api/settings`) is honoured by the next start of the +service, because those settings are written into the config file of the +instance and that file wins over the registration. + +The interactive CLI is unchanged: there a `--cache-dir` flag still wins over +the config file, exactly like a `--local-dir` flag. + +## Network exposure + +**Service mode binds loopback by default** (`127.0.0.1:8080`), even though the +interactive server defaults to a wildcard address. A supervised instance has +nobody sitting in front of it, so it must not expose the dashboard to the +network by accident. + +To reach the service from another machine, request the address explicitly: + +```bash +hfdesk service install --listen 0.0.0.0:8080 # every interface +hfdesk service install --listen 192.0.2.10:8080 # one interface +``` + +An address with an empty host is rejected, because an empty host binds every +interface. `install` and `status` make a non-loopback bind visible, so an +exposed instance is never silent about it. + +## Credentials + +The Hugging Face token, the proxy password and the basic-auth password are +**never** written into the supervisor registration, the process command line, +or the environment of the unit/plist. A registration is readable by every user +of the machine, so it carries paths and the bind address only. + +The credentials live in the config file the service owns, which HFDesk writes +with owner-only permissions (`0600` on Unix). In service mode the instance +reads the token from that config file; the `HF_TOKEN` environment variable is +deliberately ignored, so a supervised instance cannot silently pick up a +credential from an inherited environment. + +**Practical consequence:** after `hfdesk service install`, set the token for +the service through the service's own web UI (`POST /api/settings`), which +persists it into the service-owned config file. + +## Logs + +Service mode writes its own log file into the log directory of the instance +with bounded rotation: + +- `hfdesk.log` — the active log file +- `hfdesk.log.1`, `hfdesk.log.2`, `hfdesk.log.3` — the rotated generations + +The instance uses the same logging pipeline as the interactive runs, so the +records of the server land in the log file of the instance as well. The +active file is rotated before a write would exceed 5 MiB, and three rotated +generations are kept, so the on-disk size of the log stays bounded. + +The instance creates its log directory owner-only (`0700` on Unix) when it is +missing, and the log file keeps the owner-only mode (`0600` on Unix) of the +shared log file sink. It never changes the mode of a directory or of a file +that already exists: the log directory may be a directory that existed +before the installation and that belongs to someone else, and the instance +may only be able to write there. Tightening it needs an ownership the +installation never checked, and it would keep the instance from starting at +all. + +A log file that cannot be written does not stop the instance: the instance +falls back to the console sink alone, reports the failure on the standard +error of the process and keeps serving. The failure of the log file never +changes the exit code of the host. + +The records of the instance also reach the supervisor through the standard +error of the process: the systemd journal on Linux. On macOS the property +list sets **no** `StandardOutPath` and **no** `StandardErrorPath`: launchd +does not rotate the files it owns, so they would grow without bound. launchd +therefore discards them, and the bounded `hfdesk.log` inside the log +directory of the instance is the real log sink on macOS. + +## Lifecycle commands + +| Command | Effect | A service that is not installed | +|---|---|---| +| `install` | Registers the service with the supervisor and enables it for the boot of the scope. A macOS LaunchAgent starts the instance as soon as it is registered (`RunAtLoad`); a systemd unit is only registered and enabled. The `State` line of the summary reports the state the supervisor reached. An existing registration is overwritten. | It creates the registration. | +| `uninstall` | Removes the **registration only**. | Clean success: the registration is already gone. | +| `start` | Starts the registered service. | **Error** that says the service is not installed. | +| `stop` | Stops the registered service. | Clean success: there is nothing running to stop. | +| `restart` | Stops and starts the registered service. | **Error** that says the service is not installed. | +| `status` | Reports scope, state, registration path, executable, bind address, runtime paths and the effective cache root. | Reports the state `not-installed` and exits with `1`. | + +The **machine-wide scope** needs an **elevated shell** for `start`, `stop` and +`restart`, exactly as it needs one for `install` and `uninstall` (see +[Privileged operations](#privileged-operations)). The user scope needs no +elevation: the instance belongs to the user who runs the command. + +**Uninstall never deletes user data.** It removes the registration and nothing +else: not the models, not the cache, not the config file, not the jobs or the +history, not the logs. Removing the state of an instance is a separate, +deliberate action. + +The `Cache root` line of `status` and of `uninstall` is the **effective** +cache root of the instance — the one the instance actually uses (see +[The effective cache root of an instance](#the-effective-cache-root-of-an-instance)). +It is not necessarily the cache root that was recorded in the registration: +an instance whose own config file defines a `cache-dir` uses that one. + +The summary of `install` reports the same pair: its `Cache root` and `Hub +dir` lines name the two directories the registered instance uses, so a reader +sees at once which tree the models of the instance go into. + +The commands are idempotent where the wanted state is already reached: + +- `uninstall` on a service that is not installed succeeds; the registration is + already gone. +- `start` on a service that already runs succeeds. +- `stop` on a service that is already stopped succeeds, and so does a `stop` on + a service that is not installed at all: there is nothing running to stop. +- `install` over an existing registration replaces it. + +`start` and `restart` are the two commands that need a registration to work on: +on a service that is not installed they fail with a readable "not installed" +error. + +### Stopping an instance + +A stop is a **graceful** shutdown, in two phases: + +- The HTTP server is drained: it accepts no new request any more, and the + requests that are already running finish — up to **10 s**. +- The job manager is closed: the running jobs are cancelled and drained, and + the job list is written back to the state file — up to **10 s** again. + +The orderly path therefore needs up to **20 s**. + +The service layer publishes that budget and sizes every supervisor-side wait +after it, with a margin for the work the process still has to do around the +shutdown. The shared budget is **25 s**. + +| Platform | Where the budget is published | +|---|---| +| Linux (systemd) | `TimeoutStopSec=25s` in the generated unit. | +| macOS (launchd) | `ExitTimeOut` 25 in the generated property list. | +| Windows (Service Control Manager) | the bounded wait of the service handler. | + +launchd's own default of 20 s was exactly the total of the orderly path, so +the property list carries the budget of the service layer instead: the +deadline of a supervisor must never be shorter than the shutdown it waits +for. + +The instance is **never killed to make a stop succeed**. The deadline of the +supervisor is the only hard stop, and it is larger than the orderly path +needs, so an instance that shuts down in time is never interrupted while it +saves its state. + +A stop of an instance that is not running is a **clean success**: there is +nothing running to stop. A stop deletes nothing either: the stopped instance +keeps its job list and its download history, and the next start finds both +again. + +## Exit codes + +Every command of the service family ends with one of three exit codes: + +| Code | Meaning | +|---|---| +| `0` | The operation succeeded. | +| `1` | The operation failed: the service is not installed, the elevation is missing, the supervisor refused the operation, or the installation or the removal failed. | +| `2` | The command line could not be understood: an unknown or a missing subcommand, an unknown flag, mutually exclusive scope selectors, a relative path, an unusable `--executable` or an unparsable `--listen`. | + +`status` uses the same codes with a narrower meaning: it exits with `0` when the +state of the service is `running`, `stopped`, `starting` or `stopping`, and with +`1` when the state is `not-installed`, `broken`, `failed` or `unknown`, so a +script can branch on it. + +The internal `run` mode is the exception in practice: it is the foreground host +of the instance, so it only ends when the supervisor stops the instance. It ends +with the same three codes. + +## Diagnosing a broken registration + +`status` reports the state of the registration: + +| State | Meaning | +|---|---| +| `not-installed` | No registration exists for the scope. | +| `stopped` | Registered, but not running. | +| `starting` / `stopping` | The supervisor is transitioning. | +| `running` | The service is running. | +| `failed` | The service failed. | +| `broken` | Registered, but its executable is gone. | +| `unknown` | The supervisor did not report a usable state. | + +A registration points at an **absolute executable path**. If the binary is +moved or deleted, the registration stays in place but cannot run: `status` +reports the registration as `broken` and names the missing executable, rather +than reporting a running service. The fix is to restore the binary at the +registered path, or to reinstall the service from the new path: + +```bash +hfdesk service status # shows the broken registration +hfdesk service install # re-registers the current binary +``` + +## Per-platform notes + +### Linux (systemd) + +- The user scope is a **systemd user unit** (`~/.config/systemd/user/hfdesk.service`, + or `$XDG_CONFIG_HOME/systemd/user/hfdesk.service`) and is driven with + `systemctl --user`. The system scope is a machine-wide unit in + `/etc/systemd/system`. +- **HFDesk never enables linger.** A user unit normally stops when the last + session of the user ends. Booting without a login is an explicit decision of + the user or the administrator (`loginctl enable-linger `), and HFDesk + does not make it for you. +- The unit keeps the process in the foreground (`Type=simple`) and restarts it + on failure. It holds no access data. + +### macOS (launchd) + +- The user scope is a per-user **LaunchAgent** at + `~/Library/LaunchAgents/com.hfdesk.hfdesk.plist`; the system scope is a + **LaunchDaemon** at `/Library/LaunchDaemons/com.hfdesk.hfdesk.plist`. +- The registration is managed with `launchctl bootstrap`, `bootout` and + `kickstart`. A job that is already loaded is re-bootstrapped so a changed + property list takes effect. +- The property list holds the label, the program arguments and the environment + of the instance — no credential. +- The **macOS CI job** is narrowed to the service package: the launchd + manager and the property list rendering are darwin-only code, so the job + runs `go test ./internal/service` (plus the build and the vet of the whole + module) on a macOS runner. The repository has never run its full suite on + macOS, so the job covers the darwin-only code only; widening it to the full + suite is a separate, deliberate step. + +#### Future app-bundle packaging + +HFDesk ships a standalone CLI binary today. The LaunchAgent and the +LaunchDaemon are therefore registered by writing a property list into +`~/Library/LaunchAgents` or `/Library/LaunchDaemons`. That is the +**transitional path**. + +On macOS 13 and later, Apple's +[`SMAppService`](https://developer.apple.com/documentation/servicemanagement/smappservice) +is the preferred API for registering a LaunchAgent or a LaunchDaemon that +**lives inside an application bundle**: the registration becomes part of the +bundle instead of a loose property list in a Library directory. + +If HFDesk later gains a proper `.app`, the registration path must **migrate** +to `SMAppService`. The legacy property-list installation must not stay alive +inside the app-bundle flow, so the two mechanisms never register the same +service at the same time. + +The migration is deliberately **not** implemented now: it would require a +Swift helper and an app bundle, and this work explicitly does not force +either. + +**Consequence for the user:** a registration made by the current CLI keeps +working. A future app bundle takes the registration over through +`SMAppService`, and the app bundle and the CLI must not both register the +same service at the same time. + +### Windows (Service Control Manager) + +- The Service Control Manager is used **directly through the Go API**, never + `sc.exe` and never PowerShell. +- Installing, removing, starting, stopping and restarting the service needs an + **elevated shell**. HFDesk does not self-elevate and never invokes UAC. +- The service identity is `LocalService`, a least-privilege Windows service + account, not `LocalSystem`. It is **not configurable**: the built-in account + has no name of its own and needs no credential, so a Windows instance + cannot run as another account. +- The service-owned state under `%ProgramData%\HFDesk` is ACL-restricted. The + service does **not** recursively re-ACL or re-own an existing model or cache + tree: changing the ownership of a large, possibly shared tree is an explicit + decision of an administrator, not a side effect of an install. + +### The identity of a machine-wide instance + +A machine-wide (`--system`) instance runs as a **stable and unprivileged +service account** of its platform: a dedicated one on Linux and macOS, the +built-in `LocalService` account on Windows. It never runs as root, it never +inherits the home directory of the user who runs the install command, and it +never inherits the personal profile of root. + +| Platform | The instance runs as | The account | +|---|---|---| +| Linux (systemd, `--system`) | the account **`hfdesk`** | a dedicated system account; it must exist before the installation | +| macOS (launchd, `--system`) | the account **`_hfdesk`** | a dedicated system account (the underscore prefix is the macOS convention for a service account); it must exist before the installation | +| Windows (Service Control Manager) | **LocalService** (`NT AUTHORITY\LocalService`) | a documented minimum-privilege Windows service account. It is deliberately **not** `LocalSystem`: the instance needs no machine-wide privilege, and `LocalSystem` would give it more access than it uses. The identity **cannot be chosen**: the built-in account has no name of its own and needs no credential, so it is the identity every Windows instance runs as. | + +The account is **stable** on purpose: the files it owns have to stay writable +across reboots, so an ephemeral identity would break the instance at the next +restart. It is also **unprivileged**: the instance serves a dashboard and +downloads models, it does not administer the machine. + +On Linux and macOS the name is the default of the platform and can be changed +with `--service-user ACCOUNT`. The option applies to the machine-wide scope +only: a user-scope instance runs as the user itself and never names an +account. + +**Windows has no account to choose.** The instance runs as the built-in, +passwordless `LocalService` account, and `--service-user` is rejected there: a +service under a user account would need a credential, and a credential must +never reach the command line of an installation. + +**The account must already exist.** That is a statement about Linux and +macOS: the Windows identity is an account of the operating system and is +therefore always there. HFDesk is a service manager, not a system +provisioner: it never creates an account, never calls `useradd` or `dscl`, +never runs `sudo` and never elevates itself. An installation whose account +does not exist is **refused**, and the error names the account and the exact +commands that create it: + +```bash +# Linux +sudo useradd --system --no-create-home --home-dir /var/lib/hfdesk --shell /usr/sbin/nologin hfdesk + +# macOS +sudo dscl . -create /Users/_hfdesk UserShell /usr/bin/false +sudo dscl . -create /Users/_hfdesk UserHome /var/lib/hfdesk +sudo dscl . -create /Users/_hfdesk UniqueID +``` + +Creating the account needs **administrative rights**: the commands above are +run by an administrator, before the installation. HFDesk never creates an +account itself and never asks for a password. + +**Only the three directories of the instance are granted to the account:** the +config directory, the state directory and the log directory — the directories +the installation creates anyway, together with the parent directories it +creates for them. A directory the installation **created** is granted to the +account; a directory that **existed before** the installation belongs to its +owner and is left alone, so the installation never takes a directory over from +the user who put it there. The account still has to be able to write into such +a directory: when it cannot, the installation is **refused** with the guidance +that names the directory, the account and the command that grants the access, +and the ownership of every directory stays as it was. + +A model or cache tree is **never** chowned, never chmodded and never walked +recursively. A cache directory the user names explicitly is **validated** for +accessibility by the service account instead: when the account cannot reach +it, the installation fails with ownership/permission guidance, and the tree +stays exactly as it is. + +**Consequences:** + +- The cache of a machine-wide instance has to be **readable and writable by + the service account**. Point the instance at a cache directory that the + account can reach, or grant it access to that directory explicitly, or run + a user-scope instance, which keeps the identity of the user and the access + to their own Hugging Face cache. +- The Windows instance has a profile and a filesystem access of its own: it + does not reuse `%APPDATA%` and it does not point at the model or cache + directories of the interactive user. +- The **user-scope** instance is the one that keeps the identity of the user + and the access to their own Hugging Face cache. It needs no service + account. + +### Privileged operations + +Scope changes are operating-system operations performed by an administrator. +HFDesk never invokes `sudo`, `su`, `pkexec` or a UAC prompt. A `--system` +installation that lacks the rights fails with a permission error instead of +escalating on its own. + +The **machine-wide scope** needs an **elevated shell** (root on Linux) for +`install`, `uninstall`, `start`, `stop` and `restart`: the registration lives +in a machine-wide location and the supervisor starts and stops the instance as +the system, not as the user who runs the command. A shell that is not elevated +fails with a permission error that names the requirement instead of escalating +on its own. The user scope needs no elevation: the instance belongs to the user +who runs the command. + +## Upgrades + +The registration points at an absolute executable path, so an upgrade is a +**binary replacement in place**: + +1. Stop the service. +2. Replace the binary at the registered path. +3. Start the service again. + +The registration does not need to be rewritten, because the path did not +change. If the binary *moves*, `status` reports the registration as `broken` +(see [Diagnosing a broken registration](#diagnosing-a-broken-registration)) +and you reinstall the service from the new path. + +The registered path is the **invoked** path, so a stable name keeps its name +in the registration: a symbolic link `/usr/local/bin/hfdesk` that points at +`/opt/hfdesk-1.2.3/hfdesk` is registered as `/usr/local/bin/hfdesk`, and the +next release replaces the file behind it. `install` warns when the registered +path is version-stamped (`/opt/hfdesk-1.2.3/hfdesk`), when the name of its +directory is version-stamped (`/opt/hfdesk-1.2.3/bin/hfdesk`) or when it +lives in a temporary directory, because such a path does not survive the next +upgrade. + +When the binary that is run is not the one that should be registered, name it +explicitly: + +```bash +hfdesk service install --executable /usr/local/bin/hfdesk +``` + +## See also + +- [README](../README.md) — general project behavior +- [API](API.md) — HTTP endpoints, including the settings API used to set the + token of a service instance diff --git a/internal/runtimepaths/runtimepaths.go b/internal/runtimepaths/runtimepaths.go new file mode 100644 index 00000000..e9bda263 --- /dev/null +++ b/internal/runtimepaths/runtimepaths.go @@ -0,0 +1,204 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +// Package runtimepaths resolves the runtime file locations used by HFDesk. +// +// Interactive runs keep their historical behaviour: the config file, the jobs +// state, the download history and the mirror targets are derived from the +// current working directory and the per-user config directory, and are +// resolved again on every call so a changed environment is picked up. +// +// A supervised run (systemd, launchd, an init script, ...) can install one +// explicit model so every path comes from a deterministic root instead of +// from the working directory or the user profile of the service account. +package runtimepaths + +import ( + "os" + "path/filepath" + "strings" + "sync" +) + +// Paths is the startup-resolved runtime path model. +type Paths struct { + // ConfigFile is the config file read at startup and written by settings. + ConfigFile string + // StateDir holds the mutable runtime state: jobs, history and mirror + // targets. + StateDir string + // LogDir holds the process log files. + LogDir string +} + +// configFileNames are the accepted config file names, in search order. +var configFileNames = []string{"hfdesk.json", "hfdesk.yaml", "hfdesk.yml"} + +// The names of the state files inside the state directory. +const ( + // JobsStateFileName is the name of the persisted job list. + JobsStateFileName = "jobs_state.json" + // HistoryFileName is the name of the download history. + HistoryFileName = "download_history.json" + // TargetsFileName is the name of the mirror targets config. + TargetsFileName = "targets.yaml" +) + +// JobsStateFile returns the path of the persisted job list. +func (p Paths) JobsStateFile() string { + return filepath.Join(p.StateDir, JobsStateFileName) +} + +// HistoryFile returns the path of the download history. +func (p Paths) HistoryFile() string { + return filepath.Join(p.StateDir, HistoryFileName) +} + +// TargetsFile returns the path of the mirror targets config. +func (p Paths) TargetsFile() string { + return filepath.Join(p.StateDir, TargetsFileName) +} + +var ( + installedMu sync.RWMutex + installed *Paths +) + +// Install installs an explicit path model process-wide. Afterwards Current +// returns exactly p and ignores the working directory, HOME and the user +// profile. +func Install(p Paths) { + installedMu.Lock() + defer installedMu.Unlock() + installed = &p +} + +// Reset restores the interactive default resolution. +func Reset() { + installedMu.Lock() + defer installedMu.Unlock() + installed = nil +} + +// IsInstalled reports whether an explicit runtime path model is installed +// for this process. A supervised instance installs one; an interactive run +// resolves its paths per call and installs nothing. +func IsInstalled() bool { + installedMu.RLock() + defer installedMu.RUnlock() + return installed != nil +} + +// Current returns the effective path model. Without an installed model the +// interactive defaults are resolved again on every call, so a changed HOME, +// XDG_CONFIG_HOME, APPDATA or working directory takes effect immediately. +func Current() Paths { + installedMu.RLock() + p := installed + installedMu.RUnlock() + if p != nil { + return *p + } + return interactivePaths() +} + +// StateDir returns the state directory of the effective path model. +// +// Unlike Current it does not resolve the config file, so it stays cheap +// enough for the hot paths that write the job list, the download history and +// the mirror targets on every save. The result is always the StateDir of +// Current: the installed state directory of a supervised instance and the +// per-user config directory of an interactive run. +func StateDir() string { + installedMu.RLock() + p := installed + installedMu.RUnlock() + if p != nil { + return p.StateDir + } + return AppConfigDir() +} + +// interactivePaths resolves the interactive path model from the current +// process environment. +func interactivePaths() Paths { + stateDir := AppConfigDir() + return Paths{ + ConfigFile: interactiveConfigFile(stateDir), + StateDir: stateDir, + LogDir: filepath.Join(stateDir, "logs"), + } +} + +// interactiveConfigFile resolves the config file the way an interactive run +// always has: an existing file next to the current or the executable +// directory first, then the per-user config directory, defaulting to a JSON +// file in the per-user config directory. +func interactiveConfigFile(stateDir string) string { + dirs := []string{RunDir()} + if exe, err := os.Executable(); err == nil { + dirs = append(dirs, filepath.Dir(exe)) + } + + for _, dir := range dirs { + if isTempDir(dir) { + continue + } + if p, ok := firstExistingConfig(dir); ok { + return p + } + } + + if p, ok := firstExistingConfig(stateDir); ok { + return p + } + + return filepath.Join(stateDir, "hfdesk.json") +} + +// firstExistingConfig returns the first existing config file in dir. +func firstExistingConfig(dir string) (string, bool) { + for _, name := range configFileNames { + p := filepath.Join(dir, name) + if _, err := os.Stat(p); err == nil { + return p, true + } + } + return "", false +} + +// RunDir returns the directory where state files (config, jobs, history) +// should be stored. Priority: current working directory → executable dir → ".". +func RunDir() string { + if cwd, err := os.Getwd(); err == nil { + return cwd + } + if exe, err := os.Executable(); err == nil { + return filepath.Dir(exe) + } + return "." +} + +// AppConfigDir returns the normal per-user HFDesk configuration directory. +func AppConfigDir() string { + if dir, err := os.UserConfigDir(); err == nil && dir != "" { + return filepath.Join(dir, "HFDesk") + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + return filepath.Join(home, ".config", "hfdesk") + } + return RunDir() +} + +// isTempDir reports whether path looks like a temporary extraction directory. +// Such directories are skipped when searching for a config file so an +// unpacked archive never becomes the source of truth. +func isTempDir(path string) bool { + path = filepath.Clean(path) + temp := filepath.Clean(os.TempDir()) + lower := strings.ToLower(path) + if strings.HasPrefix(strings.ToLower(path), strings.ToLower(temp)) { + return true + } + return strings.Contains(lower, "rar$") || strings.Contains(lower, ".rartemp") +} diff --git a/internal/runtimepaths/runtimepaths_test.go b/internal/runtimepaths/runtimepaths_test.go new file mode 100644 index 00000000..d093f83e --- /dev/null +++ b/internal/runtimepaths/runtimepaths_test.go @@ -0,0 +1,429 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package runtimepaths + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCurrentReturnsInstalledModelVerbatim(t *testing.T) { + model := Paths{ + ConfigFile: filepath.Join("/etc", "hfdesk", "hfdesk.yaml"), + StateDir: filepath.Join("/var", "lib", "hfdesk"), + LogDir: filepath.Join("/var", "log", "hfdesk"), + } + Install(model) + t.Cleanup(Reset) + + if got := Current(); got != model { + t.Fatalf("Current() = %#v, want the installed model %#v", got, model) + } +} + +func TestInstalledModelIgnoresEnvironmentAndWorkingDirectory(t *testing.T) { + model := Paths{ + ConfigFile: filepath.Join("/srv", "hfdesk", "hfdesk.json"), + StateDir: filepath.Join("/srv", "hfdesk", "state"), + LogDir: filepath.Join("/srv", "hfdesk", "logs"), + } + Install(model) + t.Cleanup(Reset) + + setSandboxEnv(t, t.TempDir()) + t.Chdir(t.TempDir()) + + if got := Current(); got != model { + t.Fatalf("Current() = %#v, want the installed model %#v", got, model) + } +} + +func TestInteractiveCurrentFollowsEnvironment(t *testing.T) { + Reset() + t.Cleanup(Reset) + + setSandboxEnv(t, t.TempDir()) + + got := Current() + if want := referencePaths(t); got != want { + t.Fatalf("interactive resolution mismatch:\n got: %#v\nwant: %#v", got, want) + } + + // The interactive model is resolved on every call, so a later environment + // change must be picked up. + setSandboxEnv(t, t.TempDir()) + + got = Current() + if got2 := referencePaths(t); got != got2 { + t.Fatalf("interactive resolution did not follow the environment change:\n got: %#v\nwant: %#v", got, got2) + } +} + +func TestStateDirReturnsTheInstalledStateDirectory(t *testing.T) { + model := Paths{ + ConfigFile: filepath.Join("/etc", "hfdesk", "hfdesk.json"), + StateDir: filepath.Join("/var", "lib", "hfdesk"), + LogDir: filepath.Join("/var", "log", "hfdesk"), + } + Install(model) + t.Cleanup(Reset) + + if got, want := StateDir(), model.StateDir; got != want { + t.Fatalf("StateDir() = %q, want the installed state directory %q", got, want) + } + + // The installed state directory does not depend on the environment. + setSandboxEnv(t, t.TempDir()) + t.Chdir(t.TempDir()) + + if got, want := StateDir(), model.StateDir; got != want { + t.Fatalf("StateDir() = %q, want the installed state directory %q", got, want) + } +} + +func TestStateDirFollowsTheInteractiveEnvironment(t *testing.T) { + Reset() + t.Cleanup(Reset) + + root := t.TempDir() + setSandboxEnv(t, root) + + got := StateDir() + if want := referenceAppConfigDir(); got != want { + t.Fatalf("StateDir() = %q, want %q", got, want) + } + if !strings.Contains(got, root) { + t.Fatalf("StateDir() = %q, want a directory inside the sandbox %q", got, root) + } + + // The state directory is resolved on every call, so a later environment + // change must be picked up. + next := t.TempDir() + setSandboxEnv(t, next) + + got = StateDir() + if want := referenceAppConfigDir(); got != want { + t.Fatalf("StateDir() after the environment change = %q, want %q", got, want) + } + if !strings.Contains(got, next) { + t.Fatalf("StateDir() = %q, want a directory inside the sandbox %q", got, next) + } +} + +func TestStateDirDoesNotResolveTheConfigFile(t *testing.T) { + Reset() + t.Cleanup(Reset) + + // The home directory has to be captured before the sandbox environment + // replaces it: the run directory must not look like a temporary one. + home := currentHomeDir(t) + + setSandboxEnv(t, t.TempDir()) + + stateDir := StateDir() + + // A config file next to the run directory is the config file of the + // model, but it does not change the state directory. + runDir := nonTempRunDir(t, home) + if err := os.WriteFile(filepath.Join(runDir, "hfdesk.json"), []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(runDir) + + if got, want := Current().ConfigFile, filepath.Join(runDir, "hfdesk.json"); got != want { + t.Fatalf("config file = %q, want %q", got, want) + } + if got := StateDir(); got != stateDir { + t.Fatalf("StateDir() = %q, want the unchanged state directory %q", got, stateDir) + } + if got, want := StateDir(), referenceAppConfigDir(); got != want { + t.Fatalf("StateDir() = %q, want %q", got, want) + } +} + +func TestStateDirAgreesWithCurrent(t *testing.T) { + Reset() + t.Cleanup(Reset) + + model := Paths{ + ConfigFile: filepath.Join("/etc", "hfdesk", "hfdesk.json"), + StateDir: filepath.Join("/var", "lib", "hfdesk"), + LogDir: filepath.Join("/var", "log", "hfdesk"), + } + + check := func(stage string) { + t.Helper() + if got, want := StateDir(), Current().StateDir; got != want { + t.Fatalf("%s: StateDir() = %q, want Current().StateDir = %q", stage, got, want) + } + } + + setSandboxEnv(t, t.TempDir()) + check("interactive") + + Install(model) + check("installed") + + // An installed model without a state directory keeps its empty state + // directory: the accessor does not invent one. + Install(Paths{}) + check("installed without a state directory") + + Reset() + check("after reset") + + if got, want := StateDir(), referenceAppConfigDir(); got != want { + t.Fatalf("StateDir() after Reset = %q, want %q", got, want) + } +} + +func TestStateDirAgreesWithCurrentWithoutAnEnvironment(t *testing.T) { + Reset() + t.Cleanup(Reset) + + // Without a home and a config directory the interactive model falls + // back to the run directory. The accessor has to agree there too. + for _, key := range []string{"HOME", "USERPROFILE", "XDG_CONFIG_HOME", "APPDATA", "LOCALAPPDATA"} { + t.Setenv(key, "") + } + + if got, want := StateDir(), Current().StateDir; got != want { + t.Fatalf("StateDir() = %q, want Current().StateDir = %q", got, want) + } + if got := StateDir(); got == "" { + t.Fatalf("StateDir() = %q, want a usable path", got) + } +} + +func TestDerivedFileNamesLiveInStateDir(t *testing.T) { + stateDir := filepath.Join("var", "lib", "hfdesk") + p := Paths{StateDir: stateDir} + + if got, want := p.JobsStateFile(), filepath.Join(stateDir, "jobs_state.json"); got != want { + t.Errorf("JobsStateFile() = %q, want %q", got, want) + } + if got, want := p.HistoryFile(), filepath.Join(stateDir, "download_history.json"); got != want { + t.Errorf("HistoryFile() = %q, want %q", got, want) + } + if got, want := p.TargetsFile(), filepath.Join(stateDir, "targets.yaml"); got != want { + t.Errorf("TargetsFile() = %q, want %q", got, want) + } +} + +func TestIsInstalledTracksTheInstalledModel(t *testing.T) { + Reset() + t.Cleanup(Reset) + + if IsInstalled() { + t.Fatalf("IsInstalled() = true before a model was installed") + } + + Install(Paths{ + ConfigFile: filepath.Join("/etc", "hfdesk", "hfdesk.json"), + StateDir: filepath.Join("/var", "lib", "hfdesk"), + LogDir: filepath.Join("/var", "log", "hfdesk"), + }) + if !IsInstalled() { + t.Fatalf("IsInstalled() = false after Install") + } + + Reset() + if IsInstalled() { + t.Fatalf("IsInstalled() = true after Reset") + } +} + +func TestResetRestoresInteractiveResolution(t *testing.T) { + model := Paths{ + ConfigFile: filepath.Join("/etc", "hfdesk", "hfdesk.json"), + StateDir: filepath.Join("/var", "lib", "hfdesk"), + LogDir: filepath.Join("/var", "log", "hfdesk"), + } + Install(model) + if got := Current(); got != model { + t.Fatalf("Current() = %#v, want the installed model %#v", got, model) + } + + Reset() + + got := Current() + if got == model { + t.Fatalf("Reset did not restore the interactive resolution: %#v", got) + } + if want := referencePaths(t); got != want { + t.Fatalf("interactive resolution mismatch after Reset:\n got: %#v\nwant: %#v", got, want) + } +} + +func TestInteractiveConfigFileSearchOrder(t *testing.T) { + Reset() + t.Cleanup(Reset) + + // The home directory has to be captured before the sandbox environment + // replaces it: the run directory must not look like a temporary one. + home := currentHomeDir(t) + + sandbox := t.TempDir() + setSandboxEnv(t, sandbox) + + // The per-user config directory holds a YAML config. + cfgDir := AppConfigDir() + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatal(err) + } + userConfig := filepath.Join(cfgDir, "hfdesk.yaml") + if err := os.WriteFile(userConfig, []byte("cache-dir: /tmp\n"), 0o644); err != nil { + t.Fatal(err) + } + + runDir := nonTempRunDir(t, home) + runConfig := filepath.Join(runDir, "hfdesk.json") + if err := os.WriteFile(runConfig, []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(runDir) + + // A config next to the run directory wins over the per-user config. + if got, want := Current().ConfigFile, runConfig; got != want { + t.Fatalf("config file = %q, want the run directory config %q", got, want) + } + + // Without it the per-user config is used. + if err := os.Remove(runConfig); err != nil { + t.Fatal(err) + } + if got, want := Current().ConfigFile, userConfig; got != want { + t.Fatalf("config file = %q, want the per-user config %q", got, want) + } + + // Without any config file the default JSON file in the per-user config + // directory is used. + if err := os.Remove(userConfig); err != nil { + t.Fatal(err) + } + if got, want := Current().ConfigFile, filepath.Join(cfgDir, "hfdesk.json"); got != want { + t.Fatalf("config file = %q, want the default %q", got, want) + } +} + +// --- helpers --- + +// setSandboxEnv points every platform specific config location at root. +func setSandboxEnv(t *testing.T, root string) { + t.Helper() + t.Setenv("HOME", filepath.Join(root, "home")) + t.Setenv("USERPROFILE", filepath.Join(root, "home")) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "xdg")) + t.Setenv("APPDATA", filepath.Join(root, "appdata")) + t.Setenv("LOCALAPPDATA", filepath.Join(root, "localappdata")) +} + +// currentHomeDir returns the home directory of the current environment. +func currentHomeDir(t *testing.T) string { + t.Helper() + for _, key := range []string{"HOME", "USERPROFILE"} { + if v := os.Getenv(key); v != "" { + return v + } + } + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("no home directory available: %v", err) + } + return home +} + +// nonTempRunDir creates a run directory that the interactive search does not +// skip as a temporary extraction directory. +func nonTempRunDir(t *testing.T, home string) string { + t.Helper() + dir, err := os.MkdirTemp(home, "hfdesk-runtimepaths-run-") + if err != nil { + t.Skipf("cannot create a run directory under %s: %v", home, err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + if isTempDir(dir) { + t.Skipf("home directory %s is inside the temporary directory", home) + } + return dir +} + +// --- reference implementation --- +// +// The helpers below re-implement the documented interactive resolution without +// using the code under test, so the tests compare behaviour instead of +// comparing a function with itself. + +// referencePaths resolves the documented interactive path model. +func referencePaths(t *testing.T) Paths { + t.Helper() + stateDir := referenceAppConfigDir() + return Paths{ + ConfigFile: referenceConfigFile(t, stateDir), + StateDir: stateDir, + LogDir: filepath.Join(stateDir, "logs"), + } +} + +func referenceAppConfigDir() string { + if dir, err := os.UserConfigDir(); err == nil && dir != "" { + return filepath.Join(dir, "HFDesk") + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + return filepath.Join(home, ".config", "hfdesk") + } + return referenceRunDir() +} + +func referenceRunDir() string { + if cwd, err := os.Getwd(); err == nil { + return cwd + } + if exe, err := os.Executable(); err == nil { + return filepath.Dir(exe) + } + return "." +} + +func referenceConfigFile(t *testing.T, stateDir string) string { + t.Helper() + dirs := []string{referenceRunDir()} + if exe, err := os.Executable(); err == nil { + dirs = append(dirs, filepath.Dir(exe)) + } + for _, dir := range dirs { + if referenceIsTempDir(dir) { + continue + } + if p, ok := referenceFirstExistingConfig(dir); ok { + return p + } + } + if p, ok := referenceFirstExistingConfig(stateDir); ok { + return p + } + return filepath.Join(stateDir, "hfdesk.json") +} + +func referenceFirstExistingConfig(dir string) (string, bool) { + for _, name := range []string{"hfdesk.json", "hfdesk.yaml", "hfdesk.yml"} { + p := filepath.Join(dir, name) + if _, err := os.Stat(p); err == nil { + return p, true + } + } + return "", false +} + +func referenceIsTempDir(path string) bool { + path = filepath.Clean(path) + temp := filepath.Clean(os.TempDir()) + lower := strings.ToLower(path) + if strings.HasPrefix(strings.ToLower(path), strings.ToLower(temp)) { + return true + } + return strings.Contains(lower, "rar$") || strings.Contains(lower, ".rartemp") +} diff --git a/internal/server/api.go b/internal/server/api.go index c0a0a0ca..f6928c64 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -17,6 +17,7 @@ import ( "strings" "time" + "github.com/bashrusakh/hfdesk/internal/runtimepaths" "github.com/bashrusakh/hfdesk/pkg/hfdownloader" "github.com/bashrusakh/hfdesk/pkg/smartdl" ) @@ -412,7 +413,7 @@ func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) { LocalScanDirs: cfg.LocalScanDirs, DownloadRoutes: cfg.DownloadRoutes, ConfigFile: ConfigPath(), - TargetsFile: hfdownloader.DefaultTargetsPath(), + TargetsFile: runtimepaths.Current().TargetsFile(), } // Add proxy settings (without password for security) diff --git a/internal/server/cache_selection_test.go b/internal/server/cache_selection_test.go index 6b6e5f42..34854557 100644 --- a/internal/server/cache_selection_test.go +++ b/internal/server/cache_selection_test.go @@ -922,6 +922,9 @@ func TestSelectedDeleteConflictsWithHFWriterSnapshotPrescan(t *testing.T) { } hf, started, release := blockingRevisionServer(t, "model-Q4_K_M.gguf") defer hf.Close() + var releaseOnce sync.Once + releaseGate := func() { releaseOnce.Do(release) } + defer releaseGate() s := newTestServerWithConfig(t, Config{CacheDir: storage, LocalScanDirs: []string{rd.SnapshotsDir()}, Endpoint: hf.URL}) releaseDelete, ok := s.jobs.reserveSelectedGGUF([]string{selected}) if !ok { @@ -949,10 +952,15 @@ func TestSelectedDeleteConflictsWithHFWriterSnapshotPrescan(t *testing.T) { if _, err := os.Stat(selected); err != nil { t.Fatalf("selected snapshot was removed during active writer: %v", err) } - release() + // Cancel while the prescan request is still blocked on the gate, so the + // cleanup cancel cannot race a prescan that already completed and would + // make the cancel report that there was nothing to cancel. The cancelled + // request unwinds the blocked handler, so releasing the gate afterwards is + // a no-op. if !s.jobs.CancelJob(job.ID) { t.Fatal("could not cancel prescan test job") } + releaseGate() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := s.jobs.Close(ctx); err != nil { @@ -1013,10 +1021,15 @@ func TestCompletePlanUsesActualSnapshotDestinations(t *testing.T) { } else if _, err := os.Lstat(selected); !os.IsNotExist(err) { t.Fatalf("disjoint selected entry was not deleted: %v", err) } - releaseGate() + // Cancel while the planned transfer is still blocked on the gate. The + // job is provably in flight here, so the cleanup cancel cannot race a + // download that already finished and would make the cancel report that + // there was nothing to cancel. The cancelled request unwinds the + // blocked handler, so releasing the gate afterwards is a no-op. if !s.jobs.CancelJob(job.ID) { t.Fatal("could not cancel planned transfer") } + releaseGate() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := s.jobs.Close(ctx); err != nil { @@ -2401,6 +2414,9 @@ func TestSelectedDeleteBlocksPrescanWriterWhenAliasChangesExcludedPath(t *testin } hf, started, release := blockingRevisionServer(t, "live/model-Q4_K_M.gguf") defer hf.Close() + var releaseOnce sync.Once + releaseGate := func() { releaseOnce.Do(release) } + defer releaseGate() s := newTestServerWithConfig(t, Config{CacheDir: storage, Endpoint: hf.URL}) job, _, err := s.jobs.CreateJob(DownloadRequest{Repo: "upstream/source", LocalRepo: "o/m", Filters: []string{"Q4_K_M"}, Excludes: []string{"blocked"}}) if err != nil { @@ -2419,12 +2435,17 @@ func TestSelectedDeleteBlocksPrescanWriterWhenAliasChangesExcludedPath(t *testin if _, err := os.Stat(filepath.Join(blocked, "model-Q4_K_M.gguf")); err != nil { t.Fatalf("pending writer's selected file was deleted: %v", err) } - release() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() + // Cancel while the prescan request is still blocked on the gate, so the + // cleanup cancel cannot race a prescan that already completed and would + // make the cancel report that there was nothing to cancel. The cancelled + // request unwinds the blocked handler, so releasing the gate afterwards is + // a no-op. if !s.jobs.CancelJob(job.ID) { t.Fatal("could not cancel prescan test job") } + releaseGate() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() if err := s.jobs.Close(ctx); err != nil { t.Fatalf("close test job manager: %v", err) } diff --git a/internal/server/config.go b/internal/server/config.go index 60e7ddf9..2666eaec 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/bashrusakh/hfdesk/internal/runtimepaths" "github.com/bashrusakh/hfdesk/pkg/hfdownloader" "gopkg.in/yaml.v3" ) @@ -19,23 +20,25 @@ import ( // RunDir returns the directory where state files (config, jobs, history) // should be stored. Priority: current working directory → executable dir → ".". func RunDir() string { - if cwd, err := os.Getwd(); err == nil { - return cwd - } - if exe, err := os.Executable(); err == nil { - return filepath.Dir(exe) - } - return "." + return runtimepaths.RunDir() } -// JobsStatePath returns the path to jobs_state.json in the app config directory. +// JobsStatePath returns the path to jobs_state.json in the state directory +// of the effective runtime path model. func JobsStatePath() string { - return filepath.Join(AppConfigDir(), "jobs_state.json") + return filepath.Join(runtimepaths.StateDir(), runtimepaths.JobsStateFileName) } -// HistoryPath returns the path to download_history.json in the app config directory. +// HistoryPath returns the path to download_history.json in the state +// directory of the effective runtime path model. func HistoryPath() string { - return filepath.Join(AppConfigDir(), "download_history.json") + return filepath.Join(runtimepaths.StateDir(), runtimepaths.HistoryFileName) +} + +// TargetsFilePath returns the path to targets.yaml in the state directory of +// the effective runtime path model. +func TargetsFilePath() string { + return filepath.Join(runtimepaths.StateDir(), runtimepaths.TargetsFileName) } // ConfigFile represents the persistent configuration file format. @@ -83,67 +86,41 @@ func isRedactedToken(token string) bool { // AppConfigDir returns the normal per-user HFDesk configuration directory. func AppConfigDir() string { - if dir, err := os.UserConfigDir(); err == nil && dir != "" { - return filepath.Join(dir, "HFDesk") - } - if home, err := os.UserHomeDir(); err == nil && home != "" { - return filepath.Join(home, ".config", "hfdesk") - } - return RunDir() -} - -func isTempDir(path string) bool { - path = filepath.Clean(path) - temp := filepath.Clean(os.TempDir()) - lower := strings.ToLower(path) - if strings.HasPrefix(strings.ToLower(path), strings.ToLower(temp)) { - return true - } - return strings.Contains(lower, "rar$") || strings.Contains(lower, ".rartemp") + return runtimepaths.AppConfigDir() } // ConfigPath returns the path to the config file. // Search order: existing config next to the current/executable folder, then the // normal per-user config directory. New saves go to the per-user directory so // running an unpacked archive does not create config in a temp extraction path. +// A supervised instance resolves the path from its installed runtime model. func ConfigPath() string { - dirs := []string{RunDir()} - if exe, err := os.Executable(); err == nil { - dirs = append(dirs, filepath.Dir(exe)) - } - - for _, dir := range dirs { - if isTempDir(dir) { - continue - } - for _, name := range []string{"hfdesk.json", "hfdesk.yaml", "hfdesk.yml"} { - p := filepath.Join(dir, name) - if _, err := os.Stat(p); err == nil { - return p - } - } - } - - configDir := AppConfigDir() - for _, name := range []string{"hfdesk.json", "hfdesk.yaml", "hfdesk.yml"} { - p := filepath.Join(configDir, name) - if _, err := os.Stat(p); err == nil { - return p - } - } - - return filepath.Join(configDir, "hfdesk.json") + return runtimepaths.Current().ConfigFile } // LoadConfigFile loads configuration from the config file. // Returns empty config if file doesn't exist (not an error). func LoadConfigFile() (*ConfigFile, error) { - configMu.Lock() - defer configMu.Unlock() return loadConfigFile(ConfigPath()) } +// loadConfigFile loads the configuration from the given path under the +// config lock. The callers that already hold the lock read the file with +// LoadConfigFileAt directly. func loadConfigFile(path string) (*ConfigFile, error) { + configMu.Lock() + defer configMu.Unlock() + return LoadConfigFileAt(path) +} + +// LoadConfigFileAt loads the configuration from the given path. It returns +// an empty configuration when the file does not exist. +// +// The load does not take the config lock: the file is written atomically (a +// temporary file and a rename), so a reader sees either the previous or the +// new file. A caller outside the package therefore reads the config file of +// an instance without taking the lock of the writer. +func LoadConfigFileAt(path string) (*ConfigFile, error) { if path == "" { return &ConfigFile{}, nil } @@ -193,7 +170,7 @@ func saveSettingsConfig(cfg *ConfigFile, tokenWrite *string) error { configMu.Lock() defer configMu.Unlock() path := ConfigPath() - stored, err := loadConfigFile(path) + stored, err := LoadConfigFileAt(path) if err != nil { return err } @@ -213,9 +190,12 @@ func saveConfigFile(path string, cfg *ConfigFile) error { return errors.New("cannot save a redacted token as a credential") } - // Ensure config directory exists + // Ensure the config directory exists. It is created with the mode the + // server owns: an owner-only directory for a supervised instance, which + // holds the credentials of the instance, and the historical mode for an + // interactive run. configDir := filepath.Dir(path) - if err := os.MkdirAll(configDir, 0o755); err != nil { + if err := os.MkdirAll(configDir, serverDirMode()); err != nil { return err } diff --git a/internal/server/dirs.go b/internal/server/dirs.go new file mode 100644 index 00000000..fbf96619 --- /dev/null +++ b/internal/server/dirs.go @@ -0,0 +1,40 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "os" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +// The permission modes of the directories the server owns. +const ( + // interactiveDirMode is the historical mode of the per-user directories + // of an interactive run. + interactiveDirMode os.FileMode = 0o755 + // ownedDirMode is the owner-only mode of the directories a supervised + // instance owns. + ownedDirMode os.FileMode = 0o700 +) + +// serverDirMode returns the permission mode for a directory the server owns. +// +// A supervised instance owns its directories: they hold the job list, the +// download history, the mirror targets and the credentials of the instance, +// so nobody else has to be able to read them. +// +// An interactive run keeps the historical mode of its per-user directories. +// The container image deliberately keeps its data root world-writable so an +// arbitrary UID can create its own state there, and an owner-only directory +// created by one UID would lock a second UID out of the shared tree. +// +// The mode must not depend on which code path created the directory, so +// every directory the server creates uses this helper. +func serverDirMode() os.FileMode { + if runtimepaths.IsInstalled() { + return ownedDirMode + } + return interactiveDirMode +} diff --git a/internal/server/mirror.go b/internal/server/mirror.go index e650edf8..2a0fa0bf 100644 --- a/internal/server/mirror.go +++ b/internal/server/mirror.go @@ -26,7 +26,7 @@ type MirrorTargetRequest struct { // handleMirrorTargetsList returns all configured mirror targets. func (s *Server) handleMirrorTargetsList(w http.ResponseWriter, r *http.Request) { - cfg, err := hfdownloader.LoadTargets("") + cfg, err := hfdownloader.LoadTargets(TargetsFilePath()) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to load targets", err.Error()) return @@ -93,7 +93,7 @@ func (s *Server) handleMirrorTargetAdd(w http.ResponseWriter, r *http.Request) { return } - cfg, err := hfdownloader.LoadTargets("") + cfg, err := hfdownloader.LoadTargets(TargetsFilePath()) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to load targets", err.Error()) return @@ -101,7 +101,7 @@ func (s *Server) handleMirrorTargetAdd(w http.ResponseWriter, r *http.Request) { cfg.Add(req.Name, absPath, req.Description) - if err := cfg.Save(""); err != nil { + if err := saveMirrorTargets(cfg); err != nil { writeError(w, http.StatusInternalServerError, "Failed to save targets", err.Error()) return } @@ -125,7 +125,7 @@ func (s *Server) handleMirrorTargetRemove(w http.ResponseWriter, r *http.Request return } - cfg, err := hfdownloader.LoadTargets("") + cfg, err := hfdownloader.LoadTargets(TargetsFilePath()) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to load targets", err.Error()) return @@ -136,7 +136,7 @@ func (s *Server) handleMirrorTargetRemove(w http.ResponseWriter, r *http.Request return } - if err := cfg.Save(""); err != nil { + if err := saveMirrorTargets(cfg); err != nil { writeError(w, http.StatusInternalServerError, "Failed to save targets", err.Error()) return } @@ -179,7 +179,7 @@ func (s *Server) handleMirrorDiff(w http.ResponseWriter, r *http.Request) { } // Resolve target path - targetsCfg, err := hfdownloader.LoadTargets("") + targetsCfg, err := hfdownloader.LoadTargets(TargetsFilePath()) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to load targets", err.Error()) return @@ -328,7 +328,7 @@ func (s *Server) handleMirrorPush(w http.ResponseWriter, r *http.Request) { } // Resolve target path - targetsCfg, err := hfdownloader.LoadTargets("") + targetsCfg, err := hfdownloader.LoadTargets(TargetsFilePath()) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to load targets", err.Error()) return @@ -371,7 +371,7 @@ func (s *Server) handleMirrorPull(w http.ResponseWriter, r *http.Request) { } // Resolve target path - targetsCfg, err := hfdownloader.LoadTargets("") + targetsCfg, err := hfdownloader.LoadTargets(TargetsFilePath()) if err != nil { writeError(w, http.StatusInternalServerError, "Failed to load targets", err.Error()) return @@ -403,6 +403,20 @@ func (s *Server) handleMirrorPull(w http.ResponseWriter, r *http.Request) { // --- Helper types and functions --- +// saveMirrorTargets writes the mirror targets into the state directory. +// +// The downloader package creates the parent directory of the targets file +// with its own mode, so the server creates the state directory with the mode +// it owns before the file is written: the mode of the directory must not +// depend on which code path created it. +func saveMirrorTargets(cfg *hfdownloader.TargetsConfig) error { + targetsFile := TargetsFilePath() + if err := os.MkdirAll(filepath.Dir(targetsFile), serverDirMode()); err != nil { + return err + } + return cfg.Save(targetsFile) +} + type mirrorEntry struct { Type string Repo string diff --git a/internal/server/runtimepaths_test.go b/internal/server/runtimepaths_test.go new file mode 100644 index 00000000..b2f9a697 --- /dev/null +++ b/internal/server/runtimepaths_test.go @@ -0,0 +1,414 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +func TestServerPathsFollowInstalledRuntimeModel(t *testing.T) { + state := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(state, "hfdesk.json"), + StateDir: state, + LogDir: filepath.Join(state, "logs"), + } + runtimepaths.Install(model) + t.Cleanup(runtimepaths.Reset) + + if got, want := ConfigPath(), model.ConfigFile; got != want { + t.Errorf("ConfigPath() = %q, want %q", got, want) + } + if got, want := JobsStatePath(), filepath.Join(state, "jobs_state.json"); got != want { + t.Errorf("JobsStatePath() = %q, want %q", got, want) + } + if got, want := HistoryPath(), filepath.Join(state, "download_history.json"); got != want { + t.Errorf("HistoryPath() = %q, want %q", got, want) + } + if got, want := TargetsFilePath(), filepath.Join(state, "targets.yaml"); got != want { + t.Errorf("TargetsFilePath() = %q, want %q", got, want) + } +} + +func TestServerPathsFollowTheEnvironmentWithoutAnInstalledModel(t *testing.T) { + runtimepaths.Reset() + t.Cleanup(runtimepaths.Reset) + + root := t.TempDir() + setServerSandboxEnv(t, root) + + // The per-user config directory of the sandboxed environment. + stateDir := referenceAppConfigDir() + if !strings.HasPrefix(stateDir, root) { + t.Fatalf("config directory %q is not inside the sandbox %q", stateDir, root) + } + + if got, want := JobsStatePath(), filepath.Join(stateDir, "jobs_state.json"); got != want { + t.Errorf("JobsStatePath() = %q, want %q", got, want) + } + if got, want := HistoryPath(), filepath.Join(stateDir, "download_history.json"); got != want { + t.Errorf("HistoryPath() = %q, want %q", got, want) + } + if got, want := TargetsFilePath(), filepath.Join(stateDir, "targets.yaml"); got != want { + t.Errorf("TargetsFilePath() = %q, want %q", got, want) + } +} + +func TestServerPathsInteractiveWithoutInstalledModel(t *testing.T) { + runtimepaths.Reset() + t.Cleanup(runtimepaths.Reset) + + if got, want := ConfigPath(), referenceConfigPath(t); got != want { + t.Errorf("ConfigPath() = %q, want %q", got, want) + } + if got, want := JobsStatePath(), filepath.Join(AppConfigDir(), "jobs_state.json"); got != want { + t.Errorf("JobsStatePath() = %q, want %q", got, want) + } + if got, want := HistoryPath(), filepath.Join(AppConfigDir(), "download_history.json"); got != want { + t.Errorf("HistoryPath() = %q, want %q", got, want) + } +} + +func TestMirrorTargetsUseInstalledRuntimeModel(t *testing.T) { + state := t.TempDir() + runtimepaths.Install(runtimepaths.Paths{ + ConfigFile: filepath.Join(state, "hfdesk.json"), + StateDir: state, + LogDir: filepath.Join(state, "logs"), + }) + t.Cleanup(runtimepaths.Reset) + + s := newTestServer(t) + req := httptest.NewRequest("POST", "/api/mirror/targets", strings.NewReader(`{"name":"backup","path":"/tmp/hfdesk-backup"}`)) + w := httptest.NewRecorder() + s.handleMirrorTargetAdd(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + + targetsFile := filepath.Join(state, "targets.yaml") + data, err := os.ReadFile(targetsFile) + if err != nil { + t.Fatalf("read targets file %s: %v", targetsFile, err) + } + if !strings.Contains(string(data), "backup") { + t.Errorf("targets file %s does not contain the new target:\n%s", targetsFile, data) + } +} + +func TestMirrorTargetsFollowTheEnvironmentWithoutAnInstalledModel(t *testing.T) { + runtimepaths.Reset() + t.Cleanup(runtimepaths.Reset) + setServerSandboxEnv(t, t.TempDir()) + + addMirrorTarget(t) + + targetsFile := filepath.Join(referenceAppConfigDir(), "targets.yaml") + data, err := os.ReadFile(targetsFile) + if err != nil { + t.Fatalf("read targets file %s: %v", targetsFile, err) + } + if !strings.Contains(string(data), "backup") { + t.Errorf("targets file %s does not contain the new target:\n%s", targetsFile, data) + } +} + +func TestSettingsReportInstalledRuntimeModel(t *testing.T) { + state := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(state, "hfdesk.json"), + StateDir: state, + LogDir: filepath.Join(state, "logs"), + } + runtimepaths.Install(model) + t.Cleanup(runtimepaths.Reset) + + s := newTestServer(t) + req := httptest.NewRequest("GET", "/api/settings", nil) + w := httptest.NewRecorder() + s.handleGetSettings(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + + var resp SettingsResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if want := model.ConfigFile; resp.ConfigFile != want { + t.Errorf("configFile = %q, want %q", resp.ConfigFile, want) + } + if want := filepath.Join(state, "targets.yaml"); resp.TargetsFile != want { + t.Errorf("targetsFile = %q, want %q", resp.TargetsFile, want) + } +} + +func TestServerDirectoryModeFollowsTheInstalledRuntimeModel(t *testing.T) { + runtimepaths.Reset() + t.Cleanup(runtimepaths.Reset) + + // Without an installed model the interactive run keeps the historical + // mode of its per-user directories. + if got, want := serverDirMode(), os.FileMode(0o755); got != want { + t.Errorf("serverDirMode() without an installed model = %o, want %o", got, want) + } + + runtimepaths.Install(runtimepaths.Paths{ + ConfigFile: filepath.Join(t.TempDir(), "hfdesk.json"), + StateDir: filepath.Join(t.TempDir(), "state"), + LogDir: filepath.Join(t.TempDir(), "logs"), + }) + + // A supervised instance owns its directories. + if got, want := serverDirMode(), os.FileMode(0o700); got != want { + t.Errorf("serverDirMode() with an installed model = %o, want %o", got, want) + } +} + +func TestServerDirectoriesKeepTheHistoricalModeWithoutAnInstalledModel(t *testing.T) { + runtimepaths.Reset() + t.Cleanup(runtimepaths.Reset) + setServerSandboxEnv(t, t.TempDir()) + + configDir := filepath.Join(t.TempDir(), "config") + if err := saveConfigFile(filepath.Join(configDir, "hfdesk.json"), &ConfigFile{}); err != nil { + t.Fatalf("save config file: %v", err) + } + assertDirMode(t, configDir, 0o755) + + stateDir := filepath.Join(t.TempDir(), "state") + if err := saveJobsState(filepath.Join(stateDir, "jobs_state.json"), nil); err != nil { + t.Fatalf("save jobs state: %v", err) + } + assertDirMode(t, stateDir, 0o755) + + // The history is written into the state directory of the effective + // runtime path model, which is the per-user directory of an interactive + // run. + if err := AppendHistory(&Job{ID: "1", Repo: "owner/model"}); err != nil { + t.Fatalf("append history: %v", err) + } + assertDirMode(t, AppConfigDir(), 0o755) +} + +func TestServerDirectoriesAreOwnerOnlyWithAnInstalledModel(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "hfdesk.json"), + StateDir: filepath.Join(root, "state"), + LogDir: filepath.Join(root, "logs"), + } + runtimepaths.Install(model) + t.Cleanup(runtimepaths.Reset) + + configDir := filepath.Join(t.TempDir(), "config") + if err := saveConfigFile(filepath.Join(configDir, "hfdesk.json"), &ConfigFile{}); err != nil { + t.Fatalf("save config file: %v", err) + } + assertDirMode(t, configDir, 0o700) + + stateDir := filepath.Join(t.TempDir(), "state") + if err := saveJobsState(filepath.Join(stateDir, "jobs_state.json"), nil); err != nil { + t.Fatalf("save jobs state: %v", err) + } + assertDirMode(t, stateDir, 0o700) + + if err := AppendHistory(&Job{ID: "1", Repo: "owner/model"}); err != nil { + t.Fatalf("append history: %v", err) + } + assertDirMode(t, model.StateDir, 0o700) +} + +func TestServerDirectoriesKeepTheModeOfAnExistingDirectory(t *testing.T) { + runtimepaths.Reset() + t.Cleanup(runtimepaths.Reset) + + // MkdirAll never changes the mode of a directory that already exists, + // so an existing directory keeps its mode. + configDir := filepath.Join(t.TempDir(), "config") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatal(err) + } + if err := saveConfigFile(filepath.Join(configDir, "hfdesk.json"), &ConfigFile{}); err != nil { + t.Fatalf("save config file: %v", err) + } + assertDirMode(t, configDir, 0o755) + + stateDir := filepath.Join(t.TempDir(), "state") + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + if err := saveJobsState(filepath.Join(stateDir, "jobs_state.json"), nil); err != nil { + t.Fatalf("save jobs state: %v", err) + } + assertDirMode(t, stateDir, 0o755) +} + +func TestMirrorTargetsSaveCreatesTheStateDirectoryWithTheServerMode(t *testing.T) { + t.Run("interactive", func(t *testing.T) { + runtimepaths.Reset() + t.Cleanup(runtimepaths.Reset) + setServerSandboxEnv(t, t.TempDir()) + + addMirrorTarget(t) + + // The state directory of an interactive run keeps the historical + // mode, so the targets save cannot introduce a different one. + assertDirMode(t, AppConfigDir(), 0o755) + if _, err := os.Stat(filepath.Join(AppConfigDir(), "targets.yaml")); err != nil { + t.Fatalf("stat targets file: %v", err) + } + }) + + t.Run("installed", func(t *testing.T) { + root := t.TempDir() + stateDir := filepath.Join(root, "state") + runtimepaths.Install(runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "hfdesk.json"), + StateDir: stateDir, + LogDir: filepath.Join(root, "logs"), + }) + t.Cleanup(runtimepaths.Reset) + + addMirrorTarget(t) + + assertDirMode(t, stateDir, 0o700) + if _, err := os.Stat(filepath.Join(stateDir, "targets.yaml")); err != nil { + t.Fatalf("stat targets file: %v", err) + } + }) +} + +// addMirrorTarget adds a mirror target through the API. +func addMirrorTarget(t *testing.T) { + t.Helper() + + s := newTestServer(t) + req := httptest.NewRequest("POST", "/api/mirror/targets", strings.NewReader(`{"name":"backup","path":"/tmp/hfdesk-backup"}`)) + w := httptest.NewRecorder() + s.handleMirrorTargetAdd(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } +} + +// --- helpers --- + +// setServerSandboxEnv points every platform specific config location at a +// temporary root, so an interactive run resolves its per-user directories +// inside the sandbox of the test. +func setServerSandboxEnv(t *testing.T, root string) { + t.Helper() + t.Setenv("HOME", filepath.Join(root, "home")) + t.Setenv("USERPROFILE", filepath.Join(root, "home")) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "xdg")) + t.Setenv("APPDATA", filepath.Join(root, "appdata")) + t.Setenv("LOCALAPPDATA", filepath.Join(root, "localappdata")) +} + +// assertDirMode checks the permission bits of a directory. The umask of the +// process masks the mode bits of a created directory, so the expected mode is +// masked the same way. +func assertDirMode(t *testing.T, dir string, want os.FileMode) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("Windows does not support POSIX permission bits") + } + + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat %s: %v", dir, err) + } + if got, expected := info.Mode().Perm(), want&^processUmask(t); got != expected { + t.Errorf("%s mode = %o, want %o", dir, got, expected) + } +} + +// processUmask returns the permission bits the umask of the process removes +// from a created file or directory. +func processUmask(t *testing.T) os.FileMode { + t.Helper() + + probe := filepath.Join(t.TempDir(), "umask-probe") + if err := os.Mkdir(probe, 0o777); err != nil { + t.Fatalf("create umask probe: %v", err) + } + info, err := os.Stat(probe) + if err != nil { + t.Fatalf("stat umask probe: %v", err) + } + return os.FileMode(0o777) &^ info.Mode().Perm() +} + +// --- reference implementation --- +// The helpers below re-implement the documented interactive resolution without +// using the runtime path model, so the tests compare behaviour instead of +// comparing a function with itself. + +func referenceConfigPath(t *testing.T) string { + t.Helper() + dirs := []string{referenceRunDir()} + if exe, err := os.Executable(); err == nil { + dirs = append(dirs, filepath.Dir(exe)) + } + for _, dir := range dirs { + if referenceIsTempDir(dir) { + continue + } + for _, name := range []string{"hfdesk.json", "hfdesk.yaml", "hfdesk.yml"} { + p := filepath.Join(dir, name) + if _, err := os.Stat(p); err == nil { + return p + } + } + } + + configDir := referenceAppConfigDir() + for _, name := range []string{"hfdesk.json", "hfdesk.yaml", "hfdesk.yml"} { + p := filepath.Join(configDir, name) + if _, err := os.Stat(p); err == nil { + return p + } + } + return filepath.Join(configDir, "hfdesk.json") +} + +func referenceAppConfigDir() string { + if dir, err := os.UserConfigDir(); err == nil && dir != "" { + return filepath.Join(dir, "HFDesk") + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + return filepath.Join(home, ".config", "hfdesk") + } + return referenceRunDir() +} + +func referenceRunDir() string { + if cwd, err := os.Getwd(); err == nil { + return cwd + } + if exe, err := os.Executable(); err == nil { + return filepath.Dir(exe) + } + return "." +} + +func referenceIsTempDir(path string) bool { + path = filepath.Clean(path) + temp := filepath.Clean(os.TempDir()) + lower := strings.ToLower(path) + if strings.HasPrefix(strings.ToLower(path), strings.ToLower(temp)) { + return true + } + return strings.Contains(lower, "rar$") || strings.Contains(lower, ".rartemp") +} diff --git a/internal/server/server.go b/internal/server/server.go index 37eaa982..ec2a55b4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -26,9 +26,18 @@ import ( // the application installed. var serverLog = logging.NewComponentLogger("server") -// serverShutdownTimeout bounds the graceful shutdown sequence: the HTTP -// drain and the job manager close each get this budget. -const serverShutdownTimeout = 10 * time.Second +// ShutdownPhaseTimeout bounds one phase of the graceful shutdown sequence: +// the HTTP drain and the job manager close each get this budget, so a phase +// never eats the budget of the next one. +// +// The budget is published so a supervisor that waits for the instance knows +// how long the process may need to finish its shutdown. A supervisor that +// waits less kills the instance while it is still saving its state. +const ShutdownPhaseTimeout = 10 * time.Second + +// GracefulShutdownTimeout is the total budget of the orderly shutdown: the +// sum of the budgets of its phases. +const GracefulShutdownTimeout = 2 * ShutdownPhaseTimeout // Config holds server configuration. type Config struct { @@ -236,7 +245,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error { listener, err := net.Listen("tcp", addr) if err != nil { - return s.closeJobsAfterListenError(fmt.Errorf("listen on %s: %w", addr, err), serverShutdownTimeout) + return s.closeJobsAfterListenError(fmt.Errorf("listen on %s: %w", addr, err), ShutdownPhaseTimeout) } return s.Serve(ctx, listener) } @@ -252,7 +261,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error { func (s *Server) Serve(ctx context.Context, ln net.Listener) error { s.setBoundAddr(ln.Addr()) s.logStartup(ln.Addr()) - return s.serveListener(ctx, ln, s.handler(), serverShutdownTimeout) + return s.serveListener(ctx, ln, s.handler(), ShutdownPhaseTimeout) } // BoundAddr returns the effective address the server is actually bound to. diff --git a/internal/server/state.go b/internal/server/state.go index aa75f07d..c2432f72 100644 --- a/internal/server/state.go +++ b/internal/server/state.go @@ -13,6 +13,8 @@ import ( "path/filepath" "sync" "time" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" ) // jobsStateFile is the on-disk format for jobs_state.json. @@ -47,7 +49,10 @@ func saveJobsStateLocked(path string, jobs []*Job) error { if err != nil { return err } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + // The state directory is created with the mode the server owns: an + // owner-only directory for a supervised instance, the historical mode + // for an interactive run. + if err := os.MkdirAll(filepath.Dir(path), serverDirMode()); err != nil { return err } return replaceJobsStateFile(path, data, defaultStateFileOps{}) @@ -242,7 +247,10 @@ func AppendHistory(job *Job) error { if err != nil { return err } - if err := os.MkdirAll(AppConfigDir(), 0o755); err != nil { + // The state directory comes from the effective runtime path model, so a + // supervised instance writes its history into its own state root. The + // directory is created with the mode the server owns. + if err := os.MkdirAll(runtimepaths.StateDir(), serverDirMode()); err != nil { return err } return os.WriteFile(HistoryPath(), data, 0o644) diff --git a/internal/service/access_unix.go b/internal/service/access_unix.go new file mode 100644 index 00000000..7180b98e --- /dev/null +++ b/internal/service/access_unix.go @@ -0,0 +1,374 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build unix + +package service + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "syscall" +) + +// The access check of a directory the service account of a machine-wide +// instance has to use. +// +// The check is the effective one: it asks whether the account can really +// create entries in the directory and really reach it, not whether it owns +// the directory. A directory the account owns with the mode 0500 is not +// writable at all, because the owner has no write permission in it, and a +// directory below a parent without the execute permission cannot be reached +// at all, not even by its owner. +// +// The check lives in one file for the Unix platforms, so Linux and macOS +// cannot drift apart. The identity of the account stays with the platform +// managers: how the account of a machine-wide instance is resolved is a +// fact of the platform. +// +// The check judges the real location of a path. The kernel follows the +// symbolic links of a path before it uses it, so a directory that is reached +// through a link is used by its real location and not by the path of the +// link: the check resolves the path and asks about the resolved directory +// and the resolved ancestors of it. A check that asked about the path as it +// was written would accept a directory whose real location the account never +// reaches. +// +// A directory the installation creates itself does not need the check: the +// directory inside the state directory of the instance that does not exist +// yet is one of them. The state directory belongs to the account of the +// instance, and the service creates the directory below it. A directory that +// exists already is a different case: it belongs to whoever owns it, so it +// is checked against the account like every other named directory. + +// maxResolvedLinks is the number of symbolic links the resolution of a path +// follows before it gives up. +// +// The kernel refuses a path with a link loop after a few links, so the +// resolution refuses it as well instead of following it forever. +const maxResolvedLinks = 40 + +// accessAccount is the identity an access check runs for. +type accessAccount struct { + // UID is the numeric user id of the account. + UID int + // GID is the numeric id of the primary group of the account. + GID int + // Groups holds the numeric ids of the groups the account belongs to. + // The primary group of the account is part of it. + Groups []int +} + +// accountCanUseDir reports whether the account can use the directory path. +// +// The check judges the real location of the path: the kernel follows the +// symbolic links of a path before it uses it, so the permissions of the +// resolved directory and the traversal of the resolved ancestors of it are +// the ones that decide whether the account can use the directory. +// +// The account has to create entries in the directory: it needs the write and +// the execute permission on it. A directory that does not exist yet is +// checked through its nearest existing ancestor: the account has to be able +// to create the directory there. +// +// Every directory above the target has to be traversable by the account: it +// needs the execute permission on it, otherwise the account never reaches +// the target. The directories of the path as it was written have to be +// traversable as well: the kernel resolves the path part by part, so it +// passes through them to reach the symbolic links of it. +// +// The answer is a plain yes or no. An error means that the directory could +// not be read at all, which is a refusal as well. +func accountCanUseDir(path string, account accessAccount) (bool, error) { + // The real location of the path is the one the kernel uses, so the + // check judges it and not the path as it was written. + resolved, err := resolvePath(path) + if err != nil { + return false, err + } + + target := resolved + info, err := os.Stat(target) + switch { + case err == nil: + if !info.IsDir() { + return false, fmt.Errorf("it exists and is not a directory") + } + case errors.Is(err, fs.ErrNotExist): + ancestor, err := nearestExistingDir(target) + if err != nil { + return false, err + } + target = ancestor + info, err = os.Stat(target) + if err != nil { + return false, err + } + default: + return false, err + } + + // The account has to create entries in the directory: it needs the + // write and the execute permission on it. + if effectivePermission(info, account)&0o300 != 0o300 { + return false, nil + } + + // Every directory above the real location of the target has to be + // traversable, otherwise the account never reaches the target. + ok, err := accountCanTraverse(filepath.Dir(target), account) + if err != nil || !ok { + return ok, err + } + + // The path as it was written has to be traversable as well: the + // kernel resolves it part by part, so it passes through the + // directories of the path to reach the symbolic links in it. + written, err := nearestExistingDir(path) + if err != nil { + return false, err + } + return accountCanTraverse(written, account) +} + +// accountCanTraverse reports whether the account can pass through the +// directory and through every directory above it. +// +// A directory without the execute permission cannot be entered, so a +// directory below it is out of reach of the account. +func accountCanTraverse(dir string, account accessAccount) (bool, error) { + current := filepath.Clean(dir) + for { + info, err := os.Stat(current) + if err != nil { + return false, err + } + if !info.IsDir() { + return false, fmt.Errorf("%q is not a directory", current) + } + if effectivePermission(info, account)&0o100 == 0 { + return false, nil + } + parent := filepath.Dir(current) + if parent == current { + return true, nil + } + current = parent + } +} + +// effectivePermission returns the permission bits that apply to the account +// for the file the information describes. +// +// The resolution is the one of POSIX: the owner bits apply to the owner of +// the file, the group bits to a member of the group of the file and the +// other bits to everybody else. +// +// The bits are shifted into the position of the owner, so a caller tests +// them like the mode of a file: the write permission is 0o200 and the +// execute permission is 0o100, whatever the class of the permission is. +func effectivePermission(info fs.FileInfo, account accessAccount) os.FileMode { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + // The file system does not report an owner: nobody is known to + // have a permission on the file. + return 0 + } + mode := info.Mode().Perm() + switch { + case int(stat.Uid) == account.UID: + return mode & 0o700 + case accountInGroup(int(stat.Gid), account): + return (mode & 0o070) << 3 + default: + return (mode & 0o007) << 6 + } +} + +// accountInGroup reports whether the account belongs to the group. +// +// The primary group of the account and the supplementary groups of the +// account count: a directory the account reaches through a supplementary +// group is as usable as one it reaches through its primary group. +func accountInGroup(gid int, account accessAccount) bool { + if account.GID == gid { + return true + } + for _, group := range account.Groups { + if group == gid { + return true + } + } + return false +} + +// nearestExistingDir returns the nearest existing ancestor of the path. +// +// The ancestor has to be a directory: a path below a file can never be a +// directory. +func nearestExistingDir(path string) (string, error) { + current := filepath.Dir(filepath.Clean(path)) + for { + info, err := os.Stat(current) + if err == nil { + if !info.IsDir() { + return "", fmt.Errorf("%q is not a directory", current) + } + return current, nil + } + if !errors.Is(err, fs.ErrNotExist) { + return "", err + } + parent := filepath.Dir(current) + if parent == current { + return "", fmt.Errorf("no existing parent directory of %q was found", path) + } + current = parent + } +} + +// resolvePath returns the real location of the path. +// +// The kernel follows the symbolic links of a path before it uses it, so the +// real location of the path is the one that decides whether the account can +// use it. The resolution follows the links of the path part by part: a link +// is replaced by its target and the parts that follow it stay behind it. +// +// The resolution stops at the first part of the path that does not exist: +// the parts that are missing are appended to the real location of the path, +// so the caller can tell a directory that is there from one that has to be +// created. +func resolvePath(path string) (string, error) { + clean := filepath.Clean(path) + if !filepath.IsAbs(clean) { + // An installation only ever names absolute paths, but a relative + // one is resolved against the working directory of the process, so + // the answer is always about a real location. + absolute, err := filepath.Abs(clean) + if err != nil { + return "", err + } + clean = absolute + } + + resolved := string(filepath.Separator) + pending := pathComponents(clean) + links := 0 + for len(pending) > 0 { + part := pending[0] + candidate := filepath.Join(resolved, part) + info, err := os.Lstat(candidate) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return "", err + } + // The path does not exist from here on: the parts that are + // missing keep the location they were written with. + return filepath.Join(append([]string{resolved}, pending...)...), nil + } + pending = pending[1:] + if info.Mode()&fs.ModeSymlink == 0 { + resolved = candidate + continue + } + // The part is a symbolic link: the link is replaced by its + // target, and the parts that follow the link stay behind it. A + // relative target is resolved against the directory of the link. + links++ + if links > maxResolvedLinks { + return "", fmt.Errorf("%q holds a symbolic link loop", path) + } + target, err := os.Readlink(candidate) + if err != nil { + return "", err + } + target = filepath.Clean(target) + if filepath.IsAbs(target) { + resolved = string(filepath.Separator) + } + pending = append(pathComponents(target), pending...) + } + return resolved, nil +} + +// pathComponents splits a path into the parts below its root. +// +// The empty parts and the "." part name the directory itself and not a part +// below it, so they are left out. +func pathComponents(path string) []string { + parts := strings.Split(path, string(filepath.Separator)) + filtered := parts[:0] + for _, part := range parts { + if part == "" || part == "." { + continue + } + filtered = append(filtered, part) + } + return filtered +} + +// pathMissingInsideRealDir reports whether the real location of the path lies +// inside the real location of the directory and the path does not exist yet. +// +// The kernel follows the symbolic links of a path before it uses it, so the +// real location of a path is the one that decides whether the account can use +// it. A link inside the directory that points out of it leaves the directory, +// so a path that is written inside the directory can still live outside of +// it. +// +// A path that does not exist yet is resolved through its nearest existing +// ancestor: the parts that are missing keep the location they were written +// with, so a directory that is going to be created is compared with the +// directory that holds it. +// +// The answer is false when the location of a path cannot be resolved: an +// unknown location is never a location inside the other path, so the caller +// checks the path against the account in that case. +// +// The directory has to be missing as well. A directory that exists already +// belongs to whoever owns it: the installation never walks the tree of the +// state directory and never grants a directory of it to the account, so an +// existing directory is checked against the account like every other named +// directory. +func pathMissingInsideRealDir(dir, path string) bool { + if dir == "" || path == "" { + return false + } + realDir, err := resolvePath(dir) + if err != nil { + return false + } + realPath, err := resolvePath(path) + if err != nil { + return false + } + if !pathContains(realDir, realPath) { + return false + } + // The real location decides whether the directory is there: the + // kernel follows the symbolic links of a path before it uses it, so a + // path that points at a directory that is not there names a directory + // that does not exist yet. + _, err = os.Stat(realPath) + return errors.Is(err, fs.ErrNotExist) +} + +// pathContains reports whether the path lies inside the directory. +// +// The directory lies inside itself, so a path that names the directory is +// contained by it. A sibling that shares the prefix of the directory +// ("/stateful" against "/state") is not inside it. +func pathContains(dir, path string) bool { + rel, err := filepath.Rel(dir, path) + if err != nil { + return false + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return false + } + return true +} diff --git a/internal/service/host.go b/internal/service/host.go new file mode 100644 index 00000000..e216b021 --- /dev/null +++ b/internal/service/host.go @@ -0,0 +1,319 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/bashrusakh/hfdesk/internal/app" + "github.com/bashrusakh/hfdesk/internal/logging" + "github.com/bashrusakh/hfdesk/internal/runtimepaths" + "github.com/bashrusakh/hfdesk/internal/server" +) + +// logFileName is the name of the log file of an instance inside its log +// directory. +const logFileName = "hfdesk.log" + +// RunOptions configures the headless service host. +type RunOptions struct { + // Paths is the runtime path model of the instance. It is installed + // process-wide before any server state is read or written. + Paths runtimepaths.Paths + // Listen is the host:port the instance binds. Empty means DefaultListen. + Listen string + // CacheDir is the absolute Hugging Face cache root of the registration. + // It is the fallback: the cache-dir of the config file of the instance + // wins over it. Empty keeps the default resolution. + CacheDir string + // HubDir is the absolute Hugging Face hub cache directory of the + // registration. It is the fallback: a config file of the instance that + // defines a cache root owns the hub directory as well. Empty keeps the + // default resolution. + HubDir string + // LocalDir is the absolute flat output directory of the registration. + // It is the fallback: the local-dir of the config file of the instance + // wins over it. Empty means the Hugging Face cache layout is used. + LocalDir string +} + +// Run starts HFDesk in service mode and blocks until ctx is cancelled or the +// server fails. It never opens a browser and never creates a tray icon: the +// instance is supervised, there is no desktop session to interact with. +func Run(ctx context.Context, opts RunOptions) error { + // The runtime path model is installed first: the config file, the job + // state, the download history and the mirror targets are all resolved + // from it, so no server state may be read or written before it is in + // place. + runtimepaths.Install(opts.Paths) + + // The shared logging pipeline is installed before the server starts: + // the server logs through it, so the pipeline is what puts the records + // of the server into the log file of the instance. The pipeline is + // closed when the host returns, so the records of the shutdown are + // written before the file sink goes away. + pipeline, err := installLogging(opts.Paths.LogDir) + if err != nil { + return err + } + defer func() { _ = pipeline.Close() }() + + // The failure of the persistent log is reported to the supervisor of the + // instance: the file sink can fail to rotate the log file, and a log + // file that stopped working is a problem the operator has to know + // about. The report happens on the standard error of the process, and + // it never changes the exit code of the host. + logFailure := app.NewLogFailureReporter(pipeline, func(text string) { + fmt.Fprintln(os.Stderr, text) + }) + logFailure.Subscribe() + defer logFailure.Report() + + // Resolve the bind address of the instance. + listen := opts.Listen + if listen == "" { + listen = DefaultListen + } + host, port, err := ParseListen(listen) + if err != nil { + return err + } + + // The configuration of the instance resolves its destinations and pins + // the cache environment, so it has to run before the server is created: + // the server captures the cache identity of the process when it is + // built. + cfg, err := buildConfig(opts, host, port) + if err != nil { + return err + } + + // The server owns the graceful shutdown path (quiesce, HTTP drain, job + // manager close); the host only waits for it. + return server.New(cfg).ListenAndServe(ctx) +} + +// destinations are the storage locations of a service instance. +type destinations struct { + // CacheDir is the Hugging Face cache root of the instance. + CacheDir string + // HubDir is the Hugging Face hub cache directory of the instance. + HubDir string + // LocalDir is the flat output directory of the instance. + LocalDir string +} + +// installLogging installs the shared logging pipeline of a service instance. +// +// The console sink is on: the supervisor of the instance captures the +// standard error of the process (the systemd journal, the launchd log), so +// the records of the instance reach the supervisor. The file sink writes +// into the log file of the instance, so the records of the server land in +// the log file of the instance as well. +// +// The service mode has no log level option, so the pipeline keeps its +// default level. +// +// A log file that cannot be written never stops the instance: the pipeline +// falls back to the console sink alone and the failure is reported in a +// readable form. The failure of the log file never changes the exit code of +// the host. +func installLogging(logDir string) (*logging.Pipeline, error) { + if logDir == "" { + return nil, errors.New("service: the log directory of the instance is required") + } + logPath := filepath.Join(logDir, logFileName) + + // The log directory is created owner-only before the pipeline is + // installed: the shared file sink creates the directory of the log file + // with a wider mode, and the log directory of an instance holds the log + // of the instance. A directory that already exists keeps its mode, so + // the instance never widens the permissions of a directory it did not + // create. + if err := ensureDir(logDir, dirMode); err != nil { + return installConsoleLogging( + fmt.Errorf("service: create the log directory %q: %w", logDir, err), "") + } + + pipeline, err := app.SetupLogging(app.LogOptions{ + Console: true, + FilePath: logPath, + }) + if err != nil { + return installConsoleLogging(err, logPath) + } + return pipeline, nil +} + +// installConsoleLogging installs a console-only pipeline and reports the +// failure of the log file. +// +// The failure of the log file is not the failure of the instance: the +// instance keeps running, and the supervisor of the instance still receives +// every record through the console sink. +func installConsoleLogging(failure error, logPath string) (*logging.Pipeline, error) { + // The report goes to the standard error of the process, where the + // supervisor of the instance reads it. There is no pipeline yet, so the + // report cannot go through the console sink. + fmt.Fprintln(os.Stderr, app.LogFailureText(failure, logPath)) + + pipeline, err := app.SetupLogging(app.LogOptions{Console: true}) + if err != nil { + return nil, err + } + return pipeline, nil +} + +// buildConfig assembles the server configuration of a service instance. +// +// The destinations of an instance follow the precedence of a service +// instance: the config file of the instance wins over the registration. +// The user changes the cache directory and the output directory in the +// settings of the instance, and the next start of the instance has to use +// the changed value. The registration is the fallback for a destination the +// config file does not define. +// +// The runtime path model of the instance is installed here, so the settings +// of the instance come from its own config file and never from an ambient +// global the caller did not set. The server applies the settings of the +// instance through the process wide path model, and the model of the +// instance is the only one that may be in place. +// +// The cache environment is pinned from the effective values after the +// configuration was loaded: the server reads HF_HOME and HF_HUB_CACHE when +// it captures the cache identity of the process, and neither variable is +// read by the config file. +func buildConfig(opts RunOptions, host string, port int) (server.Config, error) { + if opts.Paths.StateDir == "" || !filepath.IsAbs(opts.Paths.StateDir) { + return server.Config{}, fmt.Errorf("service: state dir %q must be an absolute path", opts.Paths.StateDir) + } + + // The path model of the instance is installed before any server state + // is read: the config file, the job state, the download history and + // the mirror targets all resolve from it. + runtimepaths.Install(opts.Paths) + + // server.ApplyConfigToServer prefers HF_TOKEN over the config file, so + // the environment must not keep a credential: it is removed before the + // configuration is loaded and the service-owned config file is the only + // source of the token. + if err := os.Unsetenv("HF_TOKEN"); err != nil { + return server.Config{}, fmt.Errorf("service: drop environment token: %w", err) + } + + dest, err := effectiveDestinations(opts) + if err != nil { + return server.Config{}, err + } + + cfg := server.DefaultConfig() + // The server composes the bind address as ":", so an IPv6 + // host keeps its brackets. + cfg.Addr = listenHost(host) + cfg.Port = port + cfg.CacheDir = dest.CacheDir + cfg.LocalDir = dest.LocalDir + // The interactive defaults are relative ("./Models"), which would resolve + // against the working directory the supervisor happened to choose. A + // service instance keeps its output inside its own state root. + cfg.ModelsDir = filepath.Join(opts.Paths.StateDir, "models") + cfg.DatasetsDir = filepath.Join(opts.Paths.StateDir, "datasets") + + // The service-owned config file supplies the token, the proxy and the + // remaining settings of the instance. + if err := server.ApplyConfigToServer(&cfg); err != nil { + return server.Config{}, fmt.Errorf("service: load config %q: %w", opts.Paths.ConfigFile, err) + } + + // The cache environment is pinned from the effective values: the + // configuration of the instance is loaded, so these are the values the + // instance really uses. + if err := pinCacheEnvironment(dest); err != nil { + return server.Config{}, err + } + return cfg, nil +} + +// effectiveDestinations resolves the storage locations of a service instance. +// +// The config file of the instance wins over the registration: the user +// changes the cache directory and the output directory in the settings of +// the instance, and the instance has to use the changed value after the +// next start. The registration is the fallback for a destination the config +// file does not define. +// +// The hub directory follows the cache root. A config file that defines the +// cache root owns the whole cache layout of the instance, so the hub +// directory is the "hub" child of that root. Without a cache root from the +// config file the registration keeps its hub directory, and a registration +// without one falls back to the "hub" child of the effective cache root. +func effectiveDestinations(opts RunOptions) (destinations, error) { + fileCfg, err := server.LoadConfigFileAt(opts.Paths.ConfigFile) + if err != nil { + return destinations{}, fmt.Errorf("service: load config %q: %w", opts.Paths.ConfigFile, err) + } + + dest := destinations{ + CacheDir: opts.CacheDir, + LocalDir: opts.LocalDir, + } + if fileCfg.CacheDir != "" { + dest.CacheDir = fileCfg.CacheDir + } + if fileCfg.LocalDir != "" { + dest.LocalDir = fileCfg.LocalDir + } + + switch { + case fileCfg.CacheDir != "": + dest.HubDir = filepath.Join(dest.CacheDir, "hub") + case opts.HubDir != "": + dest.HubDir = opts.HubDir + case dest.CacheDir != "": + dest.HubDir = filepath.Join(dest.CacheDir, "hub") + } + // An instance without a cache root and without a registered hub + // directory keeps the default resolution: there is no root to derive a + // hub directory from. + return dest, nil +} + +// pinCacheEnvironment pins the cache identity of the process. +// +// A supervisor hands over whatever environment it happens to have (a login +// shell, a system-wide environment file, a stale export), so the effective +// cache identity would otherwise depend on the machine instead of on the +// installation. The values of the installation win; a destination that is +// not set keeps the environment/default resolution. +// +// Only the two cache variables are pinned. The token of the environment is +// dropped instead: the config file of the instance is the only source of +// its credential. +func pinCacheEnvironment(dest destinations) error { + if dest.CacheDir != "" { + if err := os.Setenv("HF_HOME", dest.CacheDir); err != nil { + return fmt.Errorf("service: pin cache root: %w", err) + } + } + if dest.HubDir != "" { + if err := os.Setenv("HF_HUB_CACHE", dest.HubDir); err != nil { + return fmt.Errorf("service: pin hub cache: %w", err) + } + } + return nil +} + +// listenHost returns the host in the form the server composes with the port +// into a socket address. +func listenHost(host string) string { + if strings.Contains(host, ":") { + return "[" + host + "]" + } + return host +} diff --git a/internal/service/host_test.go b/internal/service/host_test.go new file mode 100644 index 00000000..31766dc0 --- /dev/null +++ b/internal/service/host_test.go @@ -0,0 +1,1228 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/bashrusakh/hfdesk/internal/logging" + "github.com/bashrusakh/hfdesk/internal/runtimepaths" + "github.com/bashrusakh/hfdesk/internal/server" +) + +func TestRunInstallsRuntimeModelAndStopsOnCancel(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + t.Cleanup(runtimepaths.Reset) + + opts := RunOptions{Paths: model} + start := time.Now() + if err := runService(t, opts); err != nil { + t.Fatalf("Run() = %v, want nil", err) + } + if elapsed := time.Since(start); elapsed > 30*time.Second { + t.Errorf("Run() returned after %s, want a prompt shutdown", elapsed) + } + + if got := runtimepaths.Current(); got != model { + t.Errorf("runtimepaths.Current() = %+v, want %+v", got, model) + } + + // The process log reached the log file of the instance. + logFile := filepath.Join(model.LogDir, "hfdesk.log") + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("read %s: %v", logFile, err) + } + if len(data) == 0 { + t.Errorf("%s is empty, want the process log", logFile) + } +} + +// TestRunWritesTheRecordsOfTheServerIntoTheLogFileOfTheInstance checks the +// property the service mode depends on: the records of the server reach the +// log file of the instance. +// +// The server logs through the shared logging pipeline, so the instance has to +// install that pipeline with a file sink that points at the log file of the +// instance. A host that redirects the standard logger instead leaves the log +// file of a running instance without the records of the server. +// +// The test is hermetic: the state directory and the log directory of the +// instance are temporary directories, and the instance is a real one. +func TestRunWritesTheRecordsOfTheServerIntoTheLogFileOfTheInstance(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + t.Cleanup(runtimepaths.Reset) + + port := freePort(t) + addr := "127.0.0.1:" + strconv.Itoa(port) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- Run(ctx, RunOptions{Paths: model, Listen: addr}) + }() + + // The instance has to answer before the log is read: the record of the + // server is written when it starts. + if _, err := waitForSettings(t, addr); err != nil { + cancel() + <-done + t.Fatalf("the instance never answered: %v", err) + } + cancel() + if err := <-done; err != nil { + t.Fatalf("Run() = %v, want nil", err) + } + + // The pipeline is closed when the host returns, so the log file of the + // instance is complete when the run is over. + logFile := filepath.Join(model.LogDir, "hfdesk.log") + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("read %s: %v", logFile, err) + } + if len(data) == 0 { + t.Fatalf("%s is empty, want the records of the server", logFile) + } + // The record is a record of the shared logging pipeline: the file sink + // of the pipeline writes the records as JSON lines, with the component + // that produced them. + if !strings.Contains(string(data), `"component":"server"`) { + t.Errorf("%s = %s, want the record of the server of the shared pipeline", logFile, data) + } + if !strings.Contains(string(data), `"message":"server starting"`) { + t.Errorf("%s = %s, want the record of the server of the shared pipeline", logFile, data) + } +} + +// TestRunKeepsRunningWhenTheLogFileCannotBeWritten checks that a log file +// that cannot be written never stops the instance. +// +// The failure of the log file is not the failure of the instance: the console +// sink still reaches the supervisor of the instance, and the instance keeps +// serving. +func TestRunKeepsRunningWhenTheLogFileCannotBeWritten(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + t.Cleanup(runtimepaths.Reset) + + // The log file cannot be created: a directory sits at its path. + if err := os.MkdirAll(filepath.Join(model.LogDir, "hfdesk.log"), 0o700); err != nil { + t.Fatal(err) + } + + port := freePort(t) + addr := "127.0.0.1:" + strconv.Itoa(port) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- Run(ctx, RunOptions{Paths: model, Listen: addr}) + }() + + if _, err := waitForSettings(t, addr); err != nil { + cancel() + <-done + t.Fatalf("the instance never answered: %v", err) + } + cancel() + if err := <-done; err != nil { + t.Fatalf("Run() = %v, want nil: the failure of the log file never stops the instance", err) + } +} + +// TestRunKeepsWritingTheLogFileWhenTheStandardErrorFails checks the log of +// a running instance against a broken standard error. +// +// The console sink of the shared pipeline writes to the standard error of the +// process, and a Windows instance is started by the service control manager +// that has no valid standard error there. The failure of the console sink +// must never suppress the file sink: the log file of the instance still has +// to receive every record. +func TestRunKeepsWritingTheLogFileWhenTheStandardErrorFails(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + t.Cleanup(runtimepaths.Reset) + + // The standard error of the process is broken: every write to it fails. + broken, err := os.CreateTemp(t.TempDir(), "stderr") + if err != nil { + t.Fatal(err) + } + if err := broken.Close(); err != nil { + t.Fatal(err) + } + previous := os.Stderr + os.Stderr = broken + t.Cleanup(func() { os.Stderr = previous }) + + port := freePort(t) + addr := "127.0.0.1:" + strconv.Itoa(port) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- Run(ctx, RunOptions{Paths: model, Listen: addr}) + }() + + if _, err := waitForSettings(t, addr); err != nil { + cancel() + <-done + t.Fatalf("the instance never answered: %v", err) + } + cancel() + if err := <-done; err != nil { + t.Fatalf("Run() = %v, want nil", err) + } + + logFile := filepath.Join(model.LogDir, "hfdesk.log") + data, err := os.ReadFile(logFile) + if err != nil { + t.Fatalf("read %s: %v", logFile, err) + } + if !strings.Contains(string(data), `"message":"server starting"`) { + t.Errorf("%s = %s, want the record of the server: the console sink fails, the file sink still receives every record", logFile, data) + } + + // The broken standard error really broke the console sink: without this + // the assertion above would prove nothing. + pipeline := logging.Default() + if pipeline == nil { + t.Fatal("the logging pipeline of the instance is not installed") + } + if pipeline.Err() == nil { + t.Error("the console sink did not fail: the broken standard error was not used") + } +} + +// TestRunCreatesTheLogDirectoryOwnerOnly checks the permissions of the log +// directory and of the log file of an instance. +// +// The log directory holds the log of the instance, so a directory that is +// missing at the start of an instance must not be recreated world-readable. +// A directory that already exists keeps its mode: it belongs to whoever +// named it, and the instance may only be able to write there. +func TestRunCreatesTheLogDirectoryOwnerOnly(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows mode bits are emulated and do not represent DACL permissions") + } + + t.Run("the missing log directory is created owner-only", func(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + t.Cleanup(runtimepaths.Reset) + + if err := runService(t, RunOptions{Paths: model}); err != nil { + t.Fatalf("Run() = %v, want nil", err) + } + + info, err := os.Stat(model.LogDir) + if err != nil { + t.Fatalf("stat %s: %v", model.LogDir, err) + } + if got := info.Mode().Perm(); got != 0o700 { + t.Errorf("the mode of the log directory = %04o, want 0700", got) + } + + // The log file keeps the mode the shared file sink gives it. + info, err = os.Stat(filepath.Join(model.LogDir, "hfdesk.log")) + if err != nil { + t.Fatalf("stat the log file: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("the mode of the log file = %04o, want 0600", got) + } + }) + + t.Run("an existing log directory keeps its mode", func(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + t.Cleanup(runtimepaths.Reset) + + // The log directory exists and belongs to somebody else: the + // instance writes there and leaves the mode alone. + if err := os.MkdirAll(model.LogDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(model.LogDir, 0o755); err != nil { + t.Fatal(err) + } + + if err := runService(t, RunOptions{Paths: model}); err != nil { + t.Fatalf("Run() = %v, want nil", err) + } + + info, err := os.Stat(model.LogDir) + if err != nil { + t.Fatalf("stat %s: %v", model.LogDir, err) + } + if got := info.Mode().Perm(); got != 0o755 { + t.Errorf("the mode of the log directory = %04o, want 0755: the instance never widens the permissions of a directory it did not create", got) + } + }) +} + +func TestRunServesUntilTheContextIsCancelled(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + t.Cleanup(runtimepaths.Reset) + + port := freePort(t) + addr := "127.0.0.1:" + strconv.Itoa(port) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- Run(ctx, RunOptions{Paths: model, Listen: addr}) + }() + + client := &http.Client{Timeout: 2 * time.Second} + url := "http://" + addr + "/api/health" + var lastErr error + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); { + body, status, err := get(client, url) + switch { + case err != nil: + lastErr = err + case status != http.StatusOK: + lastErr = fmt.Errorf("status %d: %s", status, body) + case !strings.Contains(body, `"status":"ok"`): + lastErr = fmt.Errorf("unexpected health payload: %s", body) + default: + cancel() + if err := <-done; err != nil { + t.Fatalf("Run() = %v, want nil", err) + } + return + } + time.Sleep(20 * time.Millisecond) + } + + cancel() + <-done + t.Fatalf("the health endpoint never answered: %v", lastErr) +} + +// TestRunShutdownPersistsTheJobListWithinTheBudget checks the graceful +// shutdown of a running instance. +// +// The instance is stopped while a request is in flight and while its job +// list is not empty. The host has to return within the shared budget of the +// service layer, and the job list has to end up in the state directory of +// the instance. +// +// The budget is the one every supervisor of the service layer waits for: a +// supervisor that waits less kills the instance while it is still saving +// its state. +func TestRunShutdownPersistsTheJobListWithinTheBudget(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + t.Cleanup(runtimepaths.Reset) + + // The instance starts with a job in its list: the shutdown has to + // persist it. + runtimepaths.Install(model) + if err := server.SaveJobsState([]*server.Job{{ + ID: "job-of-the-test", + Repo: "owner/model", + Status: server.JobStatusPaused, + }}); err != nil { + t.Fatalf("SaveJobsState(): %v", err) + } + stateFile := model.JobsStateFile() + + port := freePort(t) + addr := "127.0.0.1:" + strconv.Itoa(port) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- Run(ctx, RunOptions{Paths: model, Listen: addr}) + }() + + if _, err := waitForSettings(t, addr); err != nil { + cancel() + <-done + t.Fatalf("the instance never answered: %v", err) + } + + // The job list of the running instance is not empty. + client := &http.Client{Timeout: 2 * time.Second} + body, status, err := get(client, "http://"+addr+"/api/jobs") + if err != nil { + cancel() + <-done + t.Fatalf("the job list of the instance: %v", err) + } + if status != http.StatusOK { + cancel() + <-done + t.Fatalf("the job list of the instance: status %d: %s", status, body) + } + if !strings.Contains(body, "job-of-the-test") { + cancel() + <-done + t.Fatalf("the job list of the instance = %s, want the job of the test", body) + } + + // A request is in flight while the instance stops. The client sends + // the head of a request and the rest of it after the stop was asked + // for, so the drain of the HTTP server has to wait for it. + conn, err := net.Dial("tcp", addr) + if err != nil { + cancel() + <-done + t.Fatalf("dial the instance: %v", err) + } + defer conn.Close() + if _, err := conn.Write([]byte("GET /api/health HTTP/1.1\r\nHost: hfdesk\r\n")); err != nil { + cancel() + <-done + t.Fatalf("write the head of the request: %v", err) + } + // The server reads the head of the request and waits for the rest of + // it. The pause gives it the time to do so. + time.Sleep(100 * time.Millisecond) + + // The state file of the job list is removed, so the file that is there + // after the stop is the one the shutdown wrote. + if err := os.Remove(stateFile); err != nil { + cancel() + <-done + t.Fatalf("remove %q: %v", stateFile, err) + } + + const hold = 200 * time.Millisecond + start := time.Now() + cancel() + time.Sleep(hold) + if _, err := conn.Write([]byte("\r\n")); err != nil { + t.Logf("write the rest of the request: %v", err) + } + + select { + case err := <-done: + if err != nil { + t.Fatalf("Run() = %v, want nil", err) + } + case <-time.After(ShutdownBudget + shutdownMargin): + t.Fatalf("Run() did not return within %s", ShutdownBudget+shutdownMargin) + } + elapsed := time.Since(start) + + // The shutdown waited for the request that was in flight. + if elapsed < hold { + t.Errorf("the shutdown returned after %s, want at least %s: the drain of the HTTP server has to wait for the request in flight", elapsed, hold) + } + + // The job list is persisted in the state directory of the instance. + data, err := os.ReadFile(stateFile) + if err != nil { + t.Fatalf("read %q: %v", stateFile, err) + } + if !strings.Contains(string(data), "job-of-the-test") { + t.Errorf("%q = %s, want the job of the instance", stateFile, data) + } +} + +// get performs a GET request and returns the body and the status code. +func get(client *http.Client, url string) (string, int, error) { + resp, err := client.Get(url) + if err != nil { + return "", 0, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", resp.StatusCode, err + } + return string(body), resp.StatusCode, nil +} + +func TestRunPinsTheCacheEnvironment(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + t.Cleanup(runtimepaths.Reset) + + cacheDir := filepath.Join(root, "cache") + hubDir := filepath.Join(root, "cache", "hub") + t.Setenv("HF_HOME", filepath.Join(root, "stale-home")) + t.Setenv("HF_HUB_CACHE", filepath.Join(root, "stale-hub")) + + opts := RunOptions{Paths: model, CacheDir: cacheDir, HubDir: hubDir} + if err := runService(t, opts); err != nil { + t.Fatalf("Run() = %v, want nil", err) + } + + if got := os.Getenv("HF_HOME"); got != cacheDir { + t.Errorf("HF_HOME = %q, want the cache dir of the installation %q", got, cacheDir) + } + if got := os.Getenv("HF_HUB_CACHE"); got != hubDir { + t.Errorf("HF_HUB_CACHE = %q, want the hub dir of the installation %q", got, hubDir) + } +} + +func TestBuildConfigUsesTheServiceOwnedPaths(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + runtimepaths.Install(model) + t.Cleanup(runtimepaths.Reset) + + cacheDir := filepath.Join(root, "cache") + localDir := filepath.Join(root, "models") + cfg, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: cacheDir, + LocalDir: localDir, + }, "127.0.0.1", 8080) + if err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + + if cfg.Addr != "127.0.0.1" || cfg.Port != 8080 { + t.Errorf("listen = %q:%d, want 127.0.0.1:8080", cfg.Addr, cfg.Port) + } + if want := filepath.Join(model.StateDir, "models"); cfg.ModelsDir != want { + t.Errorf("ModelsDir = %q, want %q", cfg.ModelsDir, want) + } + if want := filepath.Join(model.StateDir, "datasets"); cfg.DatasetsDir != want { + t.Errorf("DatasetsDir = %q, want %q", cfg.DatasetsDir, want) + } + if cfg.CacheDir != cacheDir { + t.Errorf("CacheDir = %q, want %q", cfg.CacheDir, cacheDir) + } + if cfg.LocalDir != localDir { + t.Errorf("LocalDir = %q, want %q", cfg.LocalDir, localDir) + } +} + +// TestBuildConfigInstallsTheRuntimeModel checks that the settings of an +// instance come from its own config file, even when no runtime path model +// was installed before. +// +// The server applies the settings of the instance through the process wide +// path model. Without a model of its own, the instance would read the +// settings of an ambient model instead of its own config file. +func TestBuildConfigInstallsTheRuntimeModel(t *testing.T) { + // No model is installed: the interactive resolution is in place. + runtimepaths.Reset() + t.Cleanup(runtimepaths.Reset) + + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + if err := os.MkdirAll(filepath.Dir(model.ConfigFile), 0o700); err != nil { + t.Fatal(err) + } + cacheRoot := filepath.Join(root, "from-config-file") + data, err := json.Marshal(map[string]string{ + "cache-dir": cacheRoot, + "token": "hf_token_of_the_instance", + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(model.ConfigFile, data, 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: "/registered/cache", + }, "127.0.0.1", 8080) + if err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + + if cfg.CacheDir != cacheRoot { + t.Errorf("CacheDir = %q, want the cache dir of the config file of the instance %q", cfg.CacheDir, cacheRoot) + } + // The token is applied through the path model of the instance, so it + // is the token of the config file of the instance and never the one + // of an ambient config file. + if cfg.Token != "hf_token_of_the_instance" { + t.Errorf("Token = %q, want the token of the config file of the instance", cfg.Token) + } + if got := runtimepaths.Current(); got != model { + t.Errorf("runtimepaths.Current() = %+v, want the model of the instance %+v", got, model) + } +} + +// serviceInstance writes the config file of a service instance with the given +// fields and installs the runtime path model that owns it. The model is +// restored when the test ends. +func serviceInstance(t *testing.T, fields map[string]string) runtimepaths.Paths { + t.Helper() + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + if err := os.MkdirAll(filepath.Dir(model.ConfigFile), 0o700); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(fields) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(model.ConfigFile, data, 0o600); err != nil { + t.Fatal(err) + } + runtimepaths.Install(model) + t.Cleanup(runtimepaths.Reset) + return model +} + +// TestBuildConfigPrefersTheConfigFileOfTheInstance checks the coherence +// rule of a service instance: the config file of the instance wins over the +// registration. +// +// The user changes the cache directory and the output directory in the +// settings of the instance, so the next start of the instance has to use the +// changed value. The registration is only the fallback. +func TestBuildConfigPrefersTheConfigFileOfTheInstance(t *testing.T) { + model := serviceInstance(t, map[string]string{ + "cache-dir": "/from/config/file", + "local-dir": "/from/config/file/models", + }) + t.Setenv("HF_HOME", "/stale/home") + t.Setenv("HF_HUB_CACHE", "/stale/hub") + + cfg, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: "/registered/cache", + HubDir: "/registered/cache/hub", + LocalDir: "/registered/models", + }, "127.0.0.1", 8080) + if err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + + if cfg.CacheDir != "/from/config/file" { + t.Errorf("CacheDir = %q, want the cache dir of the config file %q", cfg.CacheDir, "/from/config/file") + } + if cfg.LocalDir != "/from/config/file/models" { + t.Errorf("LocalDir = %q, want the local dir of the config file %q", cfg.LocalDir, "/from/config/file/models") + } + if got := os.Getenv("HF_HOME"); got != "/from/config/file" { + t.Errorf("HF_HOME = %q, want the cache dir of the config file %q", got, "/from/config/file") + } + if got, want := os.Getenv("HF_HUB_CACHE"), filepath.Join("/from/config/file", "hub"); got != want { + t.Errorf("HF_HUB_CACHE = %q, want %q", got, want) + } +} + +// TestBuildConfigKeepsTheRegistrationWithoutAConfigFileValue checks the +// fallback of the coherence rule: an instance whose config file does not +// define a directory uses the directory of its registration. +func TestBuildConfigKeepsTheRegistrationWithoutAConfigFileValue(t *testing.T) { + model := serviceInstance(t, map[string]string{}) + t.Setenv("HF_HOME", "/stale/home") + t.Setenv("HF_HUB_CACHE", "/stale/hub") + + cfg, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: "/registered/cache", + HubDir: "/registered/cache/hub", + LocalDir: "/registered/models", + }, "127.0.0.1", 8080) + if err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + + if cfg.CacheDir != "/registered/cache" { + t.Errorf("CacheDir = %q, want the registered cache dir %q", cfg.CacheDir, "/registered/cache") + } + if cfg.LocalDir != "/registered/models" { + t.Errorf("LocalDir = %q, want the registered local dir %q", cfg.LocalDir, "/registered/models") + } + if got := os.Getenv("HF_HOME"); got != "/registered/cache" { + t.Errorf("HF_HOME = %q, want the registered cache dir %q", got, "/registered/cache") + } + if got := os.Getenv("HF_HUB_CACHE"); got != "/registered/cache/hub" { + t.Errorf("HF_HUB_CACHE = %q, want the registered hub dir %q", got, "/registered/cache/hub") + } +} + +// TestBuildConfigDerivesTheHubDir checks the hub rule of an instance. +// +// A config file that defines the cache root owns the whole cache layout, so +// the hub directory is the "hub" child of that root. Without a cache root +// from the config file the registration keeps its hub directory, and a +// registration without one falls back to the "hub" child of the effective +// cache root. +func TestBuildConfigDerivesTheHubDir(t *testing.T) { + t.Run("the config file owns the hub directory", func(t *testing.T) { + model := serviceInstance(t, map[string]string{"cache-dir": "/from/config/file"}) + t.Setenv("HF_HOME", "/stale/home") + t.Setenv("HF_HUB_CACHE", "/stale/hub") + + if _, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: "/registered/cache", + HubDir: "/registered/cache/hub", + }, "127.0.0.1", 8080); err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + if got, want := os.Getenv("HF_HUB_CACHE"), filepath.Join("/from/config/file", "hub"); got != want { + t.Errorf("HF_HUB_CACHE = %q, want %q", got, want) + } + }) + + t.Run("the registration keeps its hub directory", func(t *testing.T) { + model := serviceInstance(t, map[string]string{}) + t.Setenv("HF_HOME", "/stale/home") + t.Setenv("HF_HUB_CACHE", "/stale/hub") + + if _, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: "/registered/cache", + HubDir: "/registered/hub", + }, "127.0.0.1", 8080); err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + if got := os.Getenv("HF_HUB_CACHE"); got != "/registered/hub" { + t.Errorf("HF_HUB_CACHE = %q, want the registered hub dir %q", got, "/registered/hub") + } + }) + + t.Run("the cache root owns the hub directory", func(t *testing.T) { + model := serviceInstance(t, map[string]string{}) + t.Setenv("HF_HOME", "/stale/home") + t.Setenv("HF_HUB_CACHE", "/stale/hub") + + if _, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: "/registered/cache", + }, "127.0.0.1", 8080); err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + if got, want := os.Getenv("HF_HUB_CACHE"), filepath.Join("/registered/cache", "hub"); got != want { + t.Errorf("HF_HUB_CACHE = %q, want %q", got, want) + } + }) +} + +// TestBuildConfigAppliesTheLocalDirRule checks the output directory of an +// instance: the local-dir of the config file of the instance wins over the +// registered output directory. +func TestBuildConfigAppliesTheLocalDirRule(t *testing.T) { + t.Run("the config file wins", func(t *testing.T) { + model := serviceInstance(t, map[string]string{"local-dir": "/from/config/file/models"}) + t.Setenv("HF_HOME", "/stale/home") + t.Setenv("HF_HUB_CACHE", "/stale/hub") + + cfg, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: "/registered/cache", + LocalDir: "/registered/models", + }, "127.0.0.1", 8080) + if err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + if cfg.LocalDir != "/from/config/file/models" { + t.Errorf("LocalDir = %q, want the local dir of the config file %q", cfg.LocalDir, "/from/config/file/models") + } + }) + + t.Run("the registration is the fallback", func(t *testing.T) { + model := serviceInstance(t, map[string]string{}) + t.Setenv("HF_HOME", "/stale/home") + t.Setenv("HF_HUB_CACHE", "/stale/hub") + + cfg, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: "/registered/cache", + LocalDir: "/registered/models", + }, "127.0.0.1", 8080) + if err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + if cfg.LocalDir != "/registered/models" { + t.Errorf("LocalDir = %q, want the registered local dir %q", cfg.LocalDir, "/registered/models") + } + }) +} + +// TestBuildConfigPinsTheCacheEnvironment checks that the cache environment +// of the process carries the effective values of the instance. +// +// A supervisor hands over whatever environment it happens to have, so the +// pinning has to replace it with the values of the instance. The pinning +// happens after the configuration was loaded, so the values are the ones +// the instance really uses. +func TestBuildConfigPinsTheCacheEnvironment(t *testing.T) { + model := serviceInstance(t, map[string]string{"cache-dir": "/from/config/file"}) + t.Setenv("HF_HOME", "/stale/home") + t.Setenv("HF_HUB_CACHE", "/stale/hub") + + if _, err := buildConfig(RunOptions{ + Paths: model, + CacheDir: "/registered/cache", + HubDir: "/registered/cache/hub", + }, "127.0.0.1", 8080); err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + + if got := os.Getenv("HF_HOME"); got != "/from/config/file" { + t.Errorf("HF_HOME = %q, want the effective cache root %q", got, "/from/config/file") + } + if got, want := os.Getenv("HF_HUB_CACHE"), filepath.Join("/from/config/file", "hub"); got != want { + t.Errorf("HF_HUB_CACHE = %q, want the effective hub dir %q", got, want) + } +} + +// TestRunUsesTheConfigFileOfTheInstance checks the coherence rule of a +// running instance. +// +// The config file of the instance wins over the registration, so the cache +// root, the hub directory and the output directory the instance reports are +// the ones the user changed in the settings of the instance. +func TestRunUsesTheConfigFileOfTheInstance(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + if err := os.MkdirAll(filepath.Dir(model.ConfigFile), 0o700); err != nil { + t.Fatal(err) + } + cacheRoot := filepath.Join(root, "from-config-file") + localDir := filepath.Join(root, "from-config-file", "models") + data, err := json.Marshal(map[string]string{ + "cache-dir": cacheRoot, + "local-dir": localDir, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(model.ConfigFile, data, 0o600); err != nil { + t.Fatal(err) + } + + // The supervisor handed over a stale environment and a registration + // with other directories: neither of them may win over the config file. + t.Setenv("HF_HOME", filepath.Join(root, "stale-home")) + t.Setenv("HF_HUB_CACHE", filepath.Join(root, "stale-hub")) + t.Cleanup(runtimepaths.Reset) + + port := freePort(t) + addr := "127.0.0.1:" + strconv.Itoa(port) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- Run(ctx, RunOptions{ + Paths: model, + Listen: addr, + CacheDir: filepath.Join(root, "registered", "cache"), + HubDir: filepath.Join(root, "registered", "cache", "hub"), + LocalDir: filepath.Join(root, "registered", "models"), + }) + }() + + settings, settingsErr := waitForSettings(t, addr) + cancel() + if err := <-done; err != nil { + t.Fatalf("Run() = %v, want nil", err) + } + if settingsErr != nil { + t.Fatalf("the settings of the instance: %v", settingsErr) + } + + if settings.CacheDir != cacheRoot { + t.Errorf("the cache dir of the instance = %q, want the cache dir of the config file %q", settings.CacheDir, cacheRoot) + } + if settings.ConfiguredCacheDir != cacheRoot { + t.Errorf("the configured cache dir of the instance = %q, want the cache dir of the config file %q", settings.ConfiguredCacheDir, cacheRoot) + } + if want := filepath.Join(cacheRoot, "hub"); settings.EffectiveHubDir != want { + t.Errorf("the effective hub dir of the instance = %q, want %q", settings.EffectiveHubDir, want) + } + if settings.LocalDir != localDir { + t.Errorf("the local dir of the instance = %q, want the local dir of the config file %q", settings.LocalDir, localDir) + } +} + +// waitForSettings reads the settings a running instance reports. +func waitForSettings(t *testing.T, addr string) (server.SettingsResponse, error) { + t.Helper() + client := &http.Client{Timeout: 2 * time.Second} + url := "http://" + addr + "/api/settings" + var lastErr error + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); { + body, status, err := get(client, url) + switch { + case err != nil: + lastErr = err + case status != http.StatusOK: + lastErr = fmt.Errorf("status %d: %s", status, body) + default: + var settings server.SettingsResponse + if err := json.Unmarshal([]byte(body), &settings); err != nil { + return settings, fmt.Errorf("decode the settings: %w", err) + } + return settings, nil + } + time.Sleep(20 * time.Millisecond) + } + return server.SettingsResponse{}, fmt.Errorf("the settings endpoint never answered: %v", lastErr) +} + +// TestServiceDirectoriesAreOwnerOnly checks that the state directory and the +// config directory of an instance are created owner-only. +// +// They hold the job list, the download history, the mirror targets and the +// credentials of the instance, so a directory that is missing at the start +// of an instance must not be recreated world-readable. +func TestServiceDirectoriesAreOwnerOnly(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows mode bits are emulated and do not represent DACL permissions") + } + + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + runtimepaths.Install(model) + t.Cleanup(runtimepaths.Reset) + + if err := server.SaveConfigFile(&server.ConfigFile{CacheDir: "/from/config/file"}); err != nil { + t.Fatalf("SaveConfigFile() = %v, want nil", err) + } + if err := server.SaveJobsState(nil); err != nil { + t.Fatalf("SaveJobsState() = %v, want nil", err) + } + if err := server.AppendHistory(&server.Job{ID: "job", Status: server.JobStatusCompleted}); err != nil { + t.Fatalf("AppendHistory() = %v, want nil", err) + } + + for _, dir := range []string{filepath.Dir(model.ConfigFile), model.StateDir} { + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat %s: %v", dir, err) + } + if got := info.Mode().Perm(); got != 0o700 { + t.Errorf("the mode of %s = %04o, want 0700", dir, got) + } + } +} + +func TestBuildConfigKeepsIPv6HostsBracketed(t *testing.T) { + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + runtimepaths.Install(model) + t.Cleanup(runtimepaths.Reset) + + // The server composes the bind address as ":": an IPv6 host + // must keep its brackets to stay a valid socket address. + cfg, err := buildConfig(RunOptions{Paths: model}, "::1", 8080) + if err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + if cfg.Addr != "[::1]" { + t.Errorf("Addr = %q, want %q", cfg.Addr, "[::1]") + } +} + +func TestBuildConfigRejectsARelativeStateDir(t *testing.T) { + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(t.TempDir(), "etc", "hfdesk.json"), + StateDir: "var", + LogDir: filepath.Join(t.TempDir(), "logs"), + } + if _, err := buildConfig(RunOptions{Paths: model}, "127.0.0.1", 8080); err == nil { + t.Error("buildConfig() = nil, want an error for a relative state dir") + } +} + +func TestBuildConfigTakesTheTokenFromTheConfigFile(t *testing.T) { + root := t.TempDir() + configFile := filepath.Join(root, "etc", "hfdesk.json") + if err := os.MkdirAll(filepath.Dir(configFile), 0o700); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(map[string]string{"token": "hf_file_token"}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configFile, data, 0o600); err != nil { + t.Fatal(err) + } + + model := runtimepaths.Paths{ + ConfigFile: configFile, + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + runtimepaths.Install(model) + t.Cleanup(runtimepaths.Reset) + + // The environment must not contribute a credential: the token of the + // service-owned config file is the only source. + t.Setenv("HF_TOKEN", "hf_environment_token") + cfg, err := buildConfig(RunOptions{Paths: model}, "127.0.0.1", 8080) + if err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + if cfg.Token != "hf_file_token" { + t.Errorf("Token = %q, want the token of the config file", cfg.Token) + } + if got := os.Getenv("HF_TOKEN"); got != "" { + t.Errorf("HF_TOKEN = %q, want it dropped from the service environment", got) + } +} + +// TestServiceBindsLoopbackWhileTheInteractiveServerKeepsItsDefault checks +// the contrast the service mode is built on. +// +// The interactive server keeps its own default: it binds every interface, so +// the dashboard of an interactive run is reachable. A service instance binds +// loopback instead: a supervised instance has no user sitting in front of it +// and must not expose the dashboard to the network by accident. +func TestServiceBindsLoopbackWhileTheInteractiveServerKeepsItsDefault(t *testing.T) { + // The interactive default is intentionally unchanged by the service + // mode: an interactive run of the server still binds 0.0.0.0. + if got := server.DefaultConfig().Addr; got != "0.0.0.0" { + t.Errorf("server.DefaultConfig().Addr = %q, want %q", got, "0.0.0.0") + } + + // The default listen address of a service instance is loopback. + host, port, err := ParseListen(DefaultListen) + if err != nil { + t.Fatalf("ParseListen(%q) = %v, want nil", DefaultListen, err) + } + if host != "127.0.0.1" { + t.Errorf("the host of the default listen address %q = %q, want %q", DefaultListen, host, "127.0.0.1") + } + if !IsLoopbackListen(DefaultListen) { + t.Errorf("the default listen address %q is not a loopback address", DefaultListen) + } + + root := t.TempDir() + model := runtimepaths.Paths{ + ConfigFile: filepath.Join(root, "etc", "hfdesk.json"), + StateDir: filepath.Join(root, "var"), + LogDir: filepath.Join(root, "var", "logs"), + } + runtimepaths.Install(model) + t.Cleanup(runtimepaths.Reset) + + // The configuration a service instance builds binds loopback, even + // though the interactive default of the server binds every interface. + cfg, err := buildConfig(RunOptions{Paths: model}, host, port) + if err != nil { + t.Fatalf("buildConfig() = %v, want nil", err) + } + if cfg.Addr != "127.0.0.1" { + t.Errorf("the address of the service configuration = %q, want %q", cfg.Addr, "127.0.0.1") + } + if cfg.Port != port { + t.Errorf("the port of the service configuration = %d, want %d", cfg.Port, port) + } + + // The registered argument vector carries the loopback address, so the + // instance the supervisor starts binds loopback as well. + if got := listenArg(""); got != DefaultListen { + t.Errorf("listenArg(\"\") = %q, want the default listen address %q", got, DefaultListen) + } + args := RunArgs(InstallOptions{Paths: model}) + if got := argumentValue(args, "--listen"); got != DefaultListen { + t.Errorf("the listen address of the argument vector = %q, want %q", got, DefaultListen) + } +} + +// argumentValue returns the value of a flag of an argument vector. +func argumentValue(args []string, flag string) string { + for i := 0; i+1 < len(args); i++ { + if args[i] == flag { + return args[i+1] + } + } + return "" +} + +// guiLaunchMarkers are the markers of a browser or a tray launcher. No file +// of the service package may contain one of them: a supervised instance has +// no desktop session to interact with. +var guiLaunchMarkers = []string{"xdg-open", "rundll32", "systray", "url.dll,FileProtocolHandler"} + +// processLaunchMarkers are the markers of a process launch. +var processLaunchMarkers = []string{`"os/exec"`, "exec.Command"} + +// The package has two file classes and they are treated differently. +// +// The shared, platform-neutral files (host.go, service.go, runargs.go, +// process.go, paths_*.go) run on every operating system. They own the +// command semantics of the service and not the machine, so they must not +// launch a process at all. +// +// The platform files (manager_*.go, process_*.go) own the supervisor of the +// platform and have to run its command line tool ("systemctl", "launchctl"), +// so a process launch is legitimate there. It is still not a browser and not +// a tray icon. +// +// Neither class may launch a browser or a tray icon. +// +// The class comes from the file name, so a new platform file is covered +// without changing this test. +func TestServiceLayerNeverLaunchesAGUI(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + data, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + src := stripComments(string(data)) + if !isPlatformFile(name) { + for _, marker := range processLaunchMarkers { + if strings.Contains(src, marker) { + t.Errorf("%s contains %q: the shared service layer must not launch a process", name, marker) + } + } + } + for _, marker := range guiLaunchMarkers { + if strings.Contains(src, marker) { + t.Errorf("%s contains %q: the service layer must not launch a browser or a tray icon", name, marker) + } + } + } +} + +// isPlatformFile reports whether the file is a platform-specific file of the +// package. A platform file owns the supervisor of the platform and may run +// its command line tool. +func isPlatformFile(name string) bool { + return strings.HasPrefix(name, "manager_") || strings.HasPrefix(name, "process_") +} + +// runService starts the headless host with an already cancelled context and +// returns its error. A cancelled context is the normal shutdown path of a +// supervised instance, so the host must return without an error. +func runService(t *testing.T, opts RunOptions) error { + t.Helper() + return runServiceWith(t, opts, Run) +} + +// runServiceWith starts the given entry point with an already cancelled +// context and returns its error. The entry point is the function under test, +// so the same shutdown path is exercised for every entry point of the +// package. +func runServiceWith(t *testing.T, opts RunOptions, run func(context.Context, RunOptions) error) error { + t.Helper() + var err error + for attempt := 0; attempt < 3; attempt++ { + opts.Listen = "127.0.0.1:" + strconv.Itoa(freePort(t)) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err = run(ctx, opts) + if err == nil || !strings.Contains(err.Error(), "address already in use") { + return err + } + } + return err +} + +// freePort returns a port that was free a moment ago. +func freePort(t *testing.T) int { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve a port: %v", err) + } + defer listener.Close() + return listener.Addr().(*net.TCPAddr).Port +} + +// stripComments removes the line comments of a Go source so a doc comment +// does not trip a source marker check. +func stripComments(src string) string { + var out strings.Builder + for _, line := range strings.Split(src, "\n") { + if i := strings.Index(line, "//"); i >= 0 { + line = line[:i] + } + out.WriteString(line) + out.WriteString("\n") + } + return out.String() +} diff --git a/internal/service/manager_darwin.go b/internal/service/manager_darwin.go new file mode 100644 index 00000000..3696348b --- /dev/null +++ b/internal/service/manager_darwin.go @@ -0,0 +1,2222 @@ +// Copyright 2025 +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin + +package service + +import ( + "bytes" + "context" + "encoding/xml" + "errors" + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + "strings" + "syscall" + "text/template" + "time" + + "github.com/bashrusakh/hfdesk/internal/runtimepaths" +) + +// The macOS service mode of HFDesk is a launchd registration. +// +// launchd owns the lifecycle of the instance: the registered program stays +// in the foreground, launchd starts it at load time and restarts it after a +// failure. HFDesk never forks, never daemonizes itself, never writes a PID +// file and never runs the program through a shell: the property list holds +// the absolute path of the executable and the canonical argument vector of +// the service host, nothing else. +// +// A user scope instance is a LaunchAgent: it belongs to the user who +// installed it and it lives in the gui domain of that user. A system scope +// instance is a LaunchDaemon: it belongs to the machine and it lives in the +// system domain. The machine-wide instance never reuses the state of a user: +// its home is the machine-level state root of the platform and its paths are +// the machine-level paths. +// +// A machine-wide instance runs as a dedicated service account, not as root: +// the account is a stable, unprivileged identity that owns the three +// directories of the instance (the config directory, the state directory and +// the log directory) and nothing else. The installation never creates the +// account: it validates the account and refuses the installation with the +// commands that create it when the account does not exist. +// +// The machine-wide scope needs an elevated shell: the property list of a +// LaunchDaemon is written into /Library/LaunchDaemons and the job is loaded +// into the system domain, and both belong to the machine. The manager never +// escalates privileges itself: it fails with a clear error when the process +// is not root. The user scope needs no elevation: a LaunchAgent belongs to +// the user who installs it. +// +// The label of the registration is com.hfdesk.hfdesk. It is derived from the +// service name ("hfdesk") and the reverse DNS prefix of the project, and it +// is also the name of the property list file: launchd refuses to load a +// property list whose file name does not match its label. +const ( + // launchdLabelPrefix is the reverse DNS prefix of the launchd label. + launchdLabelPrefix = "com.hfdesk." + + // launchAgentsDir is the directory of the per-user LaunchAgents, + // relative to the home directory of the user. + launchAgentsDir = "Library/LaunchAgents" + + // launchDaemonsDir is the directory of the machine-wide LaunchDaemons. + launchDaemonsDir = "/Library/LaunchDaemons" + + // systemHome is the home directory of a machine-wide instance. It is + // the machine-level state root of the platform: it is never the home + // directory of the user who runs the install command and never the + // personal home of root (/var/root). + systemHome = "/var/lib/hfdesk" + + // plistMode is the permission mode of the property list. launchd + // reads the file as the user of the domain, so it has to be readable + // by that user. + plistMode os.FileMode = 0o644 + + // plistDirMode is the permission mode of the directory that holds the + // property list. + plistDirMode os.FileMode = 0o755 +) + +// stopWaitTimeout is the time the manager waits for the graceful shutdown of +// the instance before it unloads the job. +// +// The wait covers the whole orderly shutdown of the server: the drain of its +// HTTP server and the close of its job manager. It is the shared budget of +// the service layer, so the manager never kills an instance that is still +// saving its state. +const stopWaitTimeout = ShutdownBudget + +// stopPollInterval is the interval between two checks of the state of the +// job. +const stopPollInterval = 100 * time.Millisecond + +// launchdManager manages a HFDesk instance with launchd. +type launchdManager struct { + // scope is the installation scope of the instance. + scope Scope + // uid is the numeric user id of the invoking user. It is the target + // of the gui domain of a user scope instance. + uid string + // home is the home directory of the invoking user. It holds the + // property list of a user scope instance and it is the reference that + // keeps a machine-wide instance away from the state of a user. + home string + // run runs the commands of the supervisor. It is nil in production, + // where launchctl is called directly, and a test sets it to a + // recorder: a lifecycle can then be driven without a supervisor and + // the commands the manager issues can be asserted. + run func(ctx context.Context, args ...string) (string, error) + // stopBudget is the time the manager waits for the graceful shutdown + // of the instance. It is zero in production, where the wait uses the + // shared budget of the service layer, and a test shortens it to drive + // the timeout of the wait. + stopBudget time.Duration +} + +// newPlatformManager returns the launchd manager of the given scope. +func newPlatformManager(scope Scope) (Manager, error) { + switch scope { + case ScopeUser, ScopeSystem: + default: + return nil, fmt.Errorf("service: unknown scope %q (want %q or %q)", scope, ScopeUser, ScopeSystem) + } + + manager := &launchdManager{ + scope: scope, + uid: strconv.Itoa(os.Getuid()), + } + // The home directory of the invoking user is the base of the property + // list of a user scope instance and the reference that keeps a + // machine-wide instance away from the state of a user. A machine-wide + // installation does not need it, so a missing home directory is not an + // error there. + home, err := os.UserHomeDir() + if err == nil { + manager.home = home + } else if scope == ScopeUser { + return nil, fmt.Errorf("service: resolve the home directory of the user scope instance: %w", err) + } + return manager, nil +} + +// launchdLabel returns the launchd label of the instance. +func launchdLabel() string { + return launchdLabelPrefix + ServiceName +} + +// plistPath returns the path of the property list of the instance. +func (m *launchdManager) plistPath() string { + if m.scope == ScopeSystem { + return filepath.Join(launchDaemonsDir, launchdLabel()+".plist") + } + return filepath.Join(m.home, launchAgentsDir, launchdLabel()+".plist") +} + +// domain returns the launchd domain the instance is registered in. +// +// A LaunchAgent belongs to the gui domain of its user, a LaunchDaemon to the +// system domain. The gui domain of the invoking user is used even when the +// installation options carry another user id: registering a job into the +// domain of another user needs root and is not a per-user installation. +func (m *launchdManager) domain() string { + if m.scope == ScopeSystem { + return "system" + } + return "gui/" + m.uid +} + +// target returns the launchd target of the instance. +func (m *launchdManager) target() string { + return m.domain() + "/" + launchdLabel() +} + +// Install registers the instance with launchd. +// +// The property list is rendered, written atomically and loaded into the +// domain of the instance. An installation that fails after the property list +// was written restores the previous registration, so a failed installation +// never leaves a half configured registration behind. A failure removes the +// directories the installation created as well, so a failed attempt leaves +// the machine as it was. +// +// A machine-wide instance runs as a service account. The account is +// resolved and validated before any directory is created and before the +// property list is written, so a refused installation leaves nothing behind +// and every shell reports the same reason. +func (m *launchdManager) Install(ctx context.Context, opts InstallOptions) error { + if err := ValidateInstallOptions(opts); err != nil { + return err + } + // A machine-wide instance must not adopt the state of the user who + // runs the install command. The check runs before the elevation + // requirement, before any directory is created and before the property + // list is written: a refused installation leaves nothing behind, and + // every platform reports the same reason for the same command line. + if err := m.checkSystemPaths(opts); err != nil { + return err + } + // The service account of a machine-wide instance is resolved before + // anything is created: an account that does not exist is a refusal with + // the commands that create it, not a property list that points at an + // identity the machine does not know. + account, err := m.resolveAccount(opts) + if err != nil { + return err + } + // A directory the user named outside of the state directory of the + // instance has to be writable by the account. The check runs before the + // directories of the instance are created, so a refused installation + // leaves nothing behind. + if err := m.checkNamedDirs(opts, account); err != nil { + return err + } + // The elevation requirement is checked before any directory is created: + // a permission error of the machine-wide directories is a much less + // readable answer than the real reason. + if err := m.requireElevated("an installation"); err != nil { + return err + } + // The directories the installation is about to create are remembered + // before they are created, so the account is given the ownership of + // exactly those and of nothing else. + // + // The list is also what a failed installation removes again, so a + // failed installation leaves the machine as it was. + created := make(map[string][]string, 3) + for _, dir := range instanceDirs(opts) { + created[dir.path] = createdDirs(dir.path) + } + if err := PrepareDirs(opts); err != nil { + // The preparation may have created some of the directories + // before it failed: they are removed again, so a failed attempt + // leaves the machine as it was. + removeCreatedDirs(createdDirOrder(opts, created)) + return err + } + // The installation owns the directories it created from here on: a + // failure removes them again, so a failed installation never leaves + // the directories of the instance behind. + if err := m.register(ctx, opts, account, created); err != nil { + removeCreatedDirs(createdDirOrder(opts, created)) + return err + } + return nil +} + +// register writes the registration of the instance and hands it to the +// supervisor. +// +// The directories of the instance are already created when the function is +// called. A failure of the registration is rolled back: the property list is +// restored and the directories the installation created are removed by the +// caller, so a failed installation leaves no half configured registration and +// no empty directories behind. +func (m *launchdManager) register(ctx context.Context, opts InstallOptions, account *serviceAccount, created map[string][]string) error { + // The instance runs as the account, so the account owns the directories + // the instance writes. + if err := m.grantInstanceDirs(opts, account, created); err != nil { + return err + } + if err := m.preflight(opts); err != nil { + return err + } + + content, err := renderPlist(opts, account) + if err != nil { + return err + } + + path := m.plistPath() + backup, err := backupPlist(path) + if err != nil { + return err + } + changed := !backup.existed || !bytes.Equal(backup.content, []byte(content)) + if err := writePlist(path, []byte(content), plistMode); err != nil { + return err + } + + state, err := m.load(ctx, path, changed) + if err != nil { + return m.rollback(ctx, backup, state, err) + } + return nil +} + +// Uninstall removes the registration of the instance. +// +// Only the registration is removed. The config file, the jobs, the history, +// the logs, the models and the cache of the instance are left alone: an +// uninstall removes the registration of the service, not its data. +// +// A machine-wide uninstallation needs an elevated shell: the LaunchDaemon is +// registered in /Library/LaunchDaemons and it is unloaded from the system +// domain, and both belong to the machine. The check runs after the existence +// of the registration, so a registration that is not there stays a clean +// success in every shell. A user scope instance belongs to the user who +// installed it and needs no elevation. +func (m *launchdManager) Uninstall(ctx context.Context) error { + path := m.plistPath() + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + // There is no registration: the uninstall is already done. + return nil + } + return fmt.Errorf("service: read the registration %q: %w", path, err) + } + + // The elevation requirement is checked before the job is unloaded and + // before the property list is removed: a permission error of the + // machine is a much less readable answer than the real reason. + if err := m.requireElevated("an uninstallation"); err != nil { + return err + } + + // The job is unloaded before the property list is removed, so launchd + // never keeps a registration whose file is gone. A job that is not + // loaded any more is not an error: the registration is removed either + // way. + if _, err := m.launchctl(ctx, "bootout", m.target()); err != nil && !isServiceMissing(err) { + return fmt.Errorf("service: bootout %s: %w", m.target(), err) + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("service: remove the registration %q: %w", path, err) + } + return nil +} + +// Start starts the registered instance. +// +// The job is started with "launchctl kickstart", which starts a job that is +// not running and leaves a running job alone: starting a running instance +// is a stable no-op. A job that was unloaded by a stop is registered again +// from the property list that is on disk, so a start after a stop works +// without a reinstall. +// +// A machine-wide instance needs an elevated shell: the LaunchDaemon is +// registered in /Library/LaunchDaemons and it is started by the system +// domain, and both belong to the machine. The check runs after the +// existence of the registration, so a registration that is not there is +// reported as such in every shell. A user scope instance belongs to the +// user who installs it and needs no elevation. +func (m *launchdManager) Start(ctx context.Context) error { + path := m.plistPath() + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("service: %s is not installed", m.target()) + } + return fmt.Errorf("service: read the registration %q: %w", path, err) + } + // The elevation requirement is checked before the job is started: a + // permission error of the machine is a much less readable answer than + // the real reason. + if err := m.requireElevated("a start"); err != nil { + return err + } + if err := m.ensureLoaded(ctx, path); err != nil { + return err + } + if _, err := m.launchctl(ctx, "kickstart", m.target()); err != nil { + return fmt.Errorf("service: kickstart %s: %w", m.target(), err) + } + return nil +} + +// Stop stops the registered instance. +// +// The graceful shutdown is driven by HFDesk itself: SIGTERM lets the server +// quiesce, drain its connections and close the job manager before the +// process goes away. The job is unloaded afterwards, because a process that +// was killed by a signal exits with a non-zero status and launchd would +// restart it under the KeepAlive policy of the registration. The unload +// makes the stop stable and leaves the property list in place, so a later +// start brings the instance back. +// +// Stopping an instance that does not run is a stable no-op: the state the +// caller asked for already holds. +// +// An instance that did not finish its shutdown within the budget of the +// service layer is reported: the stop unloads the job anyway, but a caller +// never sees a plain success for a shutdown that did not complete. +// +// A machine-wide instance needs an elevated shell: the LaunchDaemon is +// unloaded from the system domain, and the system domain belongs to the +// machine. The check runs after the existence of the registration, so a +// registration that is not there stays a clean success in every shell. A +// user scope instance belongs to the user who installs it and needs no +// elevation. +func (m *launchdManager) Stop(ctx context.Context) error { + path := m.plistPath() + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + // The instance is not registered, so it does not run either. + return nil + } + return fmt.Errorf("service: read the registration %q: %w", path, err) + } + + // The elevation requirement is checked before the job is unloaded: a + // permission error of the machine is a much less readable answer than + // the real reason. + if err := m.requireElevated("a stop"); err != nil { + return err + } + + _, output, err := m.printState(ctx) + if err != nil { + return err + } + _, _, pid := launchdPrintFields(output) + + if _, err := m.launchctl(ctx, "kill", "SIGTERM", m.target()); err != nil && !isServiceMissing(err) { + return fmt.Errorf("service: kill SIGTERM %s: %w", m.target(), err) + } + // The wait reports the overrun of the budget: an instance that is + // still there when the budget is over did not shut down in an orderly + // way. + waitErr := m.waitForExit(ctx, pid) + // The job is unloaded even when the instance did not finish its + // shutdown: a process that is still there would be restarted by + // launchd under the KeepAlive policy of the registration. + if _, err := m.launchctl(ctx, "bootout", m.target()); err != nil && !isServiceMissing(err) { + if waitErr != nil { + return fmt.Errorf("%w (the registration could not be unloaded: %v)", waitErr, err) + } + return fmt.Errorf("service: bootout %s: %w", m.target(), err) + } + if waitErr != nil { + return fmt.Errorf("%w (the instance was unloaded anyway)", waitErr) + } + return nil +} + +// Restart restarts the registered instance. +// +// The restart is the graceful stop followed by the start: SIGTERM lets the +// server quiesce, drain its connections and close the job manager, and the +// job is unloaded before the replacement is registered and started. The +// replacement is never started while the old instance is still draining, so +// the two never compete for the bind address. +// +// The replacement is started only when the stop succeeded: an instance that +// did not shut down in an orderly way is not covered by a new one, and the +// failure is reported instead. +// +// A machine-wide instance needs an elevated shell: the LaunchDaemon is +// registered in /Library/LaunchDaemons and it is started by the system +// domain, and both belong to the machine. The check runs after the +// existence of the registration, so a registration that is not there is +// reported as such in every shell. A user scope instance belongs to the +// user who installs it and needs no elevation. +func (m *launchdManager) Restart(ctx context.Context) error { + path := m.plistPath() + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("service: %s is not installed", m.target()) + } + return fmt.Errorf("service: read the registration %q: %w", path, err) + } + // The elevation requirement is checked before the job is stopped and + // before the job is started: a permission error of the machine is a + // much less readable answer than the real reason. + if err := m.requireElevated("a restart"); err != nil { + return err + } + // The instance is stopped the way a stop stops it: the graceful + // shutdown of the server, the bounded wait for it and the unload of + // the job. The replacement is started only when that succeeded. + if err := m.Stop(ctx); err != nil { + return fmt.Errorf("service: restart %s: %w", m.target(), err) + } + if err := m.Start(ctx); err != nil { + return fmt.Errorf("service: restart %s: %w", m.target(), err) + } + return nil +} + +// Status reports the state of the registered instance. +// +// The description of the instance comes from the property list that is on +// disk, so the status describes the registration and not the intent of the +// caller. The state comes from launchd itself. +func (m *launchdManager) Status(ctx context.Context) (Status, error) { + path := m.plistPath() + status := Status{ + State: StateNotInstalled, + Scope: m.scope, + Name: launchdLabel(), + UnitPath: path, + } + + content, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + // There is no registration at all. + status.Detail = fmt.Sprintf("no launchd registration at %s", path) + return status, nil + } + return status, fmt.Errorf("service: read the registration %q: %w", path, err) + } + registration, err := parsePlist(content) + if err != nil { + return status, fmt.Errorf("service: read the registration %q: %w", path, err) + } + status.Executable = registration.executable() + status.Listen = registration.listen() + status.Paths = registration.paths() + // The cache root of the instance is the cache directory of the + // instance's own config file when it defines one, and the cache + // directory of the registration otherwise. Both come from the + // registration on disk, so the status describes the instance and not + // the intent of the caller. + status.CacheDir = EffectiveCacheDir(status.Paths.ConfigFile, registration.cacheDir()) + + loaded, output, err := m.printState(ctx) + if err != nil { + return status, err + } + if !loaded { + // The registration is on disk but the job is not loaded into its + // domain: the instance is registered and does not run. + status.State = StateStopped + status.Detail = fmt.Sprintf("%s is not loaded into %s", launchdLabel(), m.domain()) + } else { + status.State, status.Detail = launchdStatusState(m.target(), output) + } + + // A registration that points at a program that is gone is broken: the + // instance can never run again until it is installed again. + if status.Executable != "" { + if _, err := os.Stat(status.Executable); err != nil { + status.State = StateBroken + status.Detail = fmt.Sprintf("the registered executable %s does not exist", status.Executable) + } + } + return status, nil +} + +// preflight checks the conditions an installation needs before it touches +// the system. +func (m *launchdManager) preflight(opts InstallOptions) error { + if _, err := exec.LookPath("launchctl"); err != nil { + return fmt.Errorf("service: launchctl is not available: %w", err) + } + if _, _, err := ParseListen(opts.Listen); err != nil { + return err + } + info, err := os.Stat(opts.Executable) + if err != nil { + return fmt.Errorf("service: executable %q is not usable: %w", opts.Executable, err) + } + if info.Mode().Perm()&0o111 == 0 { + return fmt.Errorf("service: executable %q is not executable", opts.Executable) + } + return nil +} + +// requireElevated rejects a machine-wide operation that needs an elevated +// shell when the process does not have one. +// +// A LaunchDaemon is registered in /Library/LaunchDaemons and started by the +// system domain: both belong to the machine and need root. The manager never +// escalates privileges itself: it never calls sudo, su or osascript and it +// never asks the user for a password, so it refuses the operation with a +// readable reason instead of letting it fail with a permission error of a +// directory of the machine. +func (m *launchdManager) requireElevated(action string) error { + return m.requireElevatedFor(action, os.Geteuid()) +} + +// requireElevatedFor rejects a machine-wide operation when the given user id +// is not the one of an elevated shell. +// +// The user id is a parameter, so the decision can be driven from both sides +// without an elevated shell: the real check passes the effective user id of +// the process, and the test passes the ids of the two cases. +// +// A user scope instance belongs to the user who installs it: it is written +// into the LaunchAgents directory of that user and it is loaded into the gui +// domain of that user, so it never needs an elevated shell. +func (m *launchdManager) requireElevatedFor(action string, uid int) error { + if m.scope != ScopeSystem { + return nil + } + if uid == 0 { + return nil + } + return fmt.Errorf("service: %s in the system scope requires an elevated shell: the LaunchDaemon is registered in %s and started by the system domain, HFDesk never escalates privileges itself", action, launchDaemonsDir) +} + +// checkSystemPaths rejects a machine-wide installation that would reuse the +// state of a user. +// +// A LaunchDaemon runs as the service account of the machine. A state +// directory inside the home directory of the invoking user would either +// expose the state of the user to the machine or take the state of the user +// away from it, so the installation is refused instead of silently taking +// the state over. The model and the cache trees are never touched by an +// installation: only the three directories of the instance (the config +// directory, the state directory and the log directory) are created, and +// only those are granted to the service account. +// +// A user scope instance is supposed to live inside the directories of its own +// user, so the guard does not touch it. +func (m *launchdManager) checkSystemPaths(opts InstallOptions) error { + if m.scope != ScopeSystem { + return nil + } + home := m.home + if home == "" { + // The home directory of the invoking user is unknown: there is + // nothing to compare the paths of the installation with, and an + // unknown home is not a failed installation. + return nil + } + for _, p := range []struct { + name string + path string + }{ + {"config file", opts.Paths.ConfigFile}, + {"state dir", opts.Paths.StateDir}, + {"log dir", opts.Paths.LogDir}, + {"cache dir", opts.CacheDir}, + {"hub dir", opts.HubDir}, + {"local dir", opts.LocalDir}, + } { + if p.path == "" { + continue + } + if pathWithin(p.path, home) { + return fmt.Errorf("service: the %s %q of a machine-wide instance must not live inside the home directory %q of the invoking user", p.name, p.path, home) + } + } + return nil +} + +// serviceAccount is the identity a machine-wide instance runs as. +// +// The account is a dedicated, stable and unprivileged identity: it owns the +// three directories of the instance and nothing else, so the files it writes +// stay writable across reboots and the instance needs no privilege of root. +type serviceAccount struct { + // Name is the name of the account. + Name string + // UID is the numeric id of the account. + UID int + // GID is the numeric id of the primary group of the account. + GID int + // Group is the name of the primary group of the account. + Group string + // Groups holds the numeric ids of the groups the account belongs to. + Groups []int +} + +// serviceAccountName returns the name of the account a machine-wide instance +// runs as. +// +// The name of the options wins: an installation may name another account. An +// empty name means the default account of the platform. +func serviceAccountName(opts InstallOptions) string { + if name := strings.TrimSpace(opts.ServiceUser); name != "" { + return name + } + return DefaultServiceUser() +} + +// resolveAccount returns the account a machine-wide instance runs as. +// +// A user scope instance runs as the user who installs it: it needs no +// service account, and the returned account is nil. +func (m *launchdManager) resolveAccount(opts InstallOptions) (*serviceAccount, error) { + if m.scope != ScopeSystem { + return nil, nil + } + return resolveServiceAccount(opts) +} + +// resolveServiceAccount resolves the account of a machine-wide instance. +// +// The account is looked up in the user database of the machine: on macOS the +// lookup goes through the directory service, so an account that was created +// with "dscl" is found. +// +// The lookup does not depend on cgo: the darwin build of os/user calls the +// getpwnam_r of libSystem, which asks the directory service, and the reader +// of /etc/passwd is only compiled when the osusergo build tag is set +// explicitly. +// +// The installation never creates an account: an account that does not exist +// is refused with the commands that create it. +func resolveServiceAccount(opts InstallOptions) (*serviceAccount, error) { + name := serviceAccountName(opts) + entry, err := user.Lookup(name) + if err != nil { + return nil, missingServiceAccountError(name) + } + uid, err := unixID(entry.Uid) + if err != nil { + return nil, fmt.Errorf("service: the service account %q has the unreadable user id %q", name, entry.Uid) + } + gid, err := unixID(entry.Gid) + if err != nil { + return nil, fmt.Errorf("service: the service account %q has the unreadable group id %q", name, entry.Gid) + } + group, err := user.LookupGroupId(entry.Gid) + if err != nil { + return nil, fmt.Errorf("service: the primary group %q of the service account %q cannot be resolved: %w (give the account a primary group and install again)", entry.Gid, name, err) + } + + account := &serviceAccount{ + Name: entry.Username, + UID: uid, + GID: gid, + Group: group.Name, + // The primary group belongs to the account as well, so it is + // part of the groups the account may own a directory through. + Groups: []int{gid}, + } + if ids, err := entry.GroupIds(); err == nil { + for _, id := range ids { + if gid, err := unixID(id); err == nil { + account.Groups = append(account.Groups, gid) + } + } + } + return account, nil +} + +// missingServiceAccountError reports the refusal of an installation whose +// service account does not exist. +// +// The installation never creates an account: it names the account and the +// commands that create it, so the administrator can run them and install +// again. +func missingServiceAccountError(name string) error { + return fmt.Errorf("service: the machine-wide instance needs the service account %q: create it with %q, %q and %q, then install again (HFDesk never creates accounts itself)", + name, + fmt.Sprintf("dscl . -create /Users/%s UserShell /usr/bin/false", name), + fmt.Sprintf("dscl . -create /Users/%s UserHome %s", name, systemHome), + fmt.Sprintf("dscl . -create /Users/%s UniqueID ", name)) +} + +// unixID converts the numeric id of the user database into the unsigned form +// the file system uses. +// +// The user database of macOS knows the account "nobody" with the id -2, and +// the file system holds the very same id as the unsigned 4294967294. +func unixID(value string) (int, error) { + id, err := strconv.Atoi(value) + if err != nil { + return 0, err + } + if id < 0 { + id += 1 << 32 + } + return id, nil +} + +// instanceDirs returns the directories an instance owns: the directory of +// the config file, the state directory and the log directory. +// +// They are the only directories the installation creates, so they are the +// only directories the service account is given the ownership of. The model +// and the cache trees are never part of them. +func instanceDirs(opts InstallOptions) []instanceDir { + return []instanceDir{ + {name: "config directory", path: filepath.Dir(opts.Paths.ConfigFile)}, + {name: "state directory", path: opts.Paths.StateDir}, + {name: "log directory", path: opts.Paths.LogDir}, + } +} + +// instanceDir is one directory an instance owns. +type instanceDir struct { + // name is the name of the directory in an error message. + name string + // path is the absolute path of the directory. + path string +} + +// createdDirs returns the directories that do not exist yet on the way to +// the path, from the top down. +// +// The list holds the path itself when it is missing: it is the set of +// directories an installation creates for the path. The list is read before +// the directories are created, so the installation knows which of them it +// owns. +func createdDirs(path string) []string { + var missing []string + current := filepath.Clean(path) + for { + if _, err := os.Stat(current); err == nil { + break + } else if !os.IsNotExist(err) { + break + } + missing = append(missing, current) + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + // The list was collected from the bottom up, the directories are + // created from the top down. + for i, j := 0, len(missing)-1; i < j; i, j = i+1, j-1 { + missing[i], missing[j] = missing[j], missing[i] + } + return missing +} + +// createdDirOrder returns the directories an installation creates, in the +// order it creates them. +// +// The map holds the directories of every directory of the instance. The +// directories of the instance are created in the order of instanceDirs (the +// config directory, the state directory and the log directory) and from the +// top down inside each of them, so the list is the order of the creation. +// +// A directory that several directories of the instance need is listed once: +// the first directory of the instance that needed it is the one that created +// it. +func createdDirOrder(opts InstallOptions, created map[string][]string) []string { + var order []string + seen := make(map[string]bool, len(created)) + for _, dir := range instanceDirs(opts) { + for _, path := range created[dir.path] { + if seen[path] { + continue + } + seen[path] = true + order = append(order, path) + } + } + return order +} + +// removeCreatedDirs removes the directories an installation created. +// +// The directories are removed in the reverse order of their creation, so a +// parent is only removed after its children. A directory that is not empty is +// kept: the removal never deletes data and it never walks into a directory. +// +// The removal is best effort: a failure of the removal is ignored, so the +// original failure of the installation stays the error the caller gets. +func removeCreatedDirs(dirs []string) { + for i := len(dirs) - 1; i >= 0; i-- { + path := dirs[i] + info, err := os.Lstat(path) + if err != nil || !info.IsDir() { + // The directory is gone already, or it is not a directory + // any more: nothing is removed, so no file is ever deleted. + continue + } + // A directory that holds something is kept: the removal of such + // a directory would delete what it holds. + _ = os.Remove(path) + } +} + +// machineDir reports whether the directory is a directory of the machine +// itself: a top level directory of the file system, like /etc or /usr. +// +// Such a directory belongs to the machine and to the software it holds, so +// it is never handed to the account of an instance. The instance needs a +// directory of its own below it. +// +// The rule is the rule of the Linux installation, so the same command line +// is answered the same way on both platforms. +func machineDir(path string) bool { + clean := filepath.Clean(path) + return filepath.Dir(clean) == string(filepath.Separator) +} + +// grantInstanceDirs grants the directories of the instance to the account. +// +// The directories the installation creates belong to the account: the +// instance writes its config file, its state and its logs there, and it runs +// as the account. The ownership is granted with a single chown per +// directory: the tree of a directory is never walked, and the model and the +// cache trees are never touched. +// +// A directory that existed before the installation belongs to whoever owns +// it, so it is not chowned. The account still has to be able to write there, +// and the installation refuses to go on when it cannot. +// +// A directory of the machine is refused: the directory of the config file is +// derived from the path of a file, so a config file in /etc would hand /etc +// itself to the account of the instance. +// +// Every directory is checked before the first of them is granted, so a +// refused installation leaves every ownership exactly as it was. +// +// The created map holds the directories the installation creates for every +// directory of the instance. It is read before the directories are created, +// so the installation knows which of them it owns. +func (m *launchdManager) grantInstanceDirs(opts InstallOptions, account *serviceAccount, created map[string][]string) error { + if account == nil { + // A user scope instance runs as the user who installs it: it owns + // its directories already. + return nil + } + dirs := instanceDirs(opts) + // A directory of the machine is refused before the first ownership is + // changed, so a refused installation leaves every ownership exactly as + // it was. + for _, dir := range dirs { + if machineDir(dir.path) { + return fmt.Errorf("service: the %s %q is a directory of the machine: the instance needs a directory of its own, name one below a directory of the machine", dir.name, dir.path) + } + } + // The directories are checked before the first of them is granted, so a + // refused installation leaves every ownership as it was. + for _, dir := range dirs { + if len(created[dir.path]) > 0 { + // The installation creates the directory: it owns it and + // grants it to the account below. + continue + } + // The directory existed before the installation: it belongs to its + // owner, so it is left alone. The account still has to be able to + // write there. + ok, err := accountCanWrite(dir.path, account) + if err != nil { + return fmt.Errorf("service: the %s %q of the machine-wide instance cannot be used: %w", dir.name, dir.path, err) + } + if !ok { + return fmt.Errorf("service: the %s %q of the machine-wide instance is not writable by the service account %q: grant it to the account (for example %q) and install again", + dir.name, dir.path, account.Name, "chown "+account.Name+" "+dir.path) + } + } + // Every directory passed the check: the directories the installation + // created belong to the account now. + for _, dir := range dirs { + for _, path := range created[dir.path] { + if err := os.Chown(path, account.UID, account.GID); err != nil { + return fmt.Errorf("service: grant the %s %q to the service account %q: %w", dir.name, path, account.Name, err) + } + } + } + return nil +} + +// checkNamedDirs verifies that the service account can write to the +// directories the user named explicitly. +// +// The defaults of the scope live inside the state directory of the instance, +// and the state directory belongs to the account, so they need no check. A +// directory the user named is outside of the instance: the installation +// verifies that the account can write there and refuses the installation +// with the commands that grant the access when it cannot. +// +// The exemption for a directory inside the state directory holds only while +// the directory does not exist yet: the installation grants the state +// directory to the account, and the service creates the directory itself +// below it. A directory that exists already inside the state directory +// belongs to whoever owns it, so it is checked like every other named +// directory. +// +// The exemption is decided on the real location of the two paths: a link +// inside the state directory that points out of it leaves the state +// directory, and the account has to reach the target of the link on its own. +// +// The installation never changes the ownership or the permissions of such a +// tree: a model or a cache tree belongs to whoever put it there. +// +// The refusal names the directory, the account and the access that is +// missing. It never recommends a recursive ownership change of such a tree: +// the access is granted to the directory itself or through the group that +// owns it, and for a tree that holds a lot of data the group is preferable +// to a new owner. +func (m *launchdManager) checkNamedDirs(opts InstallOptions, account *serviceAccount) error { + if account == nil { + // A user scope instance runs as the user who installs it: it can + // write into its own directories. + return nil + } + for _, dir := range []struct { + name string + path string + }{ + {"cache directory", opts.CacheDir}, + {"hub directory", opts.HubDir}, + {"local directory", opts.LocalDir}, + } { + if dir.path == "" { + continue + } + if pathMissingInsideRealDir(opts.Paths.StateDir, dir.path) { + // The default of the scope lives inside the state directory + // of the instance, and the state directory belongs to the + // account. The directory does not exist yet, so the service + // creates it below the state directory, and the account owns + // the state directory after the installation. + // + // The decision is made on the real location of the two + // paths: a link inside the state directory that points out + // of it leaves the state directory, and the account has to + // reach the target of the link on its own. + continue + } + ok, err := accountCanWrite(dir.path, account) + if err != nil { + return fmt.Errorf("service: the %s %q of the machine-wide instance cannot be used: %w", dir.name, dir.path, err) + } + if !ok { + return fmt.Errorf("service: the service account %q has no write access to the %s %q: grant it access to that directory (for example %q) or make it a member of the group that owns the directory; for a tree that holds a lot of data, granting group access is preferable to changing the owner, and install again", + account.Name, dir.name, dir.path, "chown "+account.Name+" "+dir.path) + } + } + return nil +} + +// accountCanWrite reports whether the account can write to the directory. +// +// The check is the effective one: the account has to create entries in the +// directory and to reach it. A directory the account owns with the mode 0500 +// is not writable at all, because the owner has no write permission in it, +// and a directory below a parent without the execute permission cannot be +// reached at all. +// +// A directory that does not exist yet is checked through its nearest +// existing ancestor: the account has to be able to create it there. +func accountCanWrite(path string, account *serviceAccount) (bool, error) { + return accountCanUseDir(path, accessAccount{ + UID: account.UID, + GID: account.GID, + Groups: account.Groups, + }) +} + +// load registers the property list with launchd and reports what it did to +// the registration of the instance. +// +// A job that is not loaded yet is bootstrapped. A job that is already loaded +// keeps its registration: an installation that does not change the property +// list must not restart the running instance. An installation that does +// change the property list loads the job again, so the new configuration +// takes effect instead of waiting for the next boot of the machine. +// +// The report tells a rollback what the attempt did to the supervisor: the +// state the registration had before it, whether the attempt loaded the job +// and whether it took over the registration of a job that was loaded +// already. +func (m *launchdManager) load(ctx context.Context, path string, changed bool) (launchdLoadState, error) { + previous, err := m.supervisorState(ctx) + if err != nil { + return launchdLoadState{}, err + } + state := launchdLoadState{previous: previous} + + switch { + case !previous.loaded: + if _, err := m.launchctl(ctx, "bootstrap", m.domain(), path); err != nil { + return state, fmt.Errorf("service: bootstrap %s: %w", m.target(), err) + } + state.bootstrapped = true + case changed: + if _, err := m.launchctl(ctx, "bootout", m.target()); err != nil && !isServiceMissing(err) { + return state, fmt.Errorf("service: bootout %s: %w", m.target(), err) + } + // The job is unloaded now: it holds no registration of its + // own, so the restored file has to be loaded again by a + // rollback. + state.replaced = true + if _, err := m.launchctl(ctx, "bootstrap", m.domain(), path); err != nil { + return state, fmt.Errorf("service: bootstrap %s: %w", m.target(), err) + } + state.bootstrapped = true + } + + // A job that was disabled in the override database of launchd would + // never start, so the disabled flag is cleared explicitly. The job is + // loaded at this point, so the command can address it. + if _, err := m.launchctl(ctx, "enable", m.target()); err != nil { + return state, fmt.Errorf("service: enable %s: %w", m.target(), err) + } + return state, nil +} + +// launchdLoadState is what an installation did to the registration of the +// instance in the supervisor. +type launchdLoadState struct { + // previous is the state the registration had before the attempt. + previous launchdSupervisorState + // bootstrapped reports whether the attempt loaded the job into its + // domain. + bootstrapped bool + // replaced reports whether the attempt took over the registration of + // a job that was loaded already: the job holds the registration of + // the attempt now, or it holds none at all. + replaced bool +} + +// launchdSupervisorState is the state of the registration of the instance in +// the supervisor. +type launchdSupervisorState struct { + // loaded reports whether the job is loaded into its domain. + loaded bool + // disabled reports whether the job is disabled in the override + // database of launchd. + disabled bool +} + +// supervisorState reports the state of the registration of the instance in +// the supervisor: whether the job is loaded into its domain and whether it +// is disabled in the override database of launchd. +func (m *launchdManager) supervisorState(ctx context.Context) (launchdSupervisorState, error) { + loaded, _, err := m.printState(ctx) + if err != nil { + return launchdSupervisorState{}, err + } + return launchdSupervisorState{ + loaded: loaded, + disabled: m.jobDisabled(ctx), + }, nil +} + +// jobDisabled reports whether the job of the instance is disabled in the +// override database of launchd. +// +// The database of the domain is asked, because that is where the disabled +// flag of a job lives. The question is best effort: a supervisor that cannot +// answer it is treated as one that knows no disabled job, so an installation +// never fails because of it. +func (m *launchdManager) jobDisabled(ctx context.Context) bool { + output, err := m.launchctl(ctx, "print-disabled", m.domain()) + if err != nil { + return false + } + return launchdDisabledLabel(output, launchdLabel()) +} + +// launchdDisabledLabel reports whether the output of "launchctl +// print-disabled" says that the job of the given label is disabled. +// +// The output of launchd is not an API: a disabled job is listed as +// "