Found provisioning spaceship-earth: - Login was not idempotent: `multica auth status` prints to stderr (0.6.5), and the guard read stdout only, so every run re-logged in and restarted the daemon. The guard now reads both streams; the status read is no_log because it echoes a token prefix. - `multica daemon start` / `daemon restart` fork the daemon inside the caller's session (Ansible's SSH session scope), outside the systemd user unit, which stays inactive: the same orphan seen on haunted-mansion on 2026-10-21. Start/restart now go through `systemctl --user`, after a daemon-reload when autostart was (re)registered. A daemon found running outside its unit is stopped first. The unit name is read from `autostart status`, so --profile hosts resolve their own unit. spaceship-earth: second run changed=0, daemon in multica-daemon.service. Not yet run against haunted-mansion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
411 lines
16 KiB
Markdown
411 lines
16 KiB
Markdown
# multica_agent_host
|
|
|
|
Provisions a Multica agent host from bare Linux to a **registered, running
|
|
daemon with its provider runtimes online**, and re-runs cleanly on a host that
|
|
is already provisioned.
|
|
|
|
Reaches "daemon running, runtimes registered" unattended. The one step it
|
|
cannot do is the provider CLI's own subscription login, which needs a browser
|
|
once per host.
|
|
|
|
## If your repo runs ansible-lint
|
|
|
|
This role keeps the variable names from its spec (`multica_version`,
|
|
`multica_server_url`, `multica_workspaces_root`, ...), which match the
|
|
product's own `MULTICA_*` namespace. ansible-lint's `var-naming[no-role-prefix]`
|
|
wants them prefixed with the role name instead — `multica_agent_host_multica_version`
|
|
at every call site.
|
|
|
|
The role ships the exemption at `roles/multica_agent_host/.ansible-lint`, but
|
|
**ansible-lint only reads that file from the directory you invoke it in**. If
|
|
you lint from your repo root, copy these two lines into your repo's own
|
|
`.ansible-lint`:
|
|
|
|
```yaml
|
|
skip_list:
|
|
- var-naming[no-role-prefix]
|
|
```
|
|
|
|
Without it a repo-root lint run reports ~78 violations against a role that is
|
|
otherwise clean at the `production` profile.
|
|
|
|
## What it does
|
|
|
|
| Step | Handled |
|
|
|---|---|
|
|
| Dedicated `multica` service account (real home, linger) | yes |
|
|
| Node.js, pinned, from nodejs.org with SHASUMS256 verification | yes, when an npm-installed agent CLI is selected |
|
|
| `multica` CLI, pinned, from the GitHub release with `checksums.txt` verification | yes |
|
|
| Agent CLIs (`claude`, `hermes`, …) | yes |
|
|
| `workspaces_root`, on the intended mount, writable by the daemon user | yes |
|
|
| Multica authentication (`multica login --token`) | yes |
|
|
| CLI config (`server_url`, `app_url`, `workspaces_root`, `device_name`, …) | yes |
|
|
| Boot autostart (systemd user unit + `loginctl enable-linger`) | yes |
|
|
| Daemon start | yes |
|
|
| Runtime registration | follows automatically |
|
|
| **Provider CLI subscription login (e.g. `claude`)** | **no — browser, once per host** |
|
|
| Provider CLI credentials from a secret manager | out of scope; the role reports it as a remaining step |
|
|
|
|
## Requirements
|
|
|
|
- Linux target, `x86_64` or `aarch64`.
|
|
- systemd, for boot autostart. Without it the CLI falls back to an XDG autostart
|
|
entry, which does not help a headless host — set `multica_autostart: false`
|
|
and supervise the daemon yourself.
|
|
- Privilege escalation on the target (`become: true`).
|
|
- Ansible 2.14+.
|
|
|
|
## Secrets
|
|
|
|
**The role does not know where your secrets live.** It consumes one plain
|
|
variable:
|
|
|
|
```yaml
|
|
multica_pat: "{{ ... }}" # mul_… user PAT, or mcn_… Cloud Node PAT
|
|
```
|
|
|
|
The playbook supplies it however your environment already does. In this
|
|
homelab that means an **`ansible-vault` encrypted var**, which is what the
|
|
example playbook uses; a `community.hashi_vault` lookup, a 1Password lookup and
|
|
a plain environment variable are written out beside it as commented
|
|
alternatives.
|
|
|
|
See [Getting and storing the token](#getting-and-storing-the-token) below for
|
|
the end-to-end procedure.
|
|
|
|
Three consequences worth stating:
|
|
|
|
1. The role is reusable across environments that do not share a secrets backend.
|
|
2. Consolidating providers later is a one-line playbook change; this role is
|
|
untouched.
|
|
3. It sidesteps the HashiCorp Vault / `ansible-vault` ambiguity entirely — a
|
|
role that takes a value rather than fetching one works with either today.
|
|
|
|
`no_log: true` is set on the login task regardless of where the token comes
|
|
from. Ansible logs command arguments by default; without it the PAT lands in
|
|
play output, in any CI log, and in every callback plugin.
|
|
|
|
This matters just as much with `ansible-vault` as with a remote lookup: an
|
|
encrypted var is decrypted in memory for the duration of the play, so
|
|
encryption at rest does nothing for the runtime path. `no_log` is what covers
|
|
it.
|
|
|
|
**Prefer a Cloud Node PAT (`mcn_…`) over a user PAT** where its scope is
|
|
narrower — a daemon host does not need everything a person can do. Confirm the
|
|
scope difference against your Multica deployment before deciding.
|
|
|
|
## Getting and storing the token
|
|
|
|
### Minting it
|
|
|
|
**There is no CLI command for this.** `multica user` only manages your profile
|
|
and `multica auth` only reports status and logs out; nothing in the command
|
|
tree mints a token of either kind — the CLI only ever consumes one. A PAT is
|
|
created in the Multica web app under account settings, in a Tokens section.
|
|
(The exact menu path could not be confirmed from a headless runtime, so treat
|
|
that as "where to look", not a click path.)
|
|
|
|
Two prefixes exist, and `multica login --token` accepts either:
|
|
|
|
| Prefix | Kind |
|
|
|---|---|
|
|
| `mul_` | User PAT — acts as you, with everything your account can reach |
|
|
| `mcn_` | "Cloud Node" PAT |
|
|
|
|
**On a self-hosted server, check whether `mcn_` is offered at all before
|
|
planning around it.** The string `mcn_` appears in exactly one place in the
|
|
shipped CLI — the `login --token` help text — and the only other place the
|
|
product uses "Cloud" as a noun is `multica setup cloud`, which points at
|
|
Multica Cloud (multica.ai) as opposed to `multica setup self-host`. That is
|
|
suggestive, not conclusive: Cloud Node PATs may well be a Multica Cloud
|
|
concept with no equivalent on a self-hosted deployment. The web UI's token
|
|
screen is the authority — whatever token types it offers you is the real
|
|
answer.
|
|
|
|
Either prefix satisfies the role; preflight validates both.
|
|
|
|
### Scoping a token on a self-hosted deployment
|
|
|
|
If `mul_` is the only kind available, a user PAT on a long-lived daemon host
|
|
means every run that host claims carries your full authority, and revoking it
|
|
later cuts off your own access too.
|
|
|
|
The lever that does exist is a **dedicated service member**. Workspace roles
|
|
are `owner`, `admin` and `member`:
|
|
|
|
```bash
|
|
multica workspace member list --output json # see who exists and their roles
|
|
multica workspace member invite agents@example.com --role member
|
|
```
|
|
|
|
Have that account accept the invitation, log in as it, mint *its* PAT, and give
|
|
that to the host. `member` is coarser than a purpose-built node credential, but
|
|
it is strictly narrower than `owner`, and revoking it touches nothing you use.
|
|
|
|
Do **not** reuse the session token your desktop CLI already holds. It is not a
|
|
PAT, the server renews it on its own schedule, and it ties the host's identity
|
|
to your desktop login — revoking one breaks the other.
|
|
|
|
### Storing it with ansible-vault
|
|
|
|
One-time, if you do not already have a vault password file:
|
|
|
|
```bash
|
|
mkdir -p ~/.ansible
|
|
openssl rand -base64 48 > ~/.ansible/vault-pass-homelab
|
|
chmod 600 ~/.ansible/vault-pass-homelab
|
|
```
|
|
|
|
Point `ansible.cfg` at it so no play needs `--ask-vault-pass`:
|
|
|
|
```ini
|
|
[defaults]
|
|
vault_password_file = ~/.ansible/vault-pass-homelab
|
|
```
|
|
|
|
Then create the encrypted var file:
|
|
|
|
```bash
|
|
mkdir -p group_vars/multica_agent_hosts
|
|
ansible-vault create group_vars/multica_agent_hosts/vault.yml
|
|
```
|
|
|
|
with a single line in it:
|
|
|
|
```yaml
|
|
vault_multica_pat: "mul_...."
|
|
```
|
|
|
|
The playbook already reads `multica_pat: "{{ vault_multica_pat }}"`. The
|
|
indirection is deliberate: the `vault_` prefix makes it obvious at a glance
|
|
which variables are encrypted, and it keeps the name greppable without
|
|
decrypting anything.
|
|
|
|
**Alternative, if you would rather not keep a whole encrypted file:** encrypt
|
|
just the string and paste the block into an ordinary `group_vars` file.
|
|
|
|
```bash
|
|
ansible-vault encrypt_string --stdin-name 'vault_multica_pat'
|
|
```
|
|
|
|
Paste the token, press Ctrl-D. Because it reads stdin, the token never enters
|
|
your shell history — which `ansible-vault encrypt_string 'mul_...'` as an
|
|
argument would.
|
|
|
|
### Checks worth running once
|
|
|
|
```bash
|
|
# the file is actually encrypted, not plaintext you forgot to encrypt
|
|
head -1 group_vars/multica_agent_hosts/vault.yml # => $ANSIBLE_VAULT;1.1;AES256
|
|
|
|
# the var resolves and the role's prefix check will pass, without printing it
|
|
ansible -i inventory/hosts.yml multica_agent_hosts -m debug \
|
|
-a 'msg="pat resolves: {{ (vault_multica_pat | default("")) is match("^(mul_|mcn_)") }}"'
|
|
```
|
|
|
|
That second command prints only `true` or `false`, never the token.
|
|
|
|
Add the vault password file to `.gitignore` if it lives anywhere near the repo.
|
|
The encrypted `vault.yml` is safe to commit; the password file never is.
|
|
|
|
## Ansible owns the version
|
|
|
|
`multica_version` is pinned and `disable_auto_update` is set to `true` in CLI
|
|
config. Re-running the play **is** the upgrade path. Without this the CLI
|
|
self-updates out from under the role's declared state and the host silently
|
|
stops matching what the repo says it is.
|
|
|
|
The same reasoning applies to `multica_node_version` (full semver, not a major
|
|
track) and to `package_version` on each npm-installed agent CLI — left empty by
|
|
default, which tracks `latest`; set it to own that version too.
|
|
|
|
## Variables
|
|
|
|
See `defaults/main.yml` for the annotated list. The ones you will actually set:
|
|
|
|
| Variable | Default | Notes |
|
|
|---|---|---|
|
|
| `multica_server_url` | — | **Required.** |
|
|
| `multica_pat` | — | **Required.** See above. |
|
|
| `multica_version` | `0.6.5` | Pinned CLI release. |
|
|
| `multica_node_version` | `24.22.1` | Pinned, full semver. |
|
|
| `multica_workspaces_root` | `/opt/multica/workspaces` | Local disk. Repoint at a mount when one exists. |
|
|
| `multica_workspaces_root_mount` | `""` | Set it and the play aborts if the mount is missing. |
|
|
| `multica_agent_clis` | `[claude]` | Keys of `multica_agent_cli_catalog`. |
|
|
| `multica_device_name` | short inventory hostname | Shows up in `runtime list` → `device_info`. |
|
|
| `multica_user` | `multica` | Daemon service account. |
|
|
| `multica_profile` | `""` | `--profile`, to isolate config/daemon state/workspaces. |
|
|
| `multica_allow_remote_install_scripts` | `false` | Required for `hermes`; see below. |
|
|
|
|
### Adding an agent CLI
|
|
|
|
Extend `multica_agent_cli_catalog`. Two install methods are implemented:
|
|
|
|
```yaml
|
|
multica_agent_cli_catalog:
|
|
codex:
|
|
provider: codex # must match `provider` in `multica runtime list`
|
|
binary: codex
|
|
method: npm
|
|
package: "@openai/codex"
|
|
package_version: ""
|
|
version_command: "codex --version"
|
|
manual_login: true
|
|
manual_login_hint: "run `codex login` once as the daemon user"
|
|
```
|
|
|
|
`method: script` fetches `install_url` and runs it as the daemon user. That is a
|
|
supply-chain decision, so the role **refuses** unless
|
|
`multica_allow_remote_install_scripts: true`. `hermes` uses this method
|
|
(`https://hermes-agent.nousresearch.com/install.sh`). Vendoring the installer
|
|
into your repo and switching to a local `script` source is the stricter option.
|
|
|
|
Agent CLI binaries are symlinked into `/usr/local/bin`. This matters: the daemon
|
|
runs from a systemd **user unit**, which gets systemd's default `PATH` and does
|
|
*not* source `/etc/profile.d`. A CLI reachable only from an interactive login
|
|
shell is invisible to the daemon. `MULTICA_<PROVIDER>_PATH` is the escape hatch
|
|
if you need the binary somewhere else.
|
|
|
|
Both install methods resolve the binary with `command -v` as the daemon user
|
|
and link *that* path, rather than assuming where they put it. This matters when
|
|
a CLI was already installed by other means — the install step is skipped, so
|
|
assuming the npm prefix would link to nothing. The role asserts the binary is
|
|
resolvable before linking, and linking the resolved path repairs a dangling
|
|
link left behind by an earlier run.
|
|
|
|
## Moving the workspaces root onto a mount later
|
|
|
|
`workspaces_root` holds per-run working directories, not durable state — the
|
|
daemon recreates what it needs. Moving it is therefore a drain-and-repoint, not
|
|
a migration:
|
|
|
|
```bash
|
|
multica daemon disk-usage --output json # see what is actually there first
|
|
sudo -iu multica multica daemon stop # let in-flight runs finish first
|
|
```
|
|
|
|
Then set `multica_workspaces_root` and `multica_workspaces_root_mount` together
|
|
in the playbook and re-run. The role creates the new directory, proves the
|
|
daemon user can write to it, updates `config set workspaces_root`, and restarts
|
|
the daemon.
|
|
|
|
**Set both variables in the same change.** Pointing the root at a mount path
|
|
without setting the guard means a play that runs while the mount is down
|
|
silently writes run workspaces onto the root filesystem — which nobody notices
|
|
until it fills. The guard turns that into a failed assertion instead.
|
|
|
|
The old directory is left behind deliberately; remove it by hand once the new
|
|
one has taken runs.
|
|
|
|
## Idempotency
|
|
|
|
Every step reads before it writes:
|
|
|
|
| Step | Guard |
|
|
|---|---|
|
|
| Node install | `node --version` equals the pin |
|
|
| CLI install | `multica --version` equals the pin |
|
|
| Agent CLI install | `command -v <binary>` and its version command |
|
|
| Config | `multica config show` parsed and compared key by key |
|
|
| Login | `multica auth status` already names `multica_server_url` (it prints to **stderr**; the guard reads both streams) |
|
|
| Autostart | `multica daemon autostart status` |
|
|
| Daemon start | `systemctl --user is-active <unit>`; a daemon running *outside* the unit is stopped first, then the unit is started |
|
|
|
|
A binary, config or credential change notifies a `systemctl --user restart`
|
|
handler, which is flushed *before* verification reads the daemon's state back.
|
|
|
|
The role never calls `multica daemon start` or `multica daemon restart`. Both
|
|
fork the daemon inside the caller's session (Ansible's SSH session scope),
|
|
outside the unit's cgroup. The unit then shows `inactive`, nothing supervises
|
|
the process, and a later `systemctl --user start` collides with it on
|
|
`daemon.lock`.
|
|
|
|
A converged host reports zero changed tasks.
|
|
|
|
## Verification
|
|
|
|
The role ends in assertions, not hope:
|
|
|
|
- `multica daemon status --output json` reports `status: running`, and its
|
|
`cli_version` and `server_url` match the declared values.
|
|
- `multica runtime list --output json` contains an **online** runtime for every
|
|
entry in `multica_agent_clis`, filtered to this host's `daemon_id`, with
|
|
`device_info` naming `multica_device_name`. Retried, because registration is
|
|
asynchronous after the daemon starts.
|
|
- `workspaces_root` exists, is a directory, is owned by the daemon user, is
|
|
actually writable by it (ownership is not writability on a root-squashed
|
|
network mount), and sits on `multica_workspaces_root_mount` when that is set.
|
|
|
|
It finishes by printing the manual steps that remain — the per-host provider CLI
|
|
logins.
|
|
|
|
## Testing against a throwaway VM
|
|
|
|
Molecule is fine but a scratch VM is proportionate:
|
|
|
|
```bash
|
|
# 1. clean host
|
|
ansible-playbook -i inventory/hosts.yml playbooks/provision-agent-host.yml -l scratch-vm
|
|
|
|
# 2. re-run: must report changed=0
|
|
ansible-playbook -i inventory/hosts.yml playbooks/provision-agent-host.yml -l scratch-vm
|
|
|
|
# 3. reboot, then confirm the daemon came back on its own
|
|
ssh scratch-vm 'sudo -iu multica multica daemon status --output json'
|
|
|
|
# 4. confirm from the workspace side
|
|
multica runtime list --output json | jq '.[] | select(.device_info | startswith("scratch-vm"))'
|
|
```
|
|
|
|
Then complete the browser login for each provider CLI once:
|
|
|
|
```bash
|
|
ssh -t scratch-vm 'sudo -iu multica claude'
|
|
```
|
|
|
|
That step only becomes automatable if the host moves from a subscription to an
|
|
API key.
|
|
|
|
## Known gaps
|
|
|
|
- **Provider CLI login.** Unavoidable for subscription auth.
|
|
- **Hermes credentials.** Hermes reads 1Password natively; wiring that is not
|
|
implemented here because the right item layout is deployment-specific. The
|
|
role installs Hermes and reports the credential step as outstanding.
|
|
- **Non-systemd hosts.** Autostart degrades to an XDG entry that a headless host
|
|
never triggers.
|
|
|
|
## Tags
|
|
|
|
| Tag | Covers |
|
|
|---|---|
|
|
| `multica_workspaces` | workspaces root |
|
|
| `multica_node` | Node.js |
|
|
| `multica_cli` | multica CLI |
|
|
| `multica_agents` | agent CLIs |
|
|
| `multica_config` | `multica config set` |
|
|
| `multica_auth` | `multica login` |
|
|
| `multica_daemon` | autostart + start |
|
|
| `multica_verify` | the assertions |
|
|
| `multica` | all of the above |
|
|
|
|
Preflight and the service-account step are tagged `always`: they build
|
|
`_multica_uid` and `_multica_user_env`, which every `become_user` task below
|
|
depends on, so a `--tags` subset still runs them. Both are idempotent.
|
|
|
|
## Offline checks
|
|
|
|
From the repo root:
|
|
|
|
```bash
|
|
ansible-lint playbooks/provision-agent-host.yml roles/multica_agent_host
|
|
ansible-playbook --syntax-check playbooks/provision-agent-host.yml
|
|
ansible-playbook -i localhost, tests/expressions.yml
|
|
```
|
|
|
|
`tests/expressions.yml` runs the role's filter chains — `config show` parsing,
|
|
version extraction, the online-runtime difference, the mount containment check
|
|
— against fixtures captured from a real `multica` CLI, with no target host.
|
|
It catches the class of bug where a filter chain silently returns the wrong
|
|
type or matches nothing; it does not replace the scratch-VM run above.
|