diff --git a/.github/workflows/test-impl.yml b/.github/workflows/test-impl.yml index 83879024309..0a2d61a068f 100644 --- a/.github/workflows/test-impl.yml +++ b/.github/workflows/test-impl.yml @@ -157,6 +157,7 @@ jobs: - run: mise run test:e2e e2e/cli/test_dotfiles_history_policies - run: mise run test:e2e e2e/cli/test_dotfiles_rollback - run: mise run test:e2e e2e/cli/test_dotfiles_rollback_types + - run: mise run test:e2e e2e/cli/test_bootstrap_user_services lint: runs-on: ${{ !inputs.trusted && 'ubuntu-latest' || 'namespace-profile-endev-linux-amd64-large;overrides.cache-tag=cache' }} diff --git a/docs/.vitepress/cli_commands.ts b/docs/.vitepress/cli_commands.ts index a09664d2d02..52591d096ac 100644 --- a/docs/.vitepress/cli_commands.ts +++ b/docs/.vitepress/cli_commands.ts @@ -309,6 +309,9 @@ export const commands: { [key: string]: Command } = { apply: { hide: false, }, + remove: { + hide: false, + }, status: { hide: false, }, diff --git a/docs/bootstrap/launchd.md b/docs/bootstrap/launchd.md index a747db485a2..ba9a01dfcd8 100644 --- a/docs/bootstrap/launchd.md +++ b/docs/bootstrap/launchd.md @@ -33,21 +33,22 @@ as pipes and redirections need an explicitly invoked shell or a wrapper script. ## Supported keys -| TOML key | launchd key | -| ------------------------- | ------------------------- | -| `program` | `ProgramArguments[0]` | -| `args` | `ProgramArguments[1..]` | -| `run_at_load` | `RunAtLoad` | -| `keep_alive` | `KeepAlive` | -| `start_interval` | `StartInterval` | -| `throttle_interval` | `ThrottleInterval` | -| `start_calendar_interval` | `StartCalendarInterval` | -| `queue_directories` | `QueueDirectories` | -| `environment` | `EnvironmentVariables` | -| `working_directory` | `WorkingDirectory` | -| `stdout_path` | `StandardOutPath` | -| `stderr_path` | `StandardErrorPath` | -| `kickstart` | run `launchctl kickstart` | +| TOML key | launchd key | +| ------------------------- | ---------------------------------------- | +| `program` | `ProgramArguments[0]` | +| `args` | `ProgramArguments[1..]` | +| `run_at_load` | `RunAtLoad` | +| `keep_alive` | `KeepAlive` | +| `keep_alive_on_failure` | `KeepAlive = { SuccessfulExit = false }` | +| `start_interval` | `StartInterval` | +| `throttle_interval` | `ThrottleInterval` | +| `start_calendar_interval` | `StartCalendarInterval` | +| `queue_directories` | `QueueDirectories` | +| `environment` | `EnvironmentVariables` | +| `working_directory` | `WorkingDirectory` | +| `stdout_path` | `StandardOutPath` | +| `stderr_path` | `StandardErrorPath` | +| `kickstart` | run `launchctl kickstart` | `program`, `working_directory`, `stdout_path`, `stderr_path`, and each entry in `queue_directories` expand bare `~` and `~/` to the current user's home diff --git a/docs/bootstrap/services.md b/docs/bootstrap/services.md index ef504c3a24d..2b661883a8e 100644 --- a/docs/bootstrap/services.md +++ b/docs/bootstrap/services.md @@ -1,9 +1,123 @@ -# System services +# Services -`[bootstrap.services]` declaratively manages the lifecycle of existing Linux -systemd system units. Package installation and `[bootstrap.files]` run first, -so a service may be installed by a package or supplied as a managed unit file. -After file changes, mise reloads systemd before applying service changes. +`[bootstrap.services]` declares services in two scopes: + +- **System services** (the default) manage the lifecycle of existing Linux + systemd system units: start, stop, enable, mask, and reload on change. +- **User services** (`scope = "user"`) are services mise defines for the + current user, declared once and installed on every platform: a systemd user + unit on Linux, a LaunchAgent on macOS, a Scheduled Task on Windows. + +## User services + +```toml +[bootstrap.services.mise-history] # the built-in history watcher +builtin = "history-watch" # implies scope = "user" + +[bootstrap.services.my-agent] +scope = "user" +command = "~/.local/bin/my-agent --serve" +description = "My agent" +restart = "on-failure" # "always" | "on-failure" | "never" +environment = { RUST_LOG = "info" } +working_directory = "~" +requires_tools = true # converge after [tools] are installed +``` + +One declaration is rendered for the platform's user service manager: + +| platform | definition | manager | +| -------- | ------------------------------------------------------------------------------------- | ------------------ | +| Linux | `~/.config/systemd/user/dev.mise..service` | `systemctl --user` | +| macOS | `~/Library/LaunchAgents/dev.mise..plist` | `launchctl` | +| Windows | Scheduled Task `mise\` (definition kept under `$MISE_STATE_DIR/user-services/`) | `schtasks` | + +### User service options + +- `command`: the command line to run. `~` and `~/` are expanded. Required + unless `builtin` is set. +- `builtin`: a definition mise supplies. `"history-watch"` runs + `mise bootstrap dotfiles watch` through a durable mise executable with + `restart = "on-failure"` and a low priority. A builtin implies + `scope = "user"`; `command` cannot be combined with it. + The history watcher is experimental and requires + `mise settings experimental=true`; ordinary user services do not. +- `description`: shown by the service manager. +- `restart`: `"on-failure"` (default), `"always"`, or `"never"`. On Linux this + is `Restart=`; on macOS `KeepAlive` (`{ SuccessfulExit = false }` for + on-failure). Task Scheduler restarts only failed runs, so on Windows + `"always"` and `"on-failure"` both restart up to three times a minute apart + after a failure and run again at logon (when `enabled = true`); a clean + exit is not restarted. Strict `"always"` semantics are a Linux and macOS + feature; a service that must survive a clean exit on Windows should loop + inside its own program. +- `environment` and `working_directory` map directly to the platform + definition. On Windows, environment variables are set through `cmd.exe`, + so values containing characters it would reinterpret (`%`, `"`, `&`, `|`, + `<`, `>`, `^`) are rejected, and so is a `command` containing `%`, `&`, + `|`, `<`, `>`, or `^` once `environment` is set (without `environment` + the command runs directly). Move such a command into a script, or set the + variables inside the program. +- `state`: `"running"` (default), `"stopped"` (installed but not running), or + `"absent"` (the installed definition is removed and stays removed while + declared so). +- `enabled`: whether the service starts at login (default `true`). On macOS + this is `RunAtLoad`, which launchd also honours when the agent is loaded, + so a stopped agent is written without it (it starts at login again once it + is set running). launchd reads any `KeepAlive` as run-at-load too, so an + agent with `enabled = false` is written without one: it is started once by + the apply but neither starts at login nor is restarted after a failure + until it is enabled again. +- `requires_tools`: converge in a second pass after `[tools]` and plugin + package managers, so a service that runs a tool starts after it exists. The + built-in watcher needs only mise and converges in the services step. + +Names must contain only letters, numbers, `.`, `_`, or `-`, and must not also +appear in `[bootstrap.linux.systemd.units]` or +`[bootstrap.macos.launchd.agents]`: both would write the same definition. + +### Durable executable + +A builtin is written with an absolute path to the mise that installed it. +mise uses the running executable unless it lives in a temporary directory or +in the staging directory of `mise bootstrap remote`, and otherwise a `mise` +found on `PATH` outside those. When only a staged binary exists the service is +reported as `unknown: no durable mise executable; install mise on this host +first` and is never written with a path that will be deleted. + +### Remove and disable + +`state = "absent"` removes the installed unit, agent, or task and keeps it +absent on later runs while declared so. Deleting the declaration leaves the +installed service in place until it is removed once: + +```sh +mise bootstrap services remove my-agent +``` + +The next `mise bootstrap` recreates it if it is still declared. + +### Status and apply + +`mise bootstrap services status` and `mise bootstrap services apply` cover +both scopes; `mise bootstrap status` and `mise bootstrap plan` list user +services as `user-service:`. `mise bootstrap status --json` includes +each user service's rendered definition under `user_services`, so what mise +would install can be inspected before applying. When the platform's user service manager is unavailable (for +example, no systemd user manager in a container), user services are reported +as `unknown` and skipped with a follow-up note; nothing is written. + +Fields that only apply to user services (`command`, `builtin`, `description`, +`restart`, `environment`, `working_directory`, `requires_tools`, and +`state = "absent"`) are rejected on a system-scope entry, so a missing +`scope = "user"` cannot silently turn a service definition into a lookup of a +system unit. Managed-file notifications apply to system services only. + +## System services + +Package installation and `[bootstrap.files]` run first, so a service may be +installed by a package or supplied as a managed unit file. After file changes, +mise reloads systemd before applying service changes. ```toml [bootstrap.packages] @@ -17,15 +131,16 @@ enabled = true Names without a unit suffix receive `.service`. Explicit unit names such as `postgresql@16-main.service`, sockets, and timers are also accepted. -For user-owned units written under `~/.config/systemd/user`, use -[systemd user units](/bootstrap/systemd.html) instead. This section manages -system units already supplied by packages or [managed files](/bootstrap/files.html). +This section manages system units already supplied by packages or +[managed files](/bootstrap/files.html). A service that runs as your user is a +[user service](#user-services) (`scope = "user"`, above); hand-written user +units go through [systemd user units](/bootstrap/systemd.html). Preview with `mise bootstrap services apply --dry-run`. If the unit will be created by the same configuration, use the full bootstrap to install its package or file before converging the service. -## Options +### System service options - `state`: `"running"` (default) or `"stopped"` - `enabled`: whether the unit starts at boot (default `true`) diff --git a/docs/cli/bootstrap.md b/docs/cli/bootstrap.md index b75150b1364..751715ae900 100644 --- a/docs/cli/bootstrap.md +++ b/docs/cli/bootstrap.md @@ -17,8 +17,9 @@ Runs the bootstrap steps for the current config in order: 2. Install built-in-manager entries from `[bootstrap.packages]` 3. `mise bootstrap files apply` — converge `[bootstrap.files]` and `[bootstrap.directories]` -4. `mise bootstrap services apply` — converge `[bootstrap.services]` - systemd system services (Linux) +4. `mise bootstrap services apply` — converge `[bootstrap.services]`: + systemd system services (Linux) and user-scope services on every + platform (those with `requires_tools = true` converge after step 14) 5. `mise bootstrap firewall apply` — converge `[bootstrap.linux.firewall]` host firewall policy and rules (Linux) 6. `mise bootstrap compose apply` — converge `[bootstrap.compose]` diff --git a/docs/cli/bootstrap/services.md b/docs/cli/bootstrap/services.md index ea0be336a31..b6782fed36a 100644 --- a/docs/cli/bootstrap/services.md +++ b/docs/cli/bootstrap/services.md @@ -5,7 +5,12 @@ - **Effect:** read-only - **Source code:** [`src/cli/bootstrap.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap.rs) -Manage Linux system services from `[bootstrap.services]` +Manage services from `[bootstrap.services]` + +System-scope entries (the default) converge existing Linux systemd system +units. `scope = "user"` entries are services mise defines for the current +user on every platform: a systemd user unit on Linux, a LaunchAgent on +macOS, a Scheduled Task on Windows. ## Flags - **`-h --help`** — Print help @@ -13,4 +18,5 @@ Manage Linux system services from `[bootstrap.services]` ## Subcommands - [`mise bootstrap services apply [-n --dry-run] [-y --yes]`](/cli/bootstrap/services/apply.html) +- [`mise bootstrap services remove [-n --dry-run] `](/cli/bootstrap/services/remove.html) - [`mise bootstrap services status [-J --json] [--missing]`](/cli/bootstrap/services/status.html) diff --git a/docs/cli/bootstrap/services/apply.md b/docs/cli/bootstrap/services/apply.md index 3b92c0bf1f0..0ba88a897d2 100644 --- a/docs/cli/bootstrap/services/apply.md +++ b/docs/cli/bootstrap/services/apply.md @@ -5,7 +5,7 @@ - **Effect:** destructive — may delete or irreversibly overwrite - **Source code:** [`src/cli/bootstrap.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap.rs) -Apply configured Linux system service state +Apply configured service state (system and user scope) ## Flags - **`-n --dry-run`** — Print what would change without changing anything diff --git a/docs/cli/bootstrap/services/remove.md b/docs/cli/bootstrap/services/remove.md new file mode 100644 index 00000000000..74cd5fbf3ec --- /dev/null +++ b/docs/cli/bootstrap/services/remove.md @@ -0,0 +1,19 @@ + +# `mise bootstrap services remove` + +- **Usage:** `mise bootstrap services remove [-n --dry-run] ` +- **Effect:** destructive — may delete or irreversibly overwrite +- **Source code:** [`src/cli/bootstrap.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap.rs) + +Remove an installed user-scope service, declared or not + +Deleting a `scope = "user"` declaration leaves its installed unit, agent, +or task in place; this removes it once. The next `mise bootstrap` +recreates it if it is still declared. + +## Arguments +- **``** — The installed user-service name to remove (declared or not) + +## Flags +- **`-n --dry-run`** — Print what would change without changing anything +- **`-h --help`** — Print help diff --git a/docs/cli/bootstrap/services/status.md b/docs/cli/bootstrap/services/status.md index 742ff0d619d..ff867b1b3e6 100644 --- a/docs/cli/bootstrap/services/status.md +++ b/docs/cli/bootstrap/services/status.md @@ -5,7 +5,7 @@ - **Effect:** read-only - **Source code:** [`src/cli/bootstrap.rs`](https://github.com/jdx/mise/blob/main/src/cli/bootstrap.rs) -Show configured Linux system service state +Show configured service state (system and user scope) ## Flags - **`-J --json`** — Output in JSON format diff --git a/docs/cli/index.md b/docs/cli/index.md index 0b204594cd6..f4dac270399 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -126,6 +126,7 @@ - [`mise bootstrap secrets status [-J --json] [--missing]`](/cli/bootstrap/secrets/status.html) - [`mise bootstrap services `](/cli/bootstrap/services.html) - [`mise bootstrap services apply [-n --dry-run] [-y --yes]`](/cli/bootstrap/services/apply.html) +- [`mise bootstrap services remove [-n --dry-run] `](/cli/bootstrap/services/remove.html) - [`mise bootstrap services status [-J --json] [--missing]`](/cli/bootstrap/services/status.html) - [`mise bootstrap status [FLAGS]`](/cli/bootstrap/status.html) - [`mise bootstrap systemd apply [-n --dry-run] [-y --yes]`](/cli/bootstrap/systemd/apply.html) diff --git a/docs/public/llms.txt b/docs/public/llms.txt index 0c0288755ea..e6daea5be5b 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -82,7 +82,7 @@ - [Package Plugins](https://mise.jdx.dev/bootstrap/packages/plugins.html): Package manager plugins extend [bootstrap.packages] without adding a manager to mise core. They are useful for machine-global state owned by another tool, such as VS Code extensions, Helm plugins,… - [Linux Users and Groups](https://mise.jdx.dev/bootstrap/accounts.html): [bootstrap.groups] and [bootstrap.users] declaratively manage local Linux accounts. mise applies groups before users and applies accounts before privileged files, so a managed file can safely refer to… - [System Files](https://mise.jdx.dev/bootstrap/files.html): [bootstrap.files] and [bootstrap.directories] declaratively manage absolute paths that may require root privileges. They are separate from [dotfiles], which manages files in a user's home directory. -- [System Services](https://mise.jdx.dev/bootstrap/services.html): [bootstrap.services] declaratively manages the lifecycle of existing Linux systemd system units. Package installation and [bootstrap.files] run first, so a service may be installed by a package or… +- [System Services](https://mise.jdx.dev/bootstrap/services.html): [bootstrap.services] declares services in two scopes. - [Docker Compose Projects](https://mise.jdx.dev/bootstrap/compose.html): [bootstrap.compose] declaratively manages long-running Docker Compose projects after packages, privileged files, directories, and system services have converged. - [Secret Inputs](https://mise.jdx.dev/bootstrap/secrets.html): [bootstrap.secrets] declares the sensitive inputs a bootstrap configuration needs without storing their values in mise configuration. - [Repos](https://mise.jdx.dev/bootstrap/repos.html): mise can declare git repositories in [bootstrap.repos] and apply them with mise bootstrap repos apply or as part of mise bootstrap. @@ -186,7 +186,7 @@ - [mise bootstrap remote](https://mise.jdx.dev/cli/bootstrap/remote.html): Bootstrap one or more machines over OpenSSH - [mise bootstrap repos](https://mise.jdx.dev/cli/bootstrap/repos.html): Manage git repo checkouts from [bootstrap.repos] - [mise bootstrap secrets](https://mise.jdx.dev/cli/bootstrap/secrets.html): Inspect bootstrap secret inputs without revealing their values -- [mise bootstrap services](https://mise.jdx.dev/cli/bootstrap/services.html): Manage Linux system services from [bootstrap.services] +- [mise bootstrap services](https://mise.jdx.dev/cli/bootstrap/services.html): Manage services from [bootstrap.services] - [mise bootstrap status](https://mise.jdx.dev/cli/bootstrap/status.html): Show the aggregate bootstrap status - [mise bootstrap user](https://mise.jdx.dev/cli/bootstrap/user.html): Manage current-user bootstrap settings from [bootstrap.user] - [mise cache](https://mise.jdx.dev/cli/cache.html): Manage the mise cache diff --git a/e2e-win/services.Tests.ps1 b/e2e-win/services.Tests.ps1 new file mode 100644 index 00000000000..ac3038e9c09 --- /dev/null +++ b/e2e-win/services.Tests.ps1 @@ -0,0 +1,106 @@ +Describe 'bootstrap user services' { + BeforeAll { + $script:OriginalExperimental = [Environment]::GetEnvironmentVariable('MISE_EXPERIMENTAL', 'Process') + $env:MISE_EXPERIMENTAL = '1' + $script:OriginalDir = Get-Location + Set-Location TestDrive: + + $script:OriginalTrusted = [Environment]::GetEnvironmentVariable('MISE_TRUSTED_CONFIG_PATHS', 'Process') + $env:MISE_TRUSTED_CONFIG_PATHS = $TestDrive + $script:Task = 'mise\mise-e2e-sleep' + } + + AfterAll { + if ($null -eq $script:OriginalExperimental) { + Remove-Item Env:MISE_EXPERIMENTAL -ErrorAction Ignore + } else { + $env:MISE_EXPERIMENTAL = $script:OriginalExperimental + } + schtasks /delete /tn $script:Task /f 2>&1 | Out-Null + Set-Location $script:OriginalDir + if ($null -eq $script:OriginalTrusted) { + Remove-Item Env:MISE_TRUSTED_CONFIG_PATHS -ErrorAction Ignore + } else { + [Environment]::SetEnvironmentVariable('MISE_TRUSTED_CONFIG_PATHS', $script:OriginalTrusted, 'Process') + } + } + + It 'renders and validates a user service without installing it' { + @" +[bootstrap.services.agent] +scope = "user" +command = "C:\\Tools\\agent.exe --serve" +environment = { RUST_LOG = "info" } + +[bootstrap.services.mise-history] +builtin = "history-watch" +"@ | Out-File -FilePath mise.toml -Encoding utf8NoBOM + + $json = mise bootstrap status --json 2>&1 | Out-String + $LASTEXITCODE | Should -Be 0 + $status = $json | ConvertFrom-Json + $agent = $status.user_services | Where-Object { $_.name -eq 'agent' } + $agent.definition | Should -BeLike '*cmd.exe*' + $agent.definition | Should -BeLike '*RUST_LOG=info*' + $agent.current | Should -Be 'not installed' + $history = $status.user_services | Where-Object { $_.name -eq 'mise-history' } + $history.command | Should -BeLike '*dotfiles watch*' + $history.definition | Should -BeLike '**' + + @" +[bootstrap.services.docker] +command = "dockerd" +"@ | Out-File -FilePath mise.toml -Encoding utf8NoBOM + $out = mise bootstrap services status 2>&1 | Out-String + $LASTEXITCODE | Should -Not -Be 0 + # a regex: the backtick escapes wildcard characters in -BeLike patterns + $out | Should -Match 'only applies to `scope = "user"` services' + } + + It 'installs, runs, and removes a scheduled task' { + @" +[bootstrap.services.mise-e2e-sleep] +scope = "user" +command = "powershell.exe -NoProfile -Command Start-Sleep 300" +"@ | Out-File -FilePath mise.toml -Encoding utf8NoBOM + + mise bootstrap services apply --yes 2>&1 | Out-String | Out-Null + $LASTEXITCODE | Should -Be 0 + schtasks /query /tn $script:Task 2>&1 | Out-Null + $LASTEXITCODE | Should -Be 0 + $json = mise bootstrap services status --json | Out-String + $LASTEXITCODE | Should -Be 0 + $status = ($json | ConvertFrom-Json)[0] + $status.current | Should -Be 'running' + $status.action | Should -Be 'noop' + + @" +[bootstrap.services.mise-e2e-sleep] +scope = "user" +command = "powershell.exe -NoProfile -Command Start-Sleep 300" +state = "absent" +"@ | Out-File -FilePath mise.toml -Encoding utf8NoBOM + $json = mise bootstrap services status --json | Out-String + $LASTEXITCODE | Should -Be 0 + $status = ($json | ConvertFrom-Json)[0] + $status.action | Should -Be 'remove' + mise bootstrap services apply --yes 2>&1 | Out-String | Out-Null + $LASTEXITCODE | Should -Be 0 + schtasks /query /tn $script:Task 2>&1 | Out-Null + $LASTEXITCODE | Should -Not -Be 0 + + @" +[bootstrap.services.mise-e2e-sleep] +scope = "user" +command = "powershell.exe -NoProfile -Command Start-Sleep 300" +"@ | Out-File -FilePath mise.toml -Encoding utf8NoBOM + mise bootstrap services apply --yes 2>&1 | Out-String | Out-Null + $LASTEXITCODE | Should -Be 0 + "[tools]" | Out-File -FilePath mise.toml -Encoding utf8NoBOM + $out = mise bootstrap services remove mise-e2e-sleep 2>&1 | Out-String + $LASTEXITCODE | Should -Be 0 + $out | Should -BeLike '*removed its Scheduled Task*' + schtasks /query /tn $script:Task 2>&1 | Out-Null + $LASTEXITCODE | Should -Not -Be 0 + } +} diff --git a/e2e/cli/test_bootstrap_user_services b/e2e/cli/test_bootstrap_user_services new file mode 100644 index 00000000000..6046358846a --- /dev/null +++ b/e2e/cli/test_bootstrap_user_services @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# `[bootstrap.services]` with `scope = "user"`: one declaration rendered for the +# platform's user service manager, validated before anything is written, and +# reported as unknown (never written) when that manager is unavailable. +# shellcheck disable=SC2016 + +cat <<'EOF' >mise.toml +[bootstrap.services.agent] +scope = "user" +command = "~/.local/bin/agent --serve" +description = "My agent" +restart = "always" +environment = { RUST_LOG = "info" } +working_directory = "~" + +[bootstrap.services.mise-history] +builtin = "history-watch" + +[bootstrap.services.later] +scope = "user" +command = "node server.js" +requires_tools = true +state = "stopped" +enabled = false + +[bootstrap.services.gone] +scope = "user" +command = "x" +state = "absent" +EOF + +# every user service is listed by the services, aggregate status, and plan +# commands with its desired state +assert_contains "mise bootstrap services status" "user-service:agent" +assert_contains "mise bootstrap services status" "user-service:mise-history" +assert_contains "mise bootstrap services status" "stopped (not at login)" +assert_contains "mise bootstrap services status" "absent" +assert_contains "mise bootstrap status" "user-service" +assert_contains "mise bootstrap plan" "user-service:later" + +# the rendered definition is inspectable before anything is installed, and a +# builtin runs through the mise that installed it +assert "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"agent\") | .restart'" "always" +assert "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"later\") | .requires_tools'" "true" +assert "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"mise-history\") | .builtin'" "history-watch" +assert_contains "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"mise-history\") | .command'" "bootstrap dotfiles watch" +if [[ "$(uname -s)" == "Linux" ]]; then + assert_contains "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"agent\") | .definition'" "Restart=always" + assert_contains "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"agent\") | .definition'" 'Environment="RUST_LOG=info"' + assert_contains "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"mise-history\") | .definition'" "Nice=10" + assert_contains "mise bootstrap status --json | jq -r '.user_services[] | select(.name == \"mise-history\") | .path'" "/.config/systemd/user/dev.mise.mise-history.service" +fi + +# without a user service manager (this container has none) nothing is written: +# status reports unknown, apply and the full bootstrap skip with a follow-up +if ! mise bootstrap services status --json | jq -e 'all(.action != "unknown")' >/dev/null; then + assert_contains "mise bootstrap services status --json" '"action": "unknown"' + assert_fail "mise bootstrap services status --missing" + assert_contains "MISE_EXPERIMENTAL=0 mise bootstrap services apply --dry-run --yes 2>&1" "skipped" + assert_contains "mise bootstrap --only services --dry-run --yes 2>&1" "skipped" + assert_fail "test -e $HOME/.config/systemd/user/dev.mise.agent.service" + assert_fail "test -e $HOME/Library/LaunchAgents/dev.mise.agent.plist" + assert_fail "mise bootstrap services remove agent" "cannot remove user service 'agent'" +fi + +# validation happens before any change: a system-scope entry cannot carry +# user-only fields, a user service needs a command or a builtin, and names +# shared with the platform-specific tables are rejected +cat <<'EOF' >mise.toml +[bootstrap.services.docker] +command = "dockerd" +EOF +assert_fail "mise bootstrap services status" 'sets command, which only applies to `scope = "user"` services' +cat <<'EOF' >mise.toml +[bootstrap.services.docker] +state = "absent" +EOF +assert_fail "mise bootstrap plan" 'sets state = "absent", which only applies to' +cat <<'EOF' >mise.toml +[bootstrap.services.agent] +scope = "user" +EOF +assert_fail "mise bootstrap services status" 'must set `command` or `builtin`' +cat <<'EOF' >mise.toml +[bootstrap.services.agent] +builtin = "history-watch" +command = "x" +EOF +assert_fail "mise bootstrap services status" 'sets both `builtin` and `command`' +cat <<'EOF' >mise.toml +[bootstrap.services.agent] +builtin = "nope" +EOF +assert_fail "mise bootstrap services status" "unknown builtin 'nope'; available: history-watch" +cat <<'EOF' >mise.toml +[bootstrap.services.agent] +scope = "user" +command = "agent" +masked = true +EOF +assert_fail "mise bootstrap services status" "cannot be masked" +cat <<'EOF' >mise.toml +[bootstrap.services.agent] +scope = "user" +command = "agent" + +[bootstrap.linux.systemd.units.agent] +exec_start = "agent" +EOF +assert_fail "mise bootstrap services status" "also declared in [bootstrap.linux.systemd.units]" +cat <<'EOF' >mise.toml +[bootstrap.services.agent] +scope = "user" +command = "agent" + +[bootstrap.macos.launchd.agents.agent] +program = "agent" +EOF +assert_fail "mise bootstrap services status" "also declared in [bootstrap.macos.launchd.agents]" + +# managed-file notifications apply to system services only +cat <mise.toml +[bootstrap.services.agent] +scope = "user" +command = "agent" + +[bootstrap.files."$PWD/notify"] +content = "x" +notify = ["agent"] +EOF +assert_fail "mise bootstrap plan" "notifies user-scope bootstrap service 'agent'" + +# a name is never guessed: the removal of an undeclared service is explicit +cat <<'EOF' >mise.toml +[tools] +EOF +assert_fail "mise bootstrap services remove 'bad name'" "must contain only letters" + +# the effective scope follows `builtin`, and skipping the services part skips +# user services too +cat <<'EOF' >mise.toml +[bootstrap.services.mise-history] +builtin = "history-watch" +EOF +assert_not_contains "mise bootstrap --skip services --dry-run --yes 2>&1" "user services" +assert_contains "mise bootstrap status --json | jq -r '.user_services[0].scope'" "user" +assert_contains "mise bootstrap services status --json | jq -r '.[0].id.kind'" "user-service" + +# on macOS the LaunchAgent is really installed, started, and removed +if [[ "$(uname -s)" == "Darwin" ]] && mise bootstrap services status --json | jq -e 'all(.action != "unknown")' >/dev/null; then + cat <<'TOML' >mise.toml +[bootstrap.services.mise-e2e-sleep] +scope = "user" +command = "/bin/sleep 300" +TOML + assert_succeed "mise bootstrap services apply --yes" + assert "test -f $HOME/Library/LaunchAgents/dev.mise.mise-e2e-sleep.plist && echo yes" "yes" + assert "mise bootstrap services status --json | jq -r '.[0] | \"\\(.current) \\(.action)\"'" "running noop" + assert_contains "mise bootstrap status --json | jq -r '.user_services[0].definition'" "SuccessfulExit" + assert_succeed "mise bootstrap services status --missing" + cat <<'TOML' >mise.toml +[bootstrap.services.mise-e2e-sleep] +scope = "user" +command = "/bin/sleep 300" +state = "absent" +TOML + assert "mise bootstrap services status --json | jq -r '.[0] | \"\\(.current) \\(.action)\"'" "installed remove" + assert_succeed "mise bootstrap services apply --yes" + assert_fail "test -e $HOME/Library/LaunchAgents/dev.mise.mise-e2e-sleep.plist" + assert "mise bootstrap services status --json | jq -r '.[0] | \"\\(.current) \\(.action)\"'" "absent noop" + # an undeclared leftover is removed once, explicitly + cat <<'TOML' >mise.toml +[bootstrap.services.mise-e2e-sleep] +scope = "user" +command = "/bin/sleep 300" +TOML + assert_succeed "mise bootstrap services apply --yes" + echo "[tools]" >mise.toml + assert_contains "mise bootstrap services remove mise-e2e-sleep 2>&1" "removed its LaunchAgent" + assert_fail "test -e $HOME/Library/LaunchAgents/dev.mise.mise-e2e-sleep.plist" + assert_contains "mise bootstrap services remove mise-e2e-sleep 2>&1" "no LaunchAgent installed" +fi diff --git a/man/man1/mise.1 b/man/man1/mise.1 index 255ea910a82..20bef03edb7 100644 --- a/man/man1/mise.1 +++ b/man/man1/mise.1 @@ -398,13 +398,16 @@ Inspect bootstrap secret inputs without revealing their values Show whether declared bootstrap secret inputs are available .TP \fBbootstrap services\fR -Manage Linux system services from `[bootstrap.services]` +Manage services from `[bootstrap.services]` .TP \fBbootstrap services apply\fR -Apply configured Linux system service state +Apply configured service state (system and user scope) +.TP +\fBbootstrap services remove\fR +Remove an installed user\-scope service, declared or not .TP \fBbootstrap services status\fR -Show configured Linux system service state +Show configured service state (system and user scope) .TP \fBbootstrap status\fR Show the aggregate bootstrap status @@ -1162,8 +1165,9 @@ Runs the bootstrap steps for the current config in order: 2. Install built\-in\-manager entries from `[bootstrap.packages]` 3. `mise bootstrap files apply` — converge `[bootstrap.files]` and `[bootstrap.directories]` -4. `mise bootstrap services apply` — converge `[bootstrap.services]` - systemd system services (Linux) +4. `mise bootstrap services apply` — converge `[bootstrap.services]`: + systemd system services (Linux) and user\-scope services on every + platform (those with `requires_tools = true` converge after step 14) 5. `mise bootstrap firewall apply` — converge `[bootstrap.linux.firewall]` host firewall policy and rules (Linux) 6. `mise bootstrap compose apply` — converge `[bootstrap.compose]` @@ -2818,7 +2822,12 @@ Exit with code 1 if a declared secret input is unavailable \fB\-h, \-\-help\fR Print help .SH "MISE BOOTSTRAP SERVICES" -Manage Linux system services from `[bootstrap.services]` +Manage services from `[bootstrap.services]` + +System\-scope entries (the default) converge existing Linux systemd system +units. `scope = "user"` entries are services mise defines for the current +user on every platform: a systemd user unit on Linux, a LaunchAgent on +macOS, a Scheduled Task on Windows. .PP \fBUsage:\fR mise bootstrap services [OPTIONS] .PP @@ -2828,7 +2837,7 @@ Manage Linux system services from `[bootstrap.services]` \fB\-h, \-\-help\fR Print help .SH "MISE BOOTSTRAP SERVICES APPLY" -Apply configured Linux system service state +Apply configured service state (system and user scope) .PP \fBUsage:\fR mise bootstrap services apply [OPTIONS] .PP @@ -2843,8 +2852,30 @@ Skip the confirmation prompt .TP \fB\-h, \-\-help\fR Print help +.SH "MISE BOOTSTRAP SERVICES REMOVE" +Remove an installed user\-scope service, declared or not + +Deleting a `scope = "user"` declaration leaves its installed unit, agent, +or task in place; this removes it once. The next `mise bootstrap` +recreates it if it is still declared. +.PP +\fBUsage:\fR mise bootstrap services remove [OPTIONS] +.PP +\fBOptions:\fR +.PP +.TP +\fB\-n, \-\-dry\-run\fR +Print what would change without changing anything +.TP +\fB\-h, \-\-help\fR +Print help +\fBArguments:\fR +.PP +.TP +\fB\fR +The installed user\-service name to remove (declared or not) .SH "MISE BOOTSTRAP SERVICES STATUS" -Show configured Linux system service state +Show configured service state (system and user scope) .PP \fBUsage:\fR mise bootstrap services status [OPTIONS] .PP diff --git a/mise.usage.kdl b/mise.usage.kdl index c417bc4c6a2..f40e0d02d20 100644 --- a/mise.usage.kdl +++ b/mise.usage.kdl @@ -445,8 +445,9 @@ Runs the bootstrap steps for the current config in order: 2. Install built-in-manager entries from `[bootstrap.packages]` 3. `mise bootstrap files apply` — converge `[bootstrap.files]` and `[bootstrap.directories]` -4. `mise bootstrap services apply` — converge `[bootstrap.services]` - systemd system services (Linux) +4. `mise bootstrap services apply` — converge `[bootstrap.services]`: + systemd system services (Linux) and user-scope services on every + platform (those with `requires_tools = true` converge after step 14) 5. `mise bootstrap firewall apply` — converge `[bootstrap.linux.firewall]` host firewall policy and rules (Linux) 6. `mise bootstrap compose apply` — converge `[bootstrap.compose]` @@ -1441,14 +1442,34 @@ Defaults to `~/.local/bin/mise`; pass `--install-mise=` for another path. flag "-h --help" help="Print help" action=help builtin=#true } } - cmd services subcommand_required=#true help="Manage Linux system services from `[bootstrap.services]`" effect=read { + cmd services subcommand_required=#true help="Manage services from `[bootstrap.services]`" effect=read { + long_help #""" +Manage services from `[bootstrap.services]` + +System-scope entries (the default) converge existing Linux systemd system +units. `scope = "user"` entries are services mise defines for the current +user on every platform: a systemd user unit on Linux, a LaunchAgent on +macOS, a Scheduled Task on Windows. +"""# flag "-h --help" help="Print help" action=help builtin=#true - cmd apply help="Apply configured Linux system service state" effect=destructive { + cmd apply help="Apply configured service state (system and user scope)" effect=destructive { flag "-n --dry-run" help="Print what would change without changing anything" flag "-y --yes" help="Skip the confirmation prompt" flag "-h --help" help="Print help" action=help builtin=#true } - cmd status help="Show configured Linux system service state" effect=read { + cmd remove help="Remove an installed user-scope service, declared or not" effect=destructive { + long_help #""" +Remove an installed user-scope service, declared or not + +Deleting a `scope = "user"` declaration leaves its installed unit, agent, +or task in place; this removes it once. The next `mise bootstrap` +recreates it if it is still declared. +"""# + flag "-n --dry-run" help="Print what would change without changing anything" + flag "-h --help" help="Print help" action=help builtin=#true + arg help="The installed user-service name to remove (declared or not)" + } + cmd status help="Show configured service state (system and user scope)" effect=read { flag "-J --json" help="Output in JSON format" flag --missing help="Exit with code 1 when any service is not converged" flag "-h --help" help="Print help" action=help builtin=#true diff --git a/scripts/test-bootstrap-linux-host.sh b/scripts/test-bootstrap-linux-host.sh index da1e78c7c5b..793f9874929 100755 --- a/scripts/test-bootstrap-linux-host.sh +++ b/scripts/test-bootstrap-linux-host.sh @@ -94,6 +94,11 @@ state = "running" enabled = true on_change = "restart" +[bootstrap.services.mise-case-user] +scope = "user" +command = "/bin/sleep 300" +description = "mise bootstrap user-service smoke" + [bootstrap.linux.firewall] backend = "nftables" state = "enabled" @@ -201,7 +206,14 @@ ssh "${ssh_args[@]}" \ systemctl is-enabled --quiet mise-case.service systemctl is-active --quiet docker.service nft list table inet mise_bootstrap | grep -q mise-bootstrap - docker compose --project-directory /opt/mise-case --file /opt/mise-case/compose.yaml --project-name mise-bootstrap-smoke ps --status running --quiet | grep -q .' + docker compose --project-directory /opt/mise-case --file /opt/mise-case/compose.yaml --project-name mise-bootstrap-smoke ps --status running --quiet | grep -q . + if systemctl --user show-environment >/dev/null 2>&1; then + test -f "$HOME/.config/systemd/user/dev.mise.mise-case-user.service" + systemctl --user is-active --quiet dev.mise.mise-case-user.service + else + echo "no systemd user manager for root on this host; user-service leg reports only" + ! test -e "$HOME/.config/systemd/user/dev.mise.mise-case-user.service" + fi' ssh "${ssh_args[@]}" \ 'set -eu diff --git a/src/cli/bootstrap.rs b/src/cli/bootstrap.rs index f362902442b..93d2e2fcbf0 100644 --- a/src/cli/bootstrap.rs +++ b/src/cli/bootstrap.rs @@ -48,8 +48,9 @@ use crate::ui::table::MiseTable; /// 2. Install built-in-manager entries from `[bootstrap.packages]` /// 3. `mise bootstrap files apply` — converge `[bootstrap.files]` and /// `[bootstrap.directories]` -/// 4. `mise bootstrap services apply` — converge `[bootstrap.services]` -/// systemd system services (Linux) +/// 4. `mise bootstrap services apply` — converge `[bootstrap.services]`: +/// systemd system services (Linux) and user-scope services on every +/// platform (those with `requires_tools = true` converge after step 14) /// 5. `mise bootstrap firewall apply` — converge `[bootstrap.linux.firewall]` /// host firewall policy and rules (Linux) /// 6. `mise bootstrap compose apply` — converge `[bootstrap.compose]` @@ -509,7 +510,12 @@ struct BootstrapFilesStatus { prompt_secrets: bool, } -/// Manage Linux system services from `[bootstrap.services]` +/// Manage services from `[bootstrap.services]` +/// +/// System-scope entries (the default) converge existing Linux systemd system +/// units. `scope = "user"` entries are services mise defines for the current +/// user on every platform: a systemd user unit on Linux, a LaunchAgent on +/// macOS, a Scheduled Task on Windows. #[derive(Debug, usage_rs::Args)] #[usage(verbatim_doc_comment)] struct BootstrapServices { @@ -520,10 +526,11 @@ struct BootstrapServices { #[derive(Debug, usage_rs::Subcommands)] enum BootstrapServicesCommands { Apply(BootstrapServicesApply), + Remove(BootstrapServicesRemove), Status(BootstrapServicesStatus), } -/// Apply configured Linux system service state +/// Apply configured service state (system and user scope) #[derive(Debug, usage_rs::Args)] struct BootstrapServicesApply { /// Print what would change without changing anything @@ -535,7 +542,23 @@ struct BootstrapServicesApply { yes: bool, } -/// Show configured Linux system service state +/// Remove an installed user-scope service, declared or not +/// +/// Deleting a `scope = "user"` declaration leaves its installed unit, agent, +/// or task in place; this removes it once. The next `mise bootstrap` +/// recreates it if it is still declared. +#[derive(Debug, usage_rs::Args)] +#[usage(verbatim_doc_comment)] +struct BootstrapServicesRemove { + /// The installed user-service name to remove (declared or not) + name: String, + + /// Print what would change without changing anything + #[usage(long, short = 'n')] + dry_run: bool, +} + +/// Show configured service state (system and user scope) #[derive(Debug, usage_rs::Args)] struct BootstrapServicesStatus { /// Output in JSON format @@ -1325,10 +1348,16 @@ impl Bootstrap { let services = configured_services .as_ref() .expect("configured notifications prepared services"); - system::services::validate_notifications(files, directories, services)?; + let user_services = system::services_common::user_service_names(&config)?; + system::services::validate_notifications(files, directories, services, &user_services)?; } let mut managed_services = services_enabled.then_some(configured_services.unwrap_or_default()); + let user_services = if services_enabled { + system::user_services::requests_from_config(&config)? + } else { + vec![] + }; let mut managed_firewall = if skip.contains(&BootstrapPart::Firewall) { None } else { @@ -1471,8 +1500,14 @@ impl Bootstrap { self.yes, )?; } + let early = user_services + .iter() + .filter(|request| !request.requires_tools) + .cloned() + .collect::>(); + apply_user_services(&early, self.dry_run, self.yes, Some(&mut follow_up)).await?; } else { - debug!("bootstrap: system services skipped"); + debug!("bootstrap: services skipped"); } if skip.contains(&BootstrapPart::Firewall) { @@ -1739,6 +1774,23 @@ impl Bootstrap { } } + // resolved again: the run may have installed a durable mise since + // the requests were first built (a remote-staged bootstrap) + let late = if services_enabled { + system::user_services::requests_from_config(&config)? + .into_iter() + .filter(|request| request.requires_tools) + .collect::>() + } else { + vec![] + }; + if !late.is_empty() { + if skip.contains(&BootstrapPart::Tools) { + info!("bootstrap: tools skipped; user services with requires_tools still converge"); + } + apply_user_services(&late, self.dry_run, self.yes, Some(&mut follow_up)).await?; + } + if skip.contains(&BootstrapPart::Task) { debug!("bootstrap: `bootstrap` task skipped"); } else { @@ -2531,7 +2583,13 @@ impl BootstrapFilesApply { .any(|directory| !directory.notify.is_empty()) { let services = system::services::prepare_requests_from_config(&config)?; - system::services::validate_notifications(&files, &directories, &services)?; + let user_services = system::services_common::user_service_names(&config)?; + system::services::validate_notifications( + &files, + &directories, + &services, + &user_services, + )?; Some(services) } else { None @@ -2623,11 +2681,49 @@ impl BootstrapServices { async fn run(self) -> Result<()> { match self.command { BootstrapServicesCommands::Apply(command) => command.run().await, + BootstrapServicesCommands::Remove(command) => command.run().await, BootstrapServicesCommands::Status(command) => command.run().await, } } } +impl BootstrapServicesRemove { + async fn run(self) -> Result<()> { + OperationScope::wrap( + "bootstrap services remove", + "services", + self.dry_run, + self.run_inner(), + ) + .await + } + + async fn run_inner(self) -> Result<()> { + let config = Config::get().await?; + // best effort: a broken declaration must not block removal, which is + // the recovery path for exactly that state + let declared = system::user_services::requests_from_config(&config) + .map(|requests| requests.iter().any(|request| request.name == self.name)) + .unwrap_or(false); + let removed = system::user_services::remove_named(&self.name, self.dry_run).await?; + let manager = system::user_services::manager_name(); + if !removed { + info!("user service {}: no {manager} installed", self.name); + } else if self.dry_run { + info!("user service {}: would remove its {manager}", self.name); + } else { + info!("user service {}: removed its {manager}", self.name); + } + if declared { + info!( + "user service {} is still declared in [bootstrap.services]; the next `mise bootstrap` recreates it", + self.name + ); + } + Ok(()) + } +} + impl BootstrapServicesApply { async fn run(self) -> Result<()> { OperationScope::wrap( @@ -2642,25 +2738,59 @@ impl BootstrapServicesApply { async fn run_inner(self) -> Result<()> { let config = Config::get().await?; let requests = system::services::requests_from_config(&config)?; - system::services::apply(&requests, self.dry_run, self.yes) + let user_requests = system::user_services::requests_from_config(&config)?; + system::services::apply(&requests, self.dry_run, self.yes)?; + apply_user_services(&user_requests, self.dry_run, self.yes, None).await + } +} + +/// Converge user-scope services, reporting an unavailable service manager as +/// a skipped follow-up instead of a failure. +async fn apply_user_services( + requests: &[system::user_services::UserServiceRequest], + dry_run: bool, + yes: bool, + follow_up: Option<&mut BootstrapFollowUp>, +) -> Result<()> { + if requests.is_empty() { + return Ok(()); } + info!("bootstrap: user services"); + if let Some(reason) = system::user_services::apply(requests, dry_run, yes).await? { + let message = format!( + "user services: {} service(s) skipped ({reason})", + requests.len() + ); + match follow_up { + Some(follow_up) => follow_up.add_skipped(message), + None => warn!("{message}"), + } + } + Ok(()) } impl BootstrapServicesStatus { async fn run(self) -> Result<()> { let config = Config::get().await?; let requests = system::services::requests_from_config(&config)?; - let resources = system::services::plans_with_notifications( + let mut resources = system::services::plans_with_notifications( &requests, &system::services::ServiceNotifications::default(), ); + let user_requests = system::user_services::requests_from_config(&config)?; + resources.extend( + system::user_services::status(&user_requests) + .await? + .iter() + .map(|status| status.plan()), + ); let missing = resources .iter() .any(|resource| resource.action != system::resources::ResourceAction::Noop); if self.json { miseprintln!("{}", serde_json::to_string_pretty(&resources)?); } else if resources.is_empty() { - info!("no bootstrap system services configured"); + info!("no bootstrap services configured"); } else { let mut table = MiseTable::new(false, &["Action", "Resource", "Current", "Desired"]); for resource in resources { @@ -3086,7 +3216,13 @@ impl BootstrapStatus { )?; let service_requests = system::services::status_requests_from_config(config)?; let firewall_request = system::firewall::status_request_from_config(config)?; - system::services::validate_notifications(&files, &directories, &service_requests)?; + let user_services = system::services_common::user_service_names(config)?; + system::services::validate_notifications( + &files, + &directories, + &service_requests, + &user_services, + )?; let notified_services = system::managed_files::pending_notifications(&files, &directories)?; let compose_requests = system::compose::requests_from_config(config)?; self.collect_secrets(&secrets.used_statuses()?, &mut report); @@ -3094,6 +3230,7 @@ impl BootstrapStatus { self.collect_accounts(&accounts, &mut report); self.collect_files(files, directories, unavailable_files, &mut report)?; self.collect_services(&service_requests, ¬ified_services, &mut report); + self.collect_user_services(config, &mut report).await?; self.collect_firewall(firewall_request.as_ref(), &mut report); self.collect_compose(&compose_requests, &mut report); self.collect_repos(config, &mut report).await?; @@ -3193,6 +3330,29 @@ impl BootstrapStatus { report.json.insert("services".to_string(), json!(resources)); } + async fn collect_user_services( + &self, + config: &Arc, + report: &mut BootstrapStatusReport, + ) -> Result<()> { + let requests = system::user_services::requests_from_config(config)?; + let statuses = system::user_services::status(&requests).await?; + for status in &statuses { + let missing = status.action != system::resources::ResourceAction::Noop; + report.row( + "user-service", + status.name.clone(), + status.current.clone(), + status.action.to_string(), + missing, + ); + } + report + .json + .insert("user_services".to_string(), json!(statuses)); + Ok(()) + } + fn collect_firewall( &self, request: Option<&system::firewall::FirewallRequest>, diff --git a/src/cli/command_effects.rs b/src/cli/command_effects.rs index 25cb58fb181..b3b05adbda1 100644 --- a/src/cli/command_effects.rs +++ b/src/cli/command_effects.rs @@ -66,6 +66,7 @@ pub(super) const EFFECTS: &[(&str, SpecCommandEffect)] = &[ ("bootstrap firewall status", Read), ("bootstrap services", Read), ("bootstrap services apply", Destructive), + ("bootstrap services remove", Destructive), ("bootstrap services status", Read), // Hidden compatibility spellings of the nested macos/linux subcommands. ("bootstrap launchd", Read), diff --git a/src/system/launchd.rs b/src/system/launchd.rs index 186f0649524..eaf5ed5e39e 100644 --- a/src/system/launchd.rs +++ b/src/system/launchd.rs @@ -22,10 +22,16 @@ pub(crate) struct LaunchdTomlConfig { pub run_at_load: bool, #[serde(default)] pub keep_alive: bool, + /// `KeepAlive = { SuccessfulExit = false }`: relaunch only after a failure. + #[serde(default)] + pub keep_alive_on_failure: bool, #[serde(default)] pub start_interval: Option, #[serde(default)] pub throttle_interval: Option, + /// Niceness of the process (`Nice` in the plist). + #[serde(default)] + pub nice: Option, #[serde(default)] pub start_calendar_interval: Option, #[serde(default)] @@ -71,8 +77,10 @@ pub(crate) struct LaunchdRequest { pub args: Vec, pub run_at_load: bool, pub keep_alive: bool, + pub keep_alive_on_failure: bool, pub start_interval: Option, pub throttle_interval: Option, + pub nice: Option, pub start_calendar_interval: Option, pub queue_directories: Vec, pub environment: IndexMap, @@ -109,6 +117,9 @@ impl LaunchdRequest { if program.is_empty() { bail!("agent '{name}' must set a non-empty `program`"); } + if config.keep_alive && config.keep_alive_on_failure { + bail!("agent '{name}' cannot set both `keep_alive` and `keep_alive_on_failure`"); + } if let Some(interval) = &config.start_calendar_interval { interval.validate(&name)?; } @@ -134,8 +145,10 @@ impl LaunchdRequest { args: config.args, run_at_load: config.run_at_load, keep_alive: config.keep_alive, + keep_alive_on_failure: config.keep_alive_on_failure, start_interval: config.start_interval, throttle_interval: config.throttle_interval, + nice: config.nice, start_calendar_interval: config.start_calendar_interval, queue_directories: config.queue_directories, environment: config.environment, @@ -317,6 +330,76 @@ pub(crate) async fn apply(requests: &[LaunchdRequest], dry_run: bool) -> Result< Ok(()) } +/// Whether the agent's process is currently running (not merely loaded). +pub(crate) async fn is_running(label: &str) -> Result { + let target = format!("{}/{}", launchctl_domain(), label); + let output = tokio::process::Command::new("launchctl") + .args(["print", &target]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .output() + .await?; + if !output.status.success() { + return Ok(false); + } + Ok(print_reports_running(&String::from_utf8_lossy( + &output.stdout, + ))) +} + +fn print_reports_running(output: &str) -> bool { + output.lines().map(str::trim).any(|line| { + line.strip_prefix("state = ") + .is_some_and(|state| state.trim() == "running") + }) +} + +/// Unload (stopping its process) the agent `label` if it is loaded, keeping +/// the plist in place. +pub(crate) async fn unload(label: &str, dry_run: bool) -> Result<()> { + let domain = launchctl_domain(); + let path = launch_agents_dir().join(format!("{label}.plist")); + if dry_run { + miseprintln!( + "{}", + shell_words::join([ + "launchctl".to_string(), + "bootout".to_string(), + domain, + path.display().to_string(), + ]) + ); + return Ok(()); + } + bootout(&domain, &path).await +} + +/// Unload and delete the LaunchAgent mise wrote for `name` +/// (`dev.mise.`). Returns whether a plist existed. +pub(crate) async fn remove_agent(name: &str, dry_run: bool) -> Result { + let label = format!("dev.mise.{name}"); + let path = launch_agents_dir().join(format!("{label}.plist")); + if !path.exists() { + return Ok(false); + } + unload(&label, dry_run).await?; + if dry_run { + miseprintln!( + "{}", + shell_words::join(["rm".to_string(), path.display().to_string()]) + ); + return Ok(true); + } + std::fs::remove_file(&path)?; + Ok(true) +} + +/// The plist path mise uses for an agent named `name`. +pub(crate) fn agent_plist_path(name: &str) -> PathBuf { + launch_agents_dir().join(format!("dev.mise.{name}.plist")) +} + pub(crate) fn render_plist(request: &LaunchdRequest) -> Result> { let mut out = vec![]; plist::to_writer_xml(&mut out, &plist_value(request))?; @@ -334,6 +417,10 @@ fn plist_value(request: &LaunchdRequest) -> Value { } if request.keep_alive { dict.insert("KeepAlive".into(), Value::Boolean(true)); + } else if request.keep_alive_on_failure { + let mut keep_alive = Dictionary::new(); + keep_alive.insert("SuccessfulExit".into(), Value::Boolean(false)); + dict.insert("KeepAlive".into(), Value::Dictionary(keep_alive)); } if let Some(interval) = request.start_interval { dict.insert("StartInterval".into(), Value::Integer(interval.into())); @@ -341,6 +428,9 @@ fn plist_value(request: &LaunchdRequest) -> Value { if let Some(interval) = request.throttle_interval { dict.insert("ThrottleInterval".into(), Value::Integer(interval.into())); } + if let Some(nice) = request.nice { + dict.insert("Nice".into(), Value::Integer(nice.into())); + } if let Some(interval) = &request.start_calendar_interval { dict.insert( "StartCalendarInterval".into(), @@ -615,8 +705,10 @@ mod tests { args: vec!["hello".to_string()], run_at_load: true, keep_alive: true, + keep_alive_on_failure: false, start_interval: Some(60), throttle_interval: Some(300), + nice: None, start_calendar_interval: Some(LaunchdCalendarIntervals::Single( LaunchdCalendarInterval { hour: Some(2), @@ -725,8 +817,10 @@ mod tests { args: vec![], run_at_load: false, keep_alive: false, + keep_alive_on_failure: false, start_interval: None, throttle_interval: None, + nice: None, start_calendar_interval: Some(LaunchdCalendarIntervals::Multiple(vec![ LaunchdCalendarInterval { hour: Some(3), @@ -782,6 +876,7 @@ mod tests { LaunchdTomlConfig { program: Some("/bin/echo".to_string()), throttle_interval: Some(10), + nice: None, queue_directories: vec![ "~/Library/Queues/sync".to_string(), "/var/spool/sync".to_string(), diff --git a/src/system/mod.rs b/src/system/mod.rs index 1cb271741ff..aa3fda53f6a 100644 --- a/src/system/mod.rs +++ b/src/system/mod.rs @@ -63,6 +63,7 @@ pub(crate) mod remote; pub(crate) mod remote_repository; pub(crate) mod repos; pub(crate) mod resources; +pub(crate) mod scheduled_tasks; pub(crate) mod secrets; #[cfg(target_os = "linux")] pub(crate) mod services; @@ -73,6 +74,7 @@ pub(crate) mod services_common; pub(crate) mod shell_activation; pub(crate) mod sudo; pub(crate) mod systemd; +pub(crate) mod user_services; /// `[bootstrap]` as parsed from a single mise.toml #[derive(Debug, Default, Clone, Deserialize)] diff --git a/src/system/resources.rs b/src/system/resources.rs index 06f48fb79da..53dd0d96fe5 100644 --- a/src/system/resources.rs +++ b/src/system/resources.rs @@ -412,7 +412,8 @@ pub(crate) async fn plan( cfg!(target_os = "linux"), )?; let services = super::services::status_requests_from_config(config)?; - super::services::validate_notifications(&files, &directories, &services)?; + let user_services = super::services_common::user_service_names(config)?; + super::services::validate_notifications(&files, &directories, &services, &user_services)?; let notified_services = super::managed_files::pending_notifications(&files, &directories)?; let directory_states = directories .iter() @@ -537,6 +538,10 @@ pub(crate) async fn plan( plan.add_dependency(&id, dependency.clone())?; } } + let user_service_requests = super::user_services::requests_from_config(config)?; + for status in super::user_services::status(&user_service_requests).await? { + plan.insert(status.plan())?; + } if let Some(mut firewall) = super::firewall::prepare_request_from_config(config)? { super::firewall::inspect_request(&mut firewall)?; let dependencies = plan diff --git a/src/system/scheduled_tasks.rs b/src/system/scheduled_tasks.rs new file mode 100644 index 00000000000..78e980f6389 --- /dev/null +++ b/src/system/scheduled_tasks.rs @@ -0,0 +1,650 @@ +//! Windows Scheduled Tasks for user-scope `[bootstrap.services]` entries. +//! +//! A task named `mise\` is registered from a rendered task definition +//! with `schtasks /create /xml`. The rendered definition is kept under +//! `$MISE_STATE_DIR/user-services/.xml` so drift is detected against +//! what mise wrote, independent of the exporter's formatting. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use eyre::{Result, bail, eyre}; +use indexmap::IndexMap; + +const SCHTASKS_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ScheduledTaskRequest { + pub name: String, + pub task: String, + pub description: Option, + pub command: String, + pub restart_on_failure: bool, + pub environment: IndexMap, + pub working_directory: Option, + /// Whether the task should be running now. + pub start: bool, + /// A niceness above zero lowers the task's priority. + pub nice: Option, + /// Whether the logon trigger is enabled. + pub at_logon: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ScheduledTaskState { + Running, + Ready, + Disabled, + Differs, + Missing, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ScheduledTaskStatus { + pub request: ScheduledTaskRequest, + pub path: PathBuf, + pub state: ScheduledTaskState, +} + +impl ScheduledTaskStatus { + pub(crate) fn is_desired(&self) -> bool { + match self.state { + ScheduledTaskState::Running => self.request.start, + ScheduledTaskState::Ready => !self.request.start, + ScheduledTaskState::Disabled + | ScheduledTaskState::Differs + | ScheduledTaskState::Missing => false, + } + } +} + +impl ScheduledTaskRequest { + pub(crate) fn new(name: &str) -> Self { + Self { + name: name.to_string(), + task: task_name(name), + description: None, + command: String::new(), + restart_on_failure: false, + environment: IndexMap::new(), + working_directory: None, + start: true, + at_logon: true, + nice: None, + } + } +} + +pub(crate) fn is_available() -> bool { + // spawnable as-is: `schtasks.exe`, which a plain lookup does not find + cfg!(windows) && crate::file::which_spawnable("schtasks").is_some() +} + +pub(crate) fn unavailable_reason() -> String { + if cfg!(windows) { + "`schtasks` not found".to_string() + } else { + "only available on windows".to_string() + } +} + +pub(crate) fn task_name(name: &str) -> String { + format!("mise\\{name}") +} + +/// Where the rendered definition mise registered is kept. +pub(crate) fn definition_path(name: &str) -> PathBuf { + crate::dirs::STATE + .join("user-services") + .join(format!("{name}.xml")) +} + +/// The account the task runs as and whose logon triggers it. +fn current_user_id() -> String { + let user = crate::env::var("USERNAME").unwrap_or_else(|_| "".to_string()); + match crate::env::var("USERDOMAIN") { + Ok(domain) if !domain.is_empty() => format!("{domain}\\{user}"), + _ => user, + } +} + +/// Render the task definition (Task Scheduler XML, UTF-16LE with a BOM as +/// `schtasks /create /xml` expects). +pub(crate) fn render_definition(request: &ScheduledTaskRequest, user_id: &str) -> Result> { + let xml = render_xml(request, user_id)?; + let mut out = vec![0xFF, 0xFE]; + for unit in xml.encode_utf16() { + out.extend_from_slice(&unit.to_le_bytes()); + } + Ok(out) +} + +pub(crate) fn render_xml(request: &ScheduledTaskRequest, user_id: &str) -> Result { + let (command, arguments) = exec_action(request)?; + let mut out = String::new(); + out.push_str("\n"); + out.push_str( + "\n", + ); + out.push_str(" \n"); + out.push_str(&format!( + " {}\n", + escape( + request + .description + .as_deref() + .unwrap_or("managed by mise bootstrap") + ) + )); + out.push_str(" \n"); + out.push_str(" \n \n"); + out.push_str(&format!( + " {}\n", + yes_no(request.at_logon) + )); + out.push_str(&format!(" {}\n", escape(user_id))); + out.push_str(" \n \n"); + out.push_str(" \n \n"); + out.push_str(&format!(" {}\n", escape(user_id))); + out.push_str(" InteractiveToken\n"); + out.push_str(" LeastPrivilege\n"); + out.push_str(" \n \n"); + out.push_str(" \n"); + out.push_str(" IgnoreNew\n"); + out.push_str(" false\n"); + out.push_str(" false\n"); + out.push_str(" true\n"); + out.push_str(" true\n"); + out.push_str(" false\n"); + out.push_str(" true\n"); + out.push_str(" true\n"); + out.push_str(" false\n"); + out.push_str(" false\n"); + out.push_str(" PT0S\n"); + if request.restart_on_failure { + out.push_str(" \n PT1M\n 3\n \n"); + } + // 7 is the default; a nice service runs at the lowest normal priority + let priority = if request.nice.is_some_and(|nice| nice > 0) { + 9 + } else { + 7 + }; + out.push_str(&format!(" {priority}\n")); + out.push_str(" \n"); + out.push_str(" \n \n"); + out.push_str(&format!(" {}\n", escape(&command))); + if !arguments.is_empty() { + out.push_str(&format!( + " {}\n", + escape(&arguments) + )); + } + if let Some(dir) = &request.working_directory { + out.push_str(&format!( + " {}\n", + escape(&expand_path_string(dir)) + )); + } + out.push_str(" \n \n"); + out.push_str("\n"); + Ok(out) +} + +/// Split the command line into the executable and its arguments. Task +/// Scheduler has no environment block, so variables are set through +/// `cmd.exe`, which reinterprets some characters; values that it would +/// change are rejected rather than passed through differently. +fn exec_action(request: &ScheduledTaskRequest) -> Result<(String, String)> { + let (program, args) = split_command(&request.command); + if request.environment.is_empty() { + return Ok((program, args)); + } + let mut sets = vec![]; + for (key, value) in &request.environment { + if key.is_empty() || key.contains(['=', '"', '%', '\n', '\r']) { + bail!( + "user service '{}': environment key {key:?} cannot be set through cmd.exe", + request.name + ); + } + if let Some(c) = value + .chars() + .find(|c| matches!(c, '"' | '%' | '&' | '|' | '<' | '>' | '^' | '\n' | '\r')) + { + bail!( + "user service '{}': environment value for {key} contains {c:?}, which cmd.exe would reinterpret; set it inside the program instead", + request.name + ); + } + sets.push(format!("set \"{key}={value}\"")); + } + // the command line goes through cmd.exe too: what it would split or + // chain is rejected the same way, rather than run differently + if let Some(c) = format!("{program} {args}") + .chars() + .find(|c| matches!(c, '%' | '&' | '|' | '<' | '>' | '^' | '\n' | '\r')) + { + bail!( + "user service '{}': the command contains {c:?}, which cmd.exe would reinterpret when `environment` is set; move it into a script", + request.name + ); + } + let program = if program.contains(char::is_whitespace) { + format!("\"{program}\"") + } else { + program + }; + let rest = if args.is_empty() { + program + } else { + format!("{program} {args}") + }; + Ok(( + "cmd.exe".to_string(), + format!("/c {} && {rest}", sets.join(" && ")), + )) +} + +fn split_command(command: &str) -> (String, String) { + let trimmed = command.trim(); + let (program, args) = if let Some(rest) = trimmed.strip_prefix('"') + && let Some(end) = rest.find('"') + { + (rest[..end].to_string(), rest[end + 1..].trim().to_string()) + } else { + match trimmed.split_once(char::is_whitespace) { + Some((program, args)) => (program.to_string(), args.trim().to_string()), + None => (trimmed.to_string(), String::new()), + } + }; + // `~` and `~/` expand on every platform, as the docs promise + let program = if program == "~" || program.starts_with("~/") || program.starts_with("~\\") { + expand_path_string(&program) + } else { + program + }; + (program, args) +} + +fn escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn yes_no(value: bool) -> &'static str { + if value { "true" } else { "false" } +} + +fn expand_path_string(path: &str) -> String { + if path == "~" { + return crate::dirs::HOME.to_string_lossy().to_string(); + } + crate::file::replace_path(Path::new(path)) + .to_string_lossy() + .to_string() +} + +pub(crate) async fn status(requests: &[ScheduledTaskRequest]) -> Result> { + let user_id = current_user_id(); + let mut out = vec![]; + for req in requests { + let path = definition_path(&req.name); + let registered = query(&req.task).await?; + let state = match registered { + None => ScheduledTaskState::Missing, + Some(query) => { + let stored = std::fs::read(&path).unwrap_or_default(); + if stored != render_definition(req, &user_id)? { + ScheduledTaskState::Differs + } else if query.running { + ScheduledTaskState::Running + } else if query.disabled { + ScheduledTaskState::Disabled + } else { + ScheduledTaskState::Ready + } + } + }; + out.push(ScheduledTaskStatus { + request: req.clone(), + path, + state, + }); + } + Ok(out) +} + +pub(crate) async fn exists(name: &str) -> Result { + Ok(query(&task_name(name)).await?.is_some()) +} + +pub(crate) async fn apply(requests: &[ScheduledTaskRequest], dry_run: bool) -> Result<()> { + let user_id = current_user_id(); + for req in requests { + let path = definition_path(&req.name); + // the definition is registered from a staging file and stored only + // once Task Scheduler accepted it, so a failed create never leaves a + // definition on disk that status would take for the registered one + let staging = path.with_extension("xml.new"); + let rendered = render_definition(req, &user_id)?; + let create = [ + "/create".to_string(), + "/tn".to_string(), + req.task.clone(), + "/xml".to_string(), + staging.display().to_string(), + "/f".to_string(), + ]; + let end = [ + "/end".to_string(), + "/tn".to_string(), + req.task.clone(), + "/HRESULT".to_string(), + ]; + let run = ["/run".to_string(), "/tn".to_string(), req.task.clone()]; + // what is registered now: a running instance keeps its old process + // (`IgnoreNew`), so a changed definition or a stop ends it first, and + // a task that is not running is never ended (its message is + // localized, so it is not parsed) + let registered = query(&req.task).await?; + let running = registered.as_ref().is_some_and(|query| query.running); + let changed = registered.is_some() + && std::fs::read(&path).ok().as_deref() != Some(rendered.as_slice()); + let end_first = running && (!req.start || changed); + let start = req.start && (!running || changed); + if dry_run { + miseprintln!("write {}", shell_words::join([path.display().to_string()])); + miseprintln!("schtasks {}", shell_words::join(&create)); + if end_first { + miseprintln!("schtasks {}", shell_words::join(&end)); + } + if start { + miseprintln!("schtasks {}", shell_words::join(&run)); + } + continue; + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&staging, &rendered)?; + if let Err(err) = schtasks(&create).await { + let _ = std::fs::remove_file(&staging); + return Err(err); + } + // written, not renamed: a rename does not replace an existing + // definition on Windows + std::fs::write(&path, &rendered)?; + let _ = std::fs::remove_file(&staging); + if end_first { + // it may have exited between the query and now: the HRESULT + // says so in every locale; the message is matched as a fallback + let (status, printed) = schtasks_output(&end).await?; + if !status.success() + && status.code() != Some(SCHED_E_TASK_NOT_RUNNING) + && !end_error_is_noop(&printed) + { + bail!("`schtasks {}` failed: {printed}", shell_words::join(&end)); + } + } + if start { + schtasks(&run).await?; + } + } + Ok(()) +} + +/// Delete the task mise registered for `name`. Returns whether one existed. +pub(crate) async fn remove_task(name: &str, dry_run: bool) -> Result { + let task = task_name(name); + let path = definition_path(name); + if !exists(name).await? { + if path.exists() && !dry_run { + std::fs::remove_file(&path)?; + } + return Ok(false); + } + let args = [ + "/delete".to_string(), + "/tn".to_string(), + task, + "/f".to_string(), + ]; + if dry_run { + miseprintln!("schtasks {}", shell_words::join(&args)); + if path.exists() { + miseprintln!( + "{}", + shell_words::join(["rm".to_string(), path.display().to_string()]) + ); + } + return Ok(true); + } + schtasks(&args).await?; + if path.exists() { + std::fs::remove_file(&path)?; + } + Ok(true) +} + +struct Query { + running: bool, + disabled: bool, +} + +/// The task's state through the Task Scheduler API rather than the +/// localized text `schtasks /query` prints. Prints `MISSING` for an +/// unregistered task and the `TaskState` name otherwise. The name is +/// embedded in the script (arguments after `-Command` are more command +/// text, not `$args`); names are validated to letters, digits, `.`, `_`, +/// and `-` before they get here. +fn query_script(name: &str) -> String { + format!( + "$t = Get-ScheduledTask -TaskPath '\\mise\\' -TaskName '{name}' -ErrorAction SilentlyContinue; if ($null -eq $t) {{ 'MISSING' }} else {{ $t.State.ToString() }}" + ) +} + +async fn query(task: &str) -> Result> { + let name = task.strip_prefix("mise\\").unwrap_or(task); + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + { + bail!("scheduled task name {name:?} contains characters that cannot be queried"); + } + let args = [ + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + query_script(name), + ]; + debug!("$ powershell {}", shell_words::join(&args)); + let mut cmd = tokio::process::Command::new("powershell.exe"); + cmd.args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let output = tokio::time::timeout(SCHTASKS_TIMEOUT, cmd.output()) + .await + .map_err(|_| eyre!("querying scheduled task {task} timed out"))??; + if !output.status.success() { + bail!( + "querying scheduled task {task} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(parse_query(&String::from_utf8_lossy(&output.stdout))) +} + +fn parse_query(output: &str) -> Option { + let state = output.trim(); + if state.eq_ignore_ascii_case("MISSING") || state.is_empty() { + return None; + } + Some(Query { + running: state.eq_ignore_ascii_case("Running"), + disabled: state.eq_ignore_ascii_case("Disabled"), + }) +} + +/// `SCHED_E_TASK_NOT_RUNNING`: the HRESULT `schtasks /end /HRESULT` exits +/// with when the task has no running instance. +const SCHED_E_TASK_NOT_RUNNING: i32 = 0x8004130Bu32 as i32; + +fn end_error_is_noop(error: &str) -> bool { + let error = error.to_ascii_lowercase(); + error.contains("not running") || error.contains("no running instance") +} + +async fn schtasks(args: &[String]) -> Result<()> { + let (status, printed) = schtasks_output(args).await?; + if !status.success() { + bail!("`schtasks {}` failed: {printed}", shell_words::join(args)); + } + Ok(()) +} + +/// Runs schtasks; its exit status and what it printed (schtasks writes its +/// SUCCESS and ERROR lines to stdout). +async fn schtasks_output(args: &[String]) -> Result<(std::process::ExitStatus, String)> { + debug!("$ schtasks {}", shell_words::join(args)); + let mut cmd = tokio::process::Command::new("schtasks"); + cmd.args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let output = tokio::time::timeout(SCHTASKS_TIMEOUT, cmd.output()) + .await + .map_err(|_| eyre!("`schtasks {}` timed out", shell_words::join(args)))??; + let printed = [ + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ] + .iter() + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) + .collect::>() + .join("; "); + Ok((output.status, printed)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> ScheduledTaskRequest { + let mut request = ScheduledTaskRequest::new("agent"); + request.command = "C:\\Tools\\agent.exe --serve".to_string(); + request.description = Some("My ".to_string()); + request.restart_on_failure = true; + request + } + + #[test] + fn renders_a_logon_task() { + let xml = render_xml(&sample(), "HOST\\me").unwrap(); + assert!(xml.contains("My <agent>")); + assert!(xml.contains( + "\n true\n HOST\\me" + )); + assert!(xml.contains("C:\\Tools\\agent.exe")); + assert!(xml.contains("--serve")); + assert!(xml.contains("")); + assert!(!xml.contains("")); + } + + #[test] + fn environment_goes_through_cmd() { + let mut request = sample(); + request.environment.insert("RUST_LOG".into(), "info".into()); + request.at_logon = false; + request.restart_on_failure = false; + let xml = render_xml(&request, "me").unwrap(); + assert!(xml.contains("cmd.exe")); + assert!(xml.contains( + "/c set "RUST_LOG=info" && C:\\Tools\\agent.exe --serve" + )); + assert!(xml.contains("false\n me")); + assert!(!xml.contains("")); + + let mut request = sample(); + request.command = "\"C:\\Program Files\\x\\a.exe\" --serve".to_string(); + request.environment.insert("A".into(), "1".into()); + let xml = render_xml(&request, "me").unwrap(); + assert!(xml.contains( + "/c set "A=1" && "C:\\Program Files\\x\\a.exe" --serve" + )); + + let mut request = sample(); + request + .environment + .insert("P".into(), "%PATH%;C:\\x".into()); + let err = render_xml(&request, "me").unwrap_err().to_string(); + assert!(err.contains("cmd.exe would reinterpret"), "{err}"); + } + + #[test] + fn tilde_expands_in_the_program() { + let (program, args) = split_command("~/.local/bin/agent --serve"); + assert!(!program.starts_with('~'), "{program}"); + assert!(program.ends_with("agent"), "{program}"); + assert_eq!(args, "--serve"); + } + + #[test] + fn quoted_programs_keep_their_spaces() { + assert_eq!( + split_command("\"C:\\Program Files\\x\\a.exe\" --flag one"), + ( + "C:\\Program Files\\x\\a.exe".to_string(), + "--flag one".to_string() + ) + ); + assert_eq!( + split_command("agent.exe"), + ("agent.exe".to_string(), String::new()) + ); + } + + #[test] + fn definition_is_utf16_with_bom() { + let bytes = render_definition(&sample(), "me").unwrap(); + assert_eq!(&bytes[..2], &[0xFF, 0xFE]); + assert_eq!(&bytes[2..4], &[b'<', 0]); + } + + #[test] + fn parses_query_output() { + let query = parse_query("Running\r\n").unwrap(); + assert!(query.running); + assert!(!query.disabled); + let query = parse_query("Disabled\n").unwrap(); + assert!(!query.running); + assert!(query.disabled); + let query = parse_query("Ready\n").unwrap(); + assert!(!query.running && !query.disabled); + assert!(parse_query("MISSING\n").is_none()); + } + + #[test] + fn desired_state_follows_start() { + let mut status = ScheduledTaskStatus { + request: sample(), + path: PathBuf::from("x"), + state: ScheduledTaskState::Running, + }; + assert!(status.is_desired()); + status.request.start = false; + assert!(!status.is_desired()); + status.state = ScheduledTaskState::Ready; + assert!(status.is_desired()); + status.state = ScheduledTaskState::Differs; + assert!(!status.is_desired()); + } +} diff --git a/src/system/services.rs b/src/system/services.rs index 239d82b9427..997f9038ddd 100644 --- a/src/system/services.rs +++ b/src/system/services.rs @@ -71,7 +71,7 @@ struct ServicePlan { } pub(crate) fn prepare_requests_from_config(config: &Config) -> Result> { - compose_declarations(config)? + compose_system_declarations(config)? .into_iter() .map(|(name, (config, origin))| { ServiceRequest::from_toml_with_origin(name, config, Some(origin)) @@ -219,10 +219,7 @@ impl ServiceRequest { fn desired(&self) -> String { format!( "{}; {}; {}; on change {}", - match self.state { - ServiceState::Running => "running", - ServiceState::Stopped => "stopped", - }, + self.state.as_str(), if self.enabled { "enabled" } else { "disabled" }, if self.masked { "masked" } else { "unmasked" }, match self.on_change { @@ -518,6 +515,8 @@ impl ServiceAction { ServiceChangeAction::None => None, }, ServiceState::Running => Some("start"), + // rejected for system services by `compose_system_declarations` + ServiceState::Absent => Some("stop"), } } } @@ -641,7 +640,7 @@ fn describe_current(active_state: &str, unit_file_state: &str, need_daemon_reloa fn active_state_matches(desired: ServiceState, current: &str) -> bool { match desired { ServiceState::Running => matches!(current, "active" | "reloading"), - ServiceState::Stopped => current == "inactive", + ServiceState::Stopped | ServiceState::Absent => current == "inactive", } } @@ -700,7 +699,7 @@ mod tests { state: ServiceState::Stopped, enabled: false, masked: true, - on_change: ServiceChangeAction::default(), + ..Default::default() }, ) .is_ok() diff --git a/src/system/services_common.rs b/src/system/services_common.rs index b4ccf12e339..e755182d221 100644 --- a/src/system/services_common.rs +++ b/src/system/services_common.rs @@ -17,6 +17,53 @@ pub(crate) enum ServiceState { #[default] Running, Stopped, + /// User scope only: the installed service definition is removed. + Absent, +} + +impl ServiceState { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Running => "running", + Self::Stopped => "stopped", + Self::Absent => "absent", + } + } +} + +/// Which service manager a `[bootstrap.services]` entry targets. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ServiceScope { + /// An existing Linux systemd system unit (the original behaviour). + #[default] + System, + /// A service mise defines for the current user: a systemd user unit on + /// Linux, a LaunchAgent on macOS, a Scheduled Task on Windows. + User, +} + +/// Restart policy of a user-scope service, with one meaning on every platform. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum ServiceRestart { + /// Restart whenever the process exits, even successfully. + Always, + /// Restart only after a failure. + #[default] + OnFailure, + /// Never restart automatically. + Never, +} + +impl ServiceRestart { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Always => "always", + Self::OnFailure => "on-failure", + Self::Never => "never", + } + } } #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] @@ -39,6 +86,30 @@ pub(crate) struct ServiceTomlConfig { pub masked: bool, #[serde(default)] pub on_change: ServiceChangeAction, + /// `"system"` (default) or `"user"`. A `builtin` implies `"user"`. + #[serde(default)] + pub scope: Option, + /// A service definition mise supplies (for example `"history-watch"`). + #[serde(default)] + pub builtin: Option, + /// User scope: the command line to run. + #[serde(default)] + pub command: Option, + /// User scope: a human-readable description. + #[serde(default)] + pub description: Option, + /// User scope: restart policy (default `"on-failure"`). + #[serde(default)] + pub restart: Option, + /// User scope: environment variables for the process. + #[serde(default)] + pub environment: IndexMap, + /// User scope: working directory (`~` is expanded). + #[serde(default)] + pub working_directory: Option, + /// User scope: converge after `[tools]` are installed. + #[serde(default)] + pub requires_tools: bool, } impl Default for ServiceTomlConfig { @@ -48,10 +119,47 @@ impl Default for ServiceTomlConfig { enabled: true, masked: false, on_change: ServiceChangeAction::default(), + scope: None, + builtin: None, + command: None, + description: None, + restart: None, + environment: IndexMap::new(), + working_directory: None, + requires_tools: false, } } } +impl ServiceTomlConfig { + /// The effective scope: explicit `scope`, else `"user"` when a `builtin` + /// is named, else `"system"`. + pub(crate) fn scope(&self) -> ServiceScope { + self.scope.unwrap_or(if self.builtin.is_some() { + ServiceScope::User + } else { + ServiceScope::System + }) + } + + /// Fields that only apply to user-scope services, when set. + fn user_only_fields(&self) -> Vec<&'static str> { + [ + (self.builtin.is_some(), "builtin"), + (self.command.is_some(), "command"), + (self.description.is_some(), "description"), + (self.restart.is_some(), "restart"), + (!self.environment.is_empty(), "environment"), + (self.working_directory.is_some(), "working_directory"), + (self.requires_tools, "requires_tools"), + (self.state == ServiceState::Absent, "state = \"absent\""), + ] + .into_iter() + .filter_map(|(is_set, field)| is_set.then_some(field)) + .collect() + } +} + #[derive(Clone, Debug, Default, Eq, PartialEq)] pub(crate) struct ServiceNotifications { pub(super) sources: IndexMap>, @@ -84,6 +192,44 @@ impl ServiceNotifications { } } +/// The system-scope entries of `[bootstrap.services]`, validated: fields that +/// only apply to user services are rejected here so a typo in `scope` cannot +/// silently turn a user service into a lookup of a system unit. +pub(crate) fn compose_system_declarations( + config: &Config, +) -> Result> { + let mut out = IndexMap::new(); + for (name, (declaration, origin)) in compose_declarations(config)? { + if declaration.scope() != ServiceScope::System { + continue; + } + let user_only = declaration.user_only_fields(); + if !user_only.is_empty() { + bail!( + "bootstrap service '{name}' sets {}, which only applies to `scope = \"user\"` services", + user_only.join(", ") + ); + } + out.insert(name, (declaration, origin)); + } + Ok(out) +} + +/// The user-scope entries of `[bootstrap.services]`. +pub(crate) fn compose_user_declarations( + config: &Config, +) -> Result> { + Ok(compose_declarations(config)? + .into_iter() + .filter(|(_, (declaration, _))| declaration.scope() == ServiceScope::User) + .collect()) +} + +/// Names of every user-scope service, for notification validation. +pub(crate) fn user_service_names(config: &Config) -> Result> { + Ok(compose_user_declarations(config)?.into_keys().collect()) +} + /// `[bootstrap.services]` from every config map, rejecting redeclarations that /// disagree. pub(crate) fn compose_declarations( @@ -134,6 +280,7 @@ pub(crate) fn validate_notifications( files: &[super::managed_files::ManagedFileRequest], directories: &[super::managed_files::ManagedDirectoryRequest], services: &[ServiceRequest], + user_services: &[String], ) -> Result<()> { let configured = services .iter() @@ -153,6 +300,13 @@ pub(crate) fn validate_notifications( .map(move |notification| (directory.path.as_path(), notification)) })) { + if user_services.iter().any(|name| name == notification) { + bail!( + "managed path '{}' notifies user-scope bootstrap service '{}'; notifications apply to system services only", + resource.display(), + notification + ); + } if !configured.contains(notification.as_str()) { bail!( "managed path '{}' notifies unconfigured bootstrap service '{}'", diff --git a/src/system/services_non_linux.rs b/src/system/services_non_linux.rs index 662b40b87cf..74fa04ed7a0 100644 --- a/src/system/services_non_linux.rs +++ b/src/system/services_non_linux.rs @@ -11,7 +11,7 @@ pub(crate) struct ServiceRequest { } pub(crate) fn prepare_requests_from_config(config: &Config) -> Result> { - Ok(compose_declarations(config)? + Ok(compose_system_declarations(config)? .into_iter() .map(|(name, _)| ServiceRequest { name }) .collect()) @@ -52,12 +52,9 @@ pub(crate) fn apply_privileged_plan_from_stdin() -> Result<()> { } fn reject_configured(config: &Config) -> Result> { - let configured = config.bootstrap_config_maps().any(|config_files| { - config_files.values().any(|cf| { - cf.bootstrap_config() - .is_some_and(|bootstrap| !bootstrap.services.is_empty()) - }) - }); + // validated like on Linux first, so a forgotten `scope = "user"` is + // reported as such rather than as a platform limitation + let configured = !compose_system_declarations(config)?.is_empty(); if configured { bail!("bootstrap system services are only supported on Linux"); } diff --git a/src/system/systemd.rs b/src/system/systemd.rs index 69b4712aaec..fc260600ee9 100644 --- a/src/system/systemd.rs +++ b/src/system/systemd.rs @@ -479,6 +479,53 @@ pub(crate) async fn apply(requests: &[SystemdRequest], dry_run: bool) -> Result< Ok(()) } +/// Stop, disable, and delete the service unit mise wrote for `name` +/// (`dev.mise..service`), then reload the user manager. Returns whether +/// a unit file existed. +pub(crate) async fn remove_service(name: &str, dry_run: bool) -> Result { + let unit = format!("dev.mise.{name}.service"); + let path = user_units_dir().join(&unit); + if !path.exists() { + return Ok(false); + } + if dry_run { + for verb in ["stop", "disable"] { + miseprintln!( + "{}", + shell_words::join([ + "systemctl".to_string(), + "--user".to_string(), + verb.to_string(), + unit.clone(), + ]) + ); + } + miseprintln!( + "{}", + shell_words::join(["rm".to_string(), path.display().to_string()]) + ); + miseprintln!( + "{}", + shell_words::join([ + "systemctl".to_string(), + "--user".to_string(), + "daemon-reload".to_string(), + ]) + ); + return Ok(true); + } + stop_unit(&unit).await?; + disable_unit(&unit).await?; + std::fs::remove_file(&path)?; + systemctl(&["daemon-reload".to_string()]).await?; + Ok(true) +} + +/// The unit file path mise uses for a service named `name`. +pub(crate) fn service_unit_path(name: &str) -> PathBuf { + user_units_dir().join(format!("dev.mise.{name}.service")) +} + pub(crate) fn render_unit(request: &SystemdRequest) -> String { let mut out = String::new(); out.push_str("[Unit]\n"); @@ -511,13 +558,13 @@ fn render_service(request: &SystemdRequest, out: &mut String) { out.push_str(&format!("Type={service_type}\n")); } if let Some(exec_start) = &request.exec_start { - out.push_str(&format!("ExecStart={}\n", expand_path_string(exec_start))); + out.push_str(&format!("ExecStart={}\n", expand_exec_string(exec_start))); } if let Some(remain_after_exit) = request.remain_after_exit { out.push_str(&format!("RemainAfterExit={}\n", yes_no(remain_after_exit))); } if let Some(exec_stop) = &request.exec_stop { - out.push_str(&format!("ExecStop={}\n", expand_path_string(exec_stop))); + out.push_str(&format!("ExecStop={}\n", expand_exec_string(exec_stop))); } if let Some(timeout_start_sec) = &request.timeout_start_sec { out.push_str(&format!("TimeoutStartSec={timeout_start_sec}\n")); @@ -661,6 +708,25 @@ fn sibling_unit_path(request: &SystemdRequest) -> PathBuf { user_units_dir().join(sibling_unit(request)) } +/// Expand the home prefix of a quoted executable without re-tokenizing +/// systemd's command syntax or changing the arguments that follow it. +fn expand_exec_string(command: &str) -> String { + for quote in ['\'', '"'] { + if let Some(rest) = command.strip_prefix(quote) + && let Some(rest) = rest.strip_prefix("~/") + { + // Replace only the home prefix. The executable's existing escapes, + // closing quote and all arguments retain systemd's original syntax. + let home = crate::dirs::HOME + .to_string_lossy() + .replace('\\', "\\\\") + .replace(quote, &format!("\\{quote}")); + return format!("{quote}{home}/{rest}"); + } + } + expand_path_string(command) +} + fn expand_path_string(path: &str) -> String { if path == "~" { return crate::dirs::HOME.to_string_lossy().to_string(); @@ -831,6 +897,31 @@ fn unit_operation_error_is_noop(error: &str) -> bool { #[cfg(test)] mod tests { + #[test] + fn quoted_executable_home_expands_without_changing_arguments() { + for quote in ['\'', '"'] { + let command = format!("{quote}~/.local/my agent{quote} --serve '$VALUE' %i"); + assert_eq!( + super::expand_exec_string(&command), + format!( + "{quote}{}{quote} --serve '$VALUE' %i", + super::expand_path_string("~/.local/my agent") + ) + ); + } + assert_eq!( + super::expand_exec_string("/bin/echo '~/literal'"), + "/bin/echo '~/literal'" + ); + for quote in ['\'', '"'] { + let rest = format!(".local/agent\\{quote}name\\x20bin{quote} --serve %i"); + assert_eq!( + super::expand_exec_string(&format!("{quote}~/{rest}")), + format!("{quote}{}/{rest}", crate::dirs::HOME.display()) + ); + } + } + use super::*; #[test] diff --git a/src/system/user_services.rs b/src/system/user_services.rs new file mode 100644 index 00000000000..4994572963c --- /dev/null +++ b/src/system/user_services.rs @@ -0,0 +1,886 @@ +//! User-scope services from `[bootstrap.services]` (`scope = "user"`). +//! +//! One declaration is rendered for the platform's user service manager: a +//! systemd user unit on Linux (`dev.mise..service`), a LaunchAgent on +//! macOS (`dev.mise.`), a Scheduled Task on Windows (`mise\`). +//! `builtin = ""` selects a definition mise supplies, run through a +//! durable mise executable. + +use std::path::{Path, PathBuf}; + +use eyre::{Result, bail}; +use indexmap::IndexMap; +use serde::Serialize; + +use crate::config::Config; +use crate::system::launchd::{self, LaunchdRequest, LaunchdState, LaunchdTomlConfig}; +use crate::system::resources::{ResourceAction, ResourceId, ResourceOrigin, ResourcePlan}; +use crate::system::scheduled_tasks::{self, ScheduledTaskRequest, ScheduledTaskState}; +use crate::system::services_common::{ + ServiceRestart, ServiceState, ServiceTomlConfig, compose_user_declarations, +}; +use crate::system::systemd::{self, SystemdRequest, SystemdState, SystemdTomlConfig}; + +/// A service definition mise supplies. +struct Builtin { + args: &'static [&'static str], + description: &'static str, + restart: ServiceRestart, + nice: Option, +} + +const BUILTIN_NAMES: &[&str] = &["history-watch"]; + +fn builtin(name: &str) -> Option { + match name { + "history-watch" => Some(Builtin { + args: &["bootstrap", "dotfiles", "watch"], + description: "mise dotfiles history: save tracked files as they change", + restart: ServiceRestart::OnFailure, + nice: Some(10), + }), + _ => None, + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct UserServiceRequest { + pub name: String, + pub description: Option, + /// The resolved command line; `None` when a builtin has no durable + /// executable to run through (see `unresolved`). + pub command: Option, + pub unresolved: Option, + pub builtin: Option, + pub restart: ServiceRestart, + pub nice: Option, + pub environment: IndexMap, + pub working_directory: Option, + pub requires_tools: bool, + pub state: ServiceState, + pub enabled: bool, + pub origin: Option, +} + +impl UserServiceRequest { + pub(crate) fn from_toml( + name: String, + config: ServiceTomlConfig, + origin: Option, + ) -> Result { + Self::from_toml_with_executable(name, config, origin, durable_mise_executable()) + } + + fn from_toml_with_executable( + name: String, + config: ServiceTomlConfig, + origin: Option, + executable: Option, + ) -> Result { + if !valid_name(&name) { + bail!( + "user service name '{name}' must contain only letters, numbers, '.', '_', or '-'" + ); + } + if config.masked { + bail!("user service '{name}' cannot be masked; use `state = \"absent\"` to remove it"); + } + let mut description = config.description; + let mut restart = config.restart; + let mut nice = None; + let mut unresolved = None; + let command = match (config.builtin.as_deref(), config.command.as_deref()) { + (Some(_), Some(_)) => { + bail!("user service '{name}' sets both `builtin` and `command`; choose one") + } + (None, None) => { + bail!("user service '{name}' must set `command` or `builtin`") + } + (Some(builtin_name), None) => { + let Some(definition) = builtin(builtin_name) else { + bail!( + "user service '{name}' names unknown builtin '{builtin_name}'; available: {}", + BUILTIN_NAMES.join(", ") + ); + }; + description.get_or_insert_with(|| definition.description.to_string()); + restart.get_or_insert(definition.restart); + nice = definition.nice; + match executable { + Some(exe) => Some( + std::iter::once(quote_program(&exe.to_string_lossy())) + .chain(definition.args.iter().map(|arg| arg.to_string())) + .collect::>() + .join(" "), + ), + None => { + unresolved = Some( + "no durable mise executable; install mise on this host first" + .to_string(), + ); + None + } + } + } + (None, Some(command)) => { + let command = command.trim(); + if command.is_empty() { + bail!("user service '{name}' must set a non-empty `command`"); + } + Some(command.to_string()) + } + }; + Ok(Self { + name, + description, + command, + unresolved, + builtin: config.builtin, + restart: restart.unwrap_or_default(), + nice, + environment: config.environment, + working_directory: config.working_directory, + requires_tools: config.requires_tools, + state: config.state, + enabled: config.enabled, + origin, + }) + } + + pub(crate) fn desired(&self) -> String { + match self.state { + ServiceState::Absent => "absent".to_string(), + state if self.enabled => state.as_str().to_string(), + state => format!("{} (not at login)", state.as_str()), + } + } + + fn start(&self) -> bool { + self.state == ServiceState::Running + } + + pub(crate) fn systemd_request(&self) -> Result { + let config = SystemdTomlConfig { + description: self.description.clone(), + exec_start: self.command.clone(), + environment: self.environment.clone(), + working_directory: self.working_directory.clone(), + nice: self.nice, + restart: Some( + match self.restart { + ServiceRestart::Always => "always", + ServiceRestart::OnFailure => "on-failure", + ServiceRestart::Never => "no", + } + .to_string(), + ), + restart_sec: Some("5s".to_string()), + start: self.start(), + wanted_by: (!self.enabled).then(Vec::new), + ..Default::default() + }; + SystemdRequest::from_toml(self.name.clone(), config) + } + + pub(crate) fn launchd_request(&self) -> Result { + let command = self.command.clone().unwrap_or_default(); + let mut words = shell_words::split(&command) + .map_err(|err| eyre::eyre!("user service '{}': invalid `command`: {err}", self.name))?; + let program = if words.is_empty() { + None + } else { + Some(words.remove(0)) + }; + let config = LaunchdTomlConfig { + program, + args: words, + run_at_load: self.enabled && self.start(), + // launchd reads any `KeepAlive` as "run at load" too, so a + // disabled agent is written without one: it neither starts at + // login nor is restarted until it is enabled again + keep_alive: self.enabled && self.start() && self.restart == ServiceRestart::Always, + keep_alive_on_failure: self.enabled + && self.start() + && self.restart == ServiceRestart::OnFailure, + environment: self.environment.clone(), + working_directory: self.working_directory.clone(), + kickstart: self.start(), + nice: self.nice, + ..Default::default() + }; + LaunchdRequest::from_toml(self.name.clone(), config) + } + + pub(crate) fn scheduled_task_request(&self) -> ScheduledTaskRequest { + let mut request = ScheduledTaskRequest::new(&self.name); + request.description = self.description.clone(); + request.command = self.command.clone().unwrap_or_default(); + request.restart_on_failure = self.restart != ServiceRestart::Never; + request.environment = self.environment.clone(); + request.working_directory = self.working_directory.clone(); + request.start = self.start(); + request.at_logon = self.enabled; + request.nice = self.nice; + request + } +} + +impl std::fmt::Display for UserServiceRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} (user)", self.name) + } +} + +/// Quote an executable path for the platform's command line: double quotes +/// on Windows (what Task Scheduler and `cmd.exe` understand), POSIX shell +/// quoting elsewhere. +fn quote_program(path: &str) -> String { + if cfg!(windows) { + if path.contains(char::is_whitespace) { + format!("\"{path}\"") + } else { + path.to_string() + } + } else { + shell_words::quote(path).to_string() + } +} + +fn valid_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') +} + +/// Every user-scope service, validated, with names that collide with +/// `[bootstrap.linux.systemd.units]` or `[bootstrap.macos.launchd.agents]` +/// rejected (both would write the same unit or plist). +pub(crate) fn requests_from_config(config: &Config) -> Result> { + let requests = compose_user_declarations(config)? + .into_iter() + .map(|(name, (declaration, origin))| { + UserServiceRequest::from_toml(name, declaration, Some(origin)) + }) + .collect::>>()?; + if requests.is_empty() { + return Ok(requests); + } + let units = crate::system::systemd_from_config(config); + let agents = crate::system::launchd_from_config(config); + for request in &requests { + if units.iter().any(|unit| unit.name == request.name) { + bail!( + "user service '{}' is also declared in [bootstrap.linux.systemd.units]; declare it once", + request.name + ); + } + if agents.iter().any(|agent| agent.name == request.name) { + bail!( + "user service '{}' is also declared in [bootstrap.macos.launchd.agents]; declare it once", + request.name + ); + } + } + Ok(requests) +} + +/// The mise executable a service definition may reference: the running +/// binary unless it lives in a temporary or remote-bootstrap staging +/// directory, else a `mise` on `PATH` outside those. Durability is judged +/// by where the binary really is, but the path kept is the one found: a +/// Homebrew or package-manager symlink survives an upgrade, the versioned +/// file behind it does not. +pub(crate) fn durable_mise_executable() -> Option { + let current = crate::env::MISE_BIN.clone(); + if durable_behind(¤t) { + return Some(current); + } + // every `mise` on PATH, not only the first: a staged binary earlier on + // PATH (a remote bootstrap) must not hide a durable one behind it; on + // Windows that is `mise.exe` + let names = crate::file::executable_names("mise"); + crate::env::PATH + .iter() + .flat_map(|dir| names.iter().map(move |name| dir.join(name))) + .filter(|candidate| candidate.is_file() && crate::file::is_executable(candidate)) + .find(|candidate| durable_behind(candidate)) +} + +/// Whether a path may be embedded in a service definition: absolute and +/// durable itself (a link inside a staging directory is not, whatever it +/// points at), and leading (through any links) to a durable file. +fn durable_behind(path: &Path) -> bool { + path.is_absolute() + && is_durable(path) + && std::fs::canonicalize(path).is_ok_and(|real| is_durable(&real)) +} + +fn is_durable(path: &Path) -> bool { + let temp = std::env::temp_dir(); + let temp = std::fs::canonicalize(&temp).unwrap_or(temp); + if path.starts_with(&temp) { + return false; + } + let text = path.to_string_lossy(); + !(text.contains("/mise-bootstrap.") || text.contains("\\mise-bootstrap.")) +} + +pub(crate) fn is_available() -> bool { + if cfg!(target_os = "linux") { + systemd::is_available() + } else if cfg!(target_os = "macos") { + launchd::is_available() + } else if cfg!(windows) { + scheduled_tasks::is_available() + } else { + false + } +} + +pub(crate) fn unavailable_reason() -> String { + if cfg!(target_os = "linux") { + systemd::unavailable_reason() + } else if cfg!(target_os = "macos") { + launchd::unavailable_reason() + } else if cfg!(windows) { + scheduled_tasks::unavailable_reason() + } else { + "user services are only supported on linux, macos, and windows".to_string() + } +} + +/// The name of the platform's user service manager, for messages. +pub(crate) fn manager_name() -> &'static str { + if cfg!(target_os = "linux") { + "systemd user unit" + } else if cfg!(target_os = "macos") { + "LaunchAgent" + } else { + "Scheduled Task" + } +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct UserServiceStatus { + pub name: String, + pub scope: &'static str, + pub current: String, + pub desired: String, + pub action: ResourceAction, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub builtin: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + pub requires_tools: bool, + pub restart: &'static str, + /// The definition mise renders for this platform, for inspection. + #[serde(skip_serializing_if = "Option::is_none")] + pub definition: Option, + #[serde(skip)] + pub request: UserServiceRequest, +} + +impl UserServiceStatus { + pub(crate) fn plan(&self) -> ResourcePlan { + let plan = ResourcePlan::new( + ResourceId::new("user-service", &self.name), + self.current.clone(), + self.desired.clone(), + self.action, + ); + match &self.request.origin { + Some(origin) => plan.with_origin(origin.clone()), + None => plan, + } + } + + fn new(request: &UserServiceRequest, current: String, action: ResourceAction) -> Self { + Self { + name: request.name.clone(), + scope: "user", + current, + desired: request.desired(), + action, + path: None, + builtin: request.builtin.clone(), + command: request.command.clone(), + requires_tools: request.requires_tools, + restart: request.restart.as_str(), + definition: None, + request: request.clone(), + } + } +} + +pub(crate) async fn status(requests: &[UserServiceRequest]) -> Result> { + let mut out = vec![]; + for request in requests { + out.push(status_one(request).await?); + } + Ok(out) +} + +/// The definition mise would install for this platform, for inspection. +fn render_definition(request: &UserServiceRequest) -> Result { + if cfg!(target_os = "linux") { + Ok(systemd::render_unit(&request.systemd_request()?)) + } else if cfg!(target_os = "macos") { + let plist = launchd::render_plist(&request.launchd_request()?)?; + Ok(String::from_utf8_lossy(&plist).to_string()) + } else { + scheduled_tasks::render_xml(&request.scheduled_task_request(), "") + } +} + +fn definition_path(name: &str) -> PathBuf { + if cfg!(target_os = "linux") { + systemd::service_unit_path(name) + } else if cfg!(target_os = "macos") { + launchd::agent_plist_path(name) + } else { + scheduled_tasks::definition_path(name) + } +} + +async fn status_one(request: &UserServiceRequest) -> Result { + let path = definition_path(&request.name); + // removal needs no command: an absent builtin is removed even when no + // durable executable resolves + if request.state == ServiceState::Absent { + if !is_available() { + let mut out = UserServiceStatus::new( + request, + format!("unavailable: {}", unavailable_reason()), + ResourceAction::Unknown, + ); + out.path = Some(path); + return Ok(out); + } + let installed = if cfg!(windows) { + scheduled_tasks::exists(&request.name).await? + } else { + path.exists() + }; + let (current, action) = absent_state(installed); + let mut out = UserServiceStatus::new(request, current.to_string(), action); + out.path = Some(path); + return Ok(out); + } + if let Some(reason) = &request.unresolved { + return Ok(UserServiceStatus::new( + request, + format!("unknown: {reason}"), + ResourceAction::Unknown, + )); + } + let definition = render_definition(request)?; + if !is_available() { + let mut out = UserServiceStatus::new( + request, + format!("unavailable: {}", unavailable_reason()), + ResourceAction::Unknown, + ); + out.path = Some(path); + out.definition = Some(definition); + return Ok(out); + } + let (current, action) = if cfg!(target_os = "linux") { + let unit = request.systemd_request()?; + { + let status = systemd::status(std::slice::from_ref(&unit)) + .await? + .pop() + .expect("one status per request"); + let current = match status.state { + SystemdState::Missing => "not installed", + SystemdState::Differs => "installed, differs", + SystemdState::Active => "running", + SystemdState::Inactive => "stopped", + }; + ( + current, + converge_action(status.is_desired(), status.state == SystemdState::Missing), + ) + } + } else if cfg!(target_os = "macos") { + let agent = request.launchd_request()?; + { + let status = launchd::status(std::slice::from_ref(&agent)) + .await? + .pop() + .expect("one status per request"); + let running = status.loaded && launchd::is_running(&agent.label).await?; + let (current, desired) = match status.state { + LaunchdState::Missing => ("not installed", false), + LaunchdState::Differs => ("installed, differs", false), + LaunchdState::Unloaded => ("installed, not loaded", false), + LaunchdState::Loaded if running => ("running", request.start()), + LaunchdState::Loaded => ("stopped", !request.start()), + }; + ( + current, + converge_action(desired, status.state == LaunchdState::Missing), + ) + } + } else { + let task = request.scheduled_task_request(); + { + let status = scheduled_tasks::status(std::slice::from_ref(&task)) + .await? + .pop() + .expect("one status per request"); + let current = match status.state { + ScheduledTaskState::Missing => "not installed", + ScheduledTaskState::Differs => "installed, differs", + ScheduledTaskState::Disabled => "installed, disabled", + ScheduledTaskState::Running => "running", + ScheduledTaskState::Ready => "stopped", + }; + ( + current, + converge_action( + status.is_desired(), + status.state == ScheduledTaskState::Missing, + ), + ) + } + }; + let mut out = UserServiceStatus::new(request, current.to_string(), action); + out.path = Some(path); + out.definition = Some(definition); + Ok(out) +} + +fn absent_state(installed: bool) -> (&'static str, ResourceAction) { + if installed { + ("installed", ResourceAction::Remove) + } else { + ("absent", ResourceAction::Noop) + } +} + +fn converge_action(desired: bool, missing: bool) -> ResourceAction { + if desired { + ResourceAction::Noop + } else if missing { + ResourceAction::Create + } else { + ResourceAction::Update + } +} + +/// Converge the given user services. Returns a reason when the platform's +/// user service manager is unavailable and nothing was applied. +pub(crate) async fn apply( + requests: &[UserServiceRequest], + dry_run: bool, + yes: bool, +) -> Result> { + if requests.is_empty() { + return Ok(None); + } + if !is_available() { + let reason = unavailable_reason(); + debug!("user services: skipping, {reason}"); + return Ok(Some(reason)); + } + if requests + .iter() + .any(|request| request.builtin.as_deref() == Some("history-watch")) + { + crate::config::Settings::get().ensure_experimental("dotfile tracking")?; + } + let statuses = status(requests).await?; + let mut targets = vec![]; + let mut skipped = 0; + for status in &statuses { + match status.action { + ResourceAction::Noop => {} + ResourceAction::Unknown => { + skipped += 1; + warn!( + "user service {}: {}; not written", + status.name, status.current + ); + } + _ => targets.push(status.request.clone()), + } + } + let applied = statuses.len() - targets.len() - skipped; + if applied > 0 { + info!("user services: {applied} service(s) already applied"); + } + if targets.is_empty() { + return Ok(None); + } + let list = targets.iter().map(|r| r.name.clone()).collect::>(); + if !dry_run && !yes && console::user_attended_stderr() { + let msg = format!("user services: apply {}?", list.join(", ")); + if !crate::ui::prompt::confirm(msg)?.is_yes() { + info!("user services: skipped"); + return Ok(None); + } + } + for request in &targets { + apply_one(request, dry_run).await?; + } + if !dry_run { + info!("user services: applied {}", list.join(", ")); + } + Ok(None) +} + +async fn apply_one(request: &UserServiceRequest, dry_run: bool) -> Result<()> { + if request.state == ServiceState::Absent { + remove_named(&request.name, dry_run).await?; + return Ok(()); + } + if cfg!(target_os = "linux") { + systemd::apply(&[request.systemd_request()?], dry_run).await + } else if cfg!(target_os = "macos") { + launchd::apply(&[request.launchd_request()?], dry_run).await + } else { + scheduled_tasks::apply(&[request.scheduled_task_request()], dry_run).await + } +} + +/// Remove the installed definition for `name`, declared or not. Returns +/// whether one existed. +pub(crate) async fn remove_named(name: &str, dry_run: bool) -> Result { + if !valid_name(name) { + bail!("user service name '{name}' must contain only letters, numbers, '.', '_', or '-'"); + } + if !is_available() { + bail!( + "cannot remove user service '{name}': {}", + unavailable_reason() + ); + } + if cfg!(target_os = "linux") { + systemd::remove_service(name, dry_run).await + } else if cfg!(target_os = "macos") { + launchd::remove_agent(name, dry_run).await + } else { + scheduled_tasks::remove_task(name, dry_run).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::system::services_common::ServiceScope; + + fn user_config(command: &str) -> ServiceTomlConfig { + ServiceTomlConfig { + scope: Some(ServiceScope::User), + command: Some(command.to_string()), + ..Default::default() + } + } + + fn request(config: ServiceTomlConfig) -> UserServiceRequest { + UserServiceRequest::from_toml_with_executable( + "agent".to_string(), + config, + None, + Some(PathBuf::from("/usr/bin/mise")), + ) + .unwrap() + } + + #[test] + fn requires_a_command_or_builtin() { + let err = request_result(ServiceTomlConfig { + scope: Some(ServiceScope::User), + ..Default::default() + }) + .unwrap_err(); + assert!(err.to_string().contains("must set `command` or `builtin`")); + let err = request_result(ServiceTomlConfig { + builtin: Some("history-watch".into()), + command: Some("x".into()), + ..Default::default() + }) + .unwrap_err(); + assert!(err.to_string().contains("both `builtin` and `command`")); + let err = request_result(ServiceTomlConfig { + builtin: Some("nope".into()), + ..Default::default() + }) + .unwrap_err(); + assert!( + err.to_string() + .contains("unknown builtin 'nope'; available: history-watch") + ); + let err = request_result(ServiceTomlConfig { + masked: true, + ..user_config("agent") + }) + .unwrap_err(); + assert!(err.to_string().contains("cannot be masked")); + } + + fn request_result(config: ServiceTomlConfig) -> Result { + UserServiceRequest::from_toml_with_executable( + "agent".to_string(), + config, + None, + Some(PathBuf::from("/usr/bin/mise")), + ) + } + + #[test] + fn builtin_expands_through_the_durable_executable() { + let request = request(ServiceTomlConfig { + builtin: Some("history-watch".into()), + ..Default::default() + }); + assert_eq!( + request.command.as_deref(), + Some("/usr/bin/mise bootstrap dotfiles watch") + ); + assert_eq!(request.restart, ServiceRestart::OnFailure); + assert_eq!(request.nice, Some(10)); + assert!(request.description.is_some()); + assert!(request.unresolved.is_none()); + + let staged = UserServiceRequest::from_toml_with_executable( + "agent".to_string(), + ServiceTomlConfig { + builtin: Some("history-watch".into()), + ..Default::default() + }, + None, + None, + ) + .unwrap(); + assert!(staged.command.is_none()); + assert!( + staged + .unresolved + .as_deref() + .unwrap() + .contains("no durable mise executable") + ); + } + + #[test] + fn staged_binaries_are_not_durable() { + let temp = std::env::temp_dir(); + let temp = std::fs::canonicalize(&temp).unwrap_or(temp); + assert!(!is_durable( + &temp.join("mise-bootstrap.abc123").join("mise") + )); + assert!(!is_durable(Path::new("/tmp/mise-bootstrap.abc123/mise"))); + assert!(is_durable(Path::new("/usr/bin/mise"))); + assert!(is_durable(Path::new("/home/me/.local/bin/mise"))); + } + + #[test] + fn renders_a_systemd_unit() { + let mut config = user_config("~/.local/bin/agent --serve"); + config.restart = Some(ServiceRestart::Always); + config.environment.insert("RUST_LOG".into(), "info".into()); + config.working_directory = Some("~".into()); + let unit = systemd::render_unit(&request(config).systemd_request().unwrap()); + assert!(unit.contains("ExecStart=")); + assert!(unit.contains("agent --serve\n")); + assert!(unit.contains("Restart=always\n")); + assert!(unit.contains("RestartSec=5s\n")); + assert!(unit.contains("Environment=\"RUST_LOG=info\"\n")); + assert!(unit.contains("WorkingDirectory=")); + assert!(unit.contains("WantedBy=default.target\n")); + + let mut config = user_config("agent"); + config.enabled = false; + config.restart = Some(ServiceRestart::Never); + let unit = systemd::render_unit(&request(config).systemd_request().unwrap()); + assert!(unit.contains("Restart=no\n")); + assert!(!unit.contains("[Install]")); + } + + #[test] + fn renders_a_launch_agent() { + let mut config = user_config("\"/Applications/My Agent.app/agent\" --serve"); + config.environment.insert("RUST_LOG".into(), "info".into()); + let agent = request(config).launchd_request().unwrap(); + assert_eq!(agent.program, "/Applications/My Agent.app/agent"); + assert_eq!(agent.args, vec!["--serve".to_string()]); + assert!(agent.run_at_load); + assert!(agent.kickstart); + assert!(!agent.keep_alive); + assert!(agent.keep_alive_on_failure); + let plist = String::from_utf8(launchd::render_plist(&agent).unwrap()).unwrap(); + assert!(plist.contains("SuccessfulExit")); + assert!(plist.contains("RUST_LOG")); + + let mut config = user_config("agent"); + config.state = ServiceState::Stopped; + config.restart = Some(ServiceRestart::Always); + let agent = request(config).launchd_request().unwrap(); + assert!(!agent.run_at_load); + assert!(!agent.kickstart); + assert!(!agent.keep_alive); + assert!(!agent.keep_alive_on_failure); + + // a disabled agent carries no KeepAlive either: launchd would read + // it as RunAtLoad and start the agent at login + let mut config = user_config("agent"); + config.enabled = false; + let agent = request(config).launchd_request().unwrap(); + assert!(!agent.run_at_load); + assert!(agent.kickstart); + assert!(!agent.keep_alive); + assert!(!agent.keep_alive_on_failure); + let plist = String::from_utf8(launchd::render_plist(&agent).unwrap()).unwrap(); + assert!(!plist.contains("KeepAlive")); + assert!(!plist.contains("RunAtLoad")); + } + + #[test] + fn renders_a_scheduled_task() { + let mut config = user_config("C:\\Tools\\agent.exe --serve"); + config.enabled = false; + config.restart = Some(ServiceRestart::Never); + let task = request(config).scheduled_task_request(); + assert_eq!(task.task, "mise\\agent"); + assert!(!task.at_logon); + assert!(!task.restart_on_failure); + assert!(task.start); + let xml = scheduled_tasks::render_xml(&task, "me").unwrap(); + assert!(xml.contains("C:\\Tools\\agent.exe")); + } + + #[test] + fn windows_restarts_failed_runs_only_for_always_and_on_failure() { + // Task Scheduler cannot restart a clean exit: both policies become + // "restart failed runs" (documented), and `never` sets nothing + for restart in [ServiceRestart::Always, ServiceRestart::OnFailure] { + let mut config = user_config("agent --serve"); + config.restart = Some(restart); + let task = request(config).scheduled_task_request(); + assert!(task.restart_on_failure, "{restart:?}"); + let xml = scheduled_tasks::render_xml(&task, "me").unwrap(); + assert!(xml.contains(""), "{restart:?}"); + } + let mut config = user_config("agent --serve"); + config.restart = Some(ServiceRestart::Never); + let task = request(config).scheduled_task_request(); + let xml = scheduled_tasks::render_xml(&task, "me").unwrap(); + assert!(!xml.contains("")); + } + + #[test] + fn desired_state_is_readable() { + assert_eq!(request(user_config("agent")).desired(), "running"); + let mut config = user_config("agent"); + config.enabled = false; + assert_eq!(request(config).desired(), "running (not at login)"); + let mut config = user_config("agent"); + config.state = ServiceState::Absent; + assert_eq!(request(config).desired(), "absent"); + } +}