added multica agent hosts
This commit is contained in:
1 parent
5559cd425d
commit
01cd7b8d1e
32 files changed
+2636
-417
No files matched your search
@@ -0,0 +1,9 @@
|
||||
---
|
||||
# The role's public variables are named in the issue's spec (multica_version,
|
||||
# multica_server_url, multica_workspaces_root, multica_agent_clis, ...) and
|
||||
# match the product's own namespace, including its MULTICA_* environment
|
||||
# variables. Prefixing them with the role name instead would make every call
|
||||
# site read multica_agent_host_multica_version. Internal facts use a leading
|
||||
# underscore (_multica_*) to stay clearly private.
|
||||
skip_list:
|
||||
- var-naming[no-role-prefix]
|
||||
@@ -0,0 +1,404 @@
|
||||
# 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` |
|
||||
| Autostart | `multica daemon autostart status` |
|
||||
| Daemon start | `multica daemon status --output json` reports `running` |
|
||||
|
||||
A binary, config or credential change notifies a `multica daemon restart`
|
||||
handler, which is flushed *before* verification reads the daemon's state back.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
# =============================================================================
|
||||
# multica_agent_host — defaults
|
||||
#
|
||||
# Everything the role needs is a plain variable. The role never reaches into a
|
||||
# secrets backend itself; see `multica_pat` below.
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Service account
|
||||
# -----------------------------------------------------------------------------
|
||||
# The daemon runs as a dedicated unprivileged user with a real home directory.
|
||||
# It needs a real home (CLI config, agent CLI credentials) and systemd linger,
|
||||
# so this is NOT a `system: true` account.
|
||||
multica_manage_user: true
|
||||
multica_user: multica
|
||||
multica_group: multica
|
||||
multica_user_home: "/home/{{ multica_user }}"
|
||||
multica_user_shell: /bin/bash
|
||||
# Extra groups for the daemon user (e.g. docker). Left empty by default.
|
||||
multica_user_extra_groups: []
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# multica CLI — Ansible owns the version
|
||||
# -----------------------------------------------------------------------------
|
||||
# Pin it. Re-running the play IS the upgrade path; see multica_disable_auto_update.
|
||||
multica_version: "0.6.5"
|
||||
multica_install_dir: /usr/local/bin
|
||||
multica_release_download_base_url: "https://github.com/multica-ai/multica/releases/download"
|
||||
# Verify the release tarball against the release's own checksums.txt.
|
||||
multica_verify_checksum: true
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Server / workspace
|
||||
# -----------------------------------------------------------------------------
|
||||
multica_server_url: "" # REQUIRED, e.g. https://multica.wdw-labs.cloud
|
||||
multica_app_url: "" # optional frontend URL
|
||||
multica_workspace_id: "" # optional: pin this profile's default workspace
|
||||
multica_profile: "" # optional: --profile <name> (isolates config + daemon state)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Daemon configuration (mirrors `multica config set` keys)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Local disk by default. Repoint this at a mount when one exists, and set
|
||||
# multica_workspaces_root_mount at the same time so the play fails loudly if
|
||||
# the mount is missing rather than silently filling the root filesystem.
|
||||
multica_workspaces_root: "/opt/multica/workspaces"
|
||||
# Optional guard: assert workspaces_root actually sits on this mount point.
|
||||
# e.g. "/mnt/utilidor". Empty disables the check.
|
||||
multica_workspaces_root_mount: ""
|
||||
multica_workspaces_root_mode: "0750"
|
||||
|
||||
multica_device_name: "{{ inventory_hostname.split('.')[0] }}"
|
||||
multica_runtime_name: "" # "" = leave unset, CLI derives it
|
||||
multica_max_concurrent_tasks: "" # "" = leave unset
|
||||
multica_poll_interval: "" # "" = leave unset
|
||||
multica_heartbeat_interval: "" # "" = leave unset
|
||||
multica_agent_timeout: "" # "" = leave unset ("0s" disables the cap)
|
||||
|
||||
# Ansible owns the version: stop the CLI self-updating out from under the
|
||||
# role's declared state. Set false only if you want the daemon to self-update.
|
||||
multica_disable_auto_update: true
|
||||
|
||||
# Boot autostart. On Linux `multica daemon autostart enable` writes a systemd
|
||||
# *user* unit, which only starts at boot when the user has lingering enabled.
|
||||
multica_autostart: true
|
||||
multica_enable_linger: true
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Authentication — backend-agnostic by design
|
||||
# -----------------------------------------------------------------------------
|
||||
# REQUIRED. A Multica personal access token: `mul_...` (user PAT) or
|
||||
# `mcn_...` (Cloud Node PAT). The role takes a VALUE; it does not know or care
|
||||
# where it came from. Supply it from the playbook with whatever your
|
||||
# environment already uses:
|
||||
#
|
||||
# HashiCorp Vault:
|
||||
# multica_pat: "{{ lookup('community.hashi_vault.vault_kv2_get',
|
||||
# 'multica/agent-host').secret.pat }}"
|
||||
# ansible-vault encrypted var:
|
||||
# multica_pat: "{{ vaulted_multica_pat }}"
|
||||
# 1Password:
|
||||
# multica_pat: "{{ lookup('community.general.onepassword',
|
||||
# 'Multica Agent Host PAT', field='credential') }}"
|
||||
# Environment:
|
||||
# multica_pat: "{{ lookup('env', 'MULTICA_PAT') }}"
|
||||
#
|
||||
# Consolidating secret providers later is a one-line playbook change; this role
|
||||
# is untouched by it.
|
||||
multica_pat: ""
|
||||
|
||||
# Force `multica login --token` even when the host already reports an
|
||||
# authenticated session against multica_server_url.
|
||||
multica_force_relogin: false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Agent CLIs
|
||||
# -----------------------------------------------------------------------------
|
||||
# Which provider CLIs this host should offer. Each must be a key in
|
||||
# multica_agent_cli_catalog. The daemon registers one runtime per CLI it finds.
|
||||
multica_agent_clis:
|
||||
- claude
|
||||
|
||||
# Some agent CLIs are installed by piping a vendor script into a shell. That is
|
||||
# a real supply-chain decision, so it is opt-in and off by default.
|
||||
multica_allow_remote_install_scripts: false
|
||||
|
||||
multica_agent_cli_catalog:
|
||||
claude:
|
||||
provider: claude # matches `provider` in `multica runtime list`
|
||||
binary: claude
|
||||
method: npm
|
||||
package: "@anthropic-ai/claude-code"
|
||||
# Pinned: npm "latest" is 2.1.306 while "stable" is 2.1.304, so an empty
|
||||
# value silently tracks whichever npm serves that day. 2.1.304 is the
|
||||
# version running on haunted-mansion.
|
||||
package_version: "2.1.304"
|
||||
version_command: "claude --version"
|
||||
# Subscription auth needs a browser once per host. Not automatable here.
|
||||
manual_login: true
|
||||
manual_login_hint: "run `claude` once as {{ multica_user }} and complete the login"
|
||||
hermes:
|
||||
provider: hermes
|
||||
binary: hermes
|
||||
method: script
|
||||
install_url: "https://hermes-agent.nousresearch.com/install.sh"
|
||||
install_dir: "{{ multica_user_home }}/.hermes/hermes-agent"
|
||||
version_command: "hermes --version"
|
||||
manual_login: true
|
||||
manual_login_hint: "configure Hermes credentials as {{ multica_user }} (Hermes reads 1Password natively; confirm against its docs)"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Node.js (installed only when a selected agent CLI needs npm)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Full semver — this is a pin, not a major-version track.
|
||||
multica_node_version: "24.22.1"
|
||||
multica_node_dist_base_url: "https://nodejs.org/dist"
|
||||
multica_node_prefix: /usr/local/lib/nodejs
|
||||
# npm global prefix for the daemon user, so agent CLI installs need no root.
|
||||
multica_npm_prefix: "{{ multica_user_home }}/.npm-global"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Verification
|
||||
# -----------------------------------------------------------------------------
|
||||
multica_verify: true
|
||||
# How long to wait for runtimes to appear online after the daemon starts.
|
||||
multica_verify_retries: 24
|
||||
multica_verify_delay: 5
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
- name: Restart multica daemon
|
||||
ansible.builtin.command:
|
||||
argv: "{{ _multica_cmd + ['daemon', 'restart'] }}"
|
||||
become: true
|
||||
become_user: "{{ multica_user }}"
|
||||
environment: "{{ _multica_user_env }}"
|
||||
changed_when: true
|
||||
listen: restart multica daemon
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: multica_agent_host
|
||||
namespace: wdwlabs
|
||||
author: WDW Labs
|
||||
description: >-
|
||||
Provision a Multica agent host from bare Linux to a registered, running
|
||||
daemon with its provider runtimes online.
|
||||
license: MIT
|
||||
min_ansible_version: "2.14"
|
||||
platforms:
|
||||
- name: Debian
|
||||
versions: [bullseye, bookworm, trixie]
|
||||
- name: Ubuntu
|
||||
versions: [jammy, noble]
|
||||
- name: EL
|
||||
versions: ["9"]
|
||||
galaxy_tags:
|
||||
- multica
|
||||
- agents
|
||||
- daemon
|
||||
dependencies: []
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
# Resolve where the binary ACTUALLY is before linking to it.
|
||||
#
|
||||
# The npm path used to link unconditionally from multica_npm_prefix. When the
|
||||
# CLI was already installed by some other means — a distro package, a vendor
|
||||
# installer, a previous hand-rollout — nothing was installed at that prefix and
|
||||
# `file: state=link force=true` happily created a DANGLING symlink in
|
||||
# /usr/local/bin. PATH resolution skips a dangling link, so discovery still
|
||||
# worked by luck wherever the real binary was already on systemd's PATH, and
|
||||
# the role's actual guarantee silently did not hold.
|
||||
- name: "Agents | Resolve the installed binary | {{ _agent_key }}"
|
||||
ansible.builtin.command:
|
||||
argv: ["/bin/sh", "-c", "command -v {{ _agent.binary }}"]
|
||||
become: true
|
||||
become_user: "{{ multica_user }}"
|
||||
environment: "{{ _multica_user_env }}"
|
||||
register: _agent_which
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: "Agents | Require the binary to be resolvable | {{ _agent_key }}"
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- _agent_which.rc == 0
|
||||
- (_agent_which.stdout | trim) | length > 0
|
||||
fail_msg: >-
|
||||
{{ _agent.binary }} is not on {{ multica_user }}'s PATH after the install
|
||||
step. The daemon discovers provider CLIs by PATH, so it would register no
|
||||
{{ _agent_key }} runtime.
|
||||
quiet: true
|
||||
|
||||
# Linking the resolved path also repairs a dangling link left by an older run:
|
||||
# `command -v` skips the broken link, so this overwrites it with a working one.
|
||||
- name: "Agents | Link onto the default PATH | {{ _agent_key }}"
|
||||
ansible.builtin.file:
|
||||
src: "{{ _agent_which.stdout | trim }}"
|
||||
dest: "{{ multica_install_dir }}/{{ _agent.binary }}"
|
||||
state: link
|
||||
force: true
|
||||
become: true
|
||||
when: (_agent_which.stdout | trim) != (multica_install_dir ~ '/' ~ _agent.binary)
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
- name: "Agents | Resolve catalog entry | {{ _agent_key }}"
|
||||
ansible.builtin.set_fact:
|
||||
_agent: "{{ multica_agent_cli_catalog[_agent_key] }}"
|
||||
|
||||
- name: "Agents | Read the installed version | {{ _agent_key }}"
|
||||
ansible.builtin.command:
|
||||
argv: ["/bin/sh", "-c", "command -v {{ _agent.binary }} >/dev/null 2>&1 && {{ _agent.version_command }}"]
|
||||
become: true
|
||||
become_user: "{{ multica_user }}"
|
||||
environment: "{{ _multica_user_env }}"
|
||||
register: _agent_installed
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
# Version formats differ per CLI ("2.1.304 (Claude Code)", "Hermes Agent
|
||||
# v0.21.5+..."), so this matches the pinned version anywhere in the output —
|
||||
# but on a version-component boundary. A plain substring test would accept
|
||||
# 2.1.3045 for a pin of 2.1.304, and silently never upgrade.
|
||||
- name: "Agents | Decide whether an install is needed | {{ _agent_key }}"
|
||||
ansible.builtin.set_fact:
|
||||
_agent_needs_install: >-
|
||||
{{ _agent_installed.rc != 0
|
||||
or (_agent.package_version | default('') | length > 0
|
||||
and not ((_agent_installed.stdout | default('')) is search(
|
||||
'(^|[^0-9.])' ~ (_agent.package_version | regex_escape) ~ '([^0-9.]|$)'))) }}
|
||||
|
||||
- name: "Agents | Install via npm as the daemon user | {{ _agent_key }}"
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- "{{ multica_install_dir }}/npm"
|
||||
- install
|
||||
- --global
|
||||
- --no-fund
|
||||
- --no-audit
|
||||
- "{{ _agent.package }}{{ ('@' ~ _agent.package_version) if _agent.package_version | default('') | length > 0 else '' }}"
|
||||
become: true
|
||||
become_user: "{{ multica_user }}"
|
||||
environment: "{{ _multica_user_env }}"
|
||||
when: _agent_needs_install | bool
|
||||
changed_when: true
|
||||
notify: restart multica daemon
|
||||
|
||||
- name: "Agents | Resolve and link onto the default PATH | {{ _agent_key }}"
|
||||
ansible.builtin.include_tasks: _link.yml
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
- name: "Agents | Resolve catalog entry | {{ _agent_key }}"
|
||||
ansible.builtin.set_fact:
|
||||
_agent: "{{ multica_agent_cli_catalog[_agent_key] }}"
|
||||
|
||||
- name: "Agents | Read the installed version | {{ _agent_key }}"
|
||||
ansible.builtin.command:
|
||||
argv: ["/bin/sh", "-c", "command -v {{ _agent.binary }} >/dev/null 2>&1 && {{ _agent.version_command }}"]
|
||||
become: true
|
||||
become_user: "{{ multica_user }}"
|
||||
environment: "{{ _multica_user_env }}"
|
||||
register: _agent_installed
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
# Piping a vendor script into a shell is a supply-chain decision, so the role
|
||||
# refuses rather than making it quietly on the operator's behalf.
|
||||
- name: "Agents | Refuse to run a remote installer unless allowed | {{ _agent_key }}"
|
||||
ansible.builtin.fail:
|
||||
msg: >-
|
||||
{{ _agent_key }} installs by executing {{ _agent.install_url }}.
|
||||
Set multica_allow_remote_install_scripts: true to permit that, vendor the
|
||||
installer yourself, or drop {{ _agent_key }} from multica_agent_clis.
|
||||
when:
|
||||
- _agent_installed.rc != 0
|
||||
- not (multica_allow_remote_install_scripts | bool)
|
||||
|
||||
- name: "Agents | Install via the vendor script | {{ _agent_key }}"
|
||||
when:
|
||||
- _agent_installed.rc != 0
|
||||
- multica_allow_remote_install_scripts | bool
|
||||
block:
|
||||
- name: "Agents | Fetch the installer | {{ _agent_key }}"
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ _agent.install_url }}"
|
||||
dest: "{{ multica_user_home }}/.cache/{{ _agent_key }}-install.sh"
|
||||
owner: "{{ multica_user }}"
|
||||
group: "{{ multica_group }}"
|
||||
mode: "0700"
|
||||
become: true
|
||||
|
||||
- name: "Agents | Run it as the daemon user | {{ _agent_key }}"
|
||||
ansible.builtin.command:
|
||||
argv: ["/bin/bash", "{{ multica_user_home }}/.cache/{{ _agent_key }}-install.sh"]
|
||||
become: true
|
||||
become_user: "{{ multica_user }}"
|
||||
environment: "{{ _multica_user_env }}"
|
||||
changed_when: true
|
||||
notify: restart multica daemon
|
||||
|
||||
- name: "Agents | Resolve and link onto the default PATH | {{ _agent_key }}"
|
||||
ansible.builtin.include_tasks: _link.yml
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
- name: Agents | Install each selected agent CLI
|
||||
ansible.builtin.include_tasks: "agent_cli/{{ multica_agent_cli_catalog[_agent_key].method }}.yml"
|
||||
loop: "{{ multica_agent_clis }}"
|
||||
loop_control:
|
||||
loop_var: _agent_key
|
||||
label: "{{ _agent_key }}"
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
# `multica auth status` prints the server and user when authenticated and exits
|
||||
# non-zero otherwise, so it is the idempotency guard for login.
|
||||
- name: Auth | Read the current authentication state
|
||||
ansible.builtin.command:
|
||||
argv: "{{ _multica_cmd + ['auth', 'status'] }}"
|
||||
become: true
|
||||
become_user: "{{ multica_user }}"
|
||||
environment: "{{ _multica_user_env }}"
|
||||
register: _multica_auth_status
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Auth | Decide whether a login is needed
|
||||
ansible.builtin.set_fact:
|
||||
_multica_needs_login: >-
|
||||
{{ multica_force_relogin | bool
|
||||
or _multica_auth_status.rc != 0
|
||||
or multica_server_url.rstrip('/') not in (_multica_auth_status.stdout | default('')) }}
|
||||
|
||||
# no_log is mandatory: without it Ansible records the full argv — including the
|
||||
# PAT — in play output, CI logs, and every callback plugin.
|
||||
- name: Auth | Log in with the personal access token
|
||||
ansible.builtin.command:
|
||||
argv: "{{ _multica_cmd + ['login', '--token', multica_pat] }}"
|
||||
become: true
|
||||
become_user: "{{ multica_user }}"
|
||||
environment: "{{ _multica_user_env }}"
|
||||
when: _multica_needs_login | bool
|
||||
changed_when: true
|
||||
no_log: true
|
||||
notify: restart multica daemon
|
||||
|
||||
- name: Auth | Pin the default workspace
|
||||
ansible.builtin.command:
|
||||
argv: "{{ _multica_cmd + ['workspace', 'switch', multica_workspace_id] }}"
|
||||
become: true
|
||||
become_user: "{{ multica_user }}"
|
||||
environment: "{{ _multica_user_env }}"
|
||||
when: multica_workspace_id | length > 0
|
||||
register: _multica_ws_switch
|
||||
changed_when: _multica_needs_login | bool
|
||||
notify: restart multica daemon
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
# The CLI owns its config file format, so the role drives `multica config set`
|
||||
# rather than templating config.json. Each key is read back first, so a
|
||||
# converged host reports zero changes.
|
||||
|
||||
- name: Config | Build the desired configuration
|
||||
ansible.builtin.set_fact:
|
||||
_multica_desired_config: >-
|
||||
{{ {
|
||||
'server_url': multica_server_url,
|
||||
'app_url': multica_app_url,
|
||||
'workspaces_root': multica_workspaces_root,
|
||||
'device_name': multica_device_name,
|
||||
'runtime_name': multica_runtime_name,
|
||||
'max_concurrent_tasks': multica_max_concurrent_tasks,
|
||||
'poll_interval': multica_poll_interval,
|
||||
'heartbeat_interval': multica_heartbeat_interval,
|
||||
'agent_timeout': multica_agent_timeout,
|
||||
'disable_auto_update': ('true' if multica_disable_auto_update | bool else 'false'),
|
||||
} | dict2items
|
||||
| rejectattr('value', 'equalto', '')
|
||||
| list | items2dict }}
|
||||
|
||||
- name: Config | Read the current configuration
|
||||
ansible.builtin.command:
|
||||
argv: "{{ _multica_cmd + ['config', 'show'] }}"
|
||||
become: true
|
||||
become_user: "{{ multica_user }}"
|
||||
environment: "{{ _multica_user_env }}"
|
||||
register: _multica_config_show
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
|
||||
# `config show` prints "key:<spaces>value", with "(not set)" for unset keys.
|
||||
- name: Config | Parse it
|
||||
ansible.builtin.set_fact:
|
||||
_multica_current_config: >-
|
||||
{{ dict(
|
||||
_multica_config_show.stdout_lines
|
||||
| select('match', '^[a-z_]+:')
|
||||
| map('regex_replace', '^([a-z_]+):\s*(.*?)\s*$', '\1|\2')
|
||||
| map('split', '|')
|
||||
) }}
|
||||
|
||||
- name: Config | Apply the keys that differ
|
||||
ansible.builtin.command:
|
||||
argv: "{{ _multica_cmd + ['config', 'set', item.key, item.value] }}"
|
||||
become: true
|
||||
become_user: "{{ multica_user }}"
|
||||
environment: "{{ _multica_user_env }}"
|
||||
loop: "{{ _multica_desired_config | dict2items }}"
|
||||
loop_control:
|
||||
label: "{{ item.key }}={{ item.value }}"
|
||||
when: (_multica_current_config[item.key] | default('(not set)')) != item.value
|
||||
changed_when: true
|
||||
notify: restart multica daemon
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
# On Linux `multica daemon autostart enable` writes a systemd *user* unit.
|
||||
# A user unit does not start at boot unless the user lingers — without this the
|
||||
# daemon only comes back when someone logs in, which on a headless agent host
|
||||
# is never.
|
||||
- name: Daemon | Enable systemd lingering for the daemon user
|
||||
ansible.builtin.command:
|
||||
argv: ["loginctl", "enable-linger", "{{ multica_user }}"]
|
||||
creates: "/var/lib/systemd/linger/{{ multica_user }}"
|
||||
become: true
|
||||
when:
|
||||
- multica_enable_linger | bool
|
||||
- multica_autostart | bool
|
||||
|
||||
- name: Daemon | Wait for the user's runtime directory
|
||||
ansible.builtin.wait_for:
|
||||
path: "/run/user/{{ _multica_uid }}"
|
||||
state: present
|
||||
timeout: 30
|
||||
become: true
|
||||
when:
|
||||
- multica_enable_linger | bool
|
||||
- multica_autostart | bool
|
||||
|
||||
- name: Daemon | Read the current autostart registration
|
||||
ansible.builtin.command:
|
||||
argv: "{{ _multica_cmd + ['daemon', 'autostart', 'status'] }}"
|
||||
become: true
|
||||
become_user: "{{ multica_user }}"
|
||||
environment: "{{ _multica_user_env }}"
|
||||
register: _multica_autostart_status
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Daemon | Register boot autostart
|
||||
ansible.builtin.command:
|
||||
argv: "{{ _multica_cmd + ['daemon', 'autostart', 'enable'] }}"
|
||||
become: true
|
||||
become_user: "{{ multica_user }}"
|
||||
environment: "{{ _multica_user_env }}"
|
||||
when:
|
||||
- multica_autostart | bool
|
||||
- _multica_autostart_status.rc != 0
|
||||
or 'enabled' not in (_multica_autostart_status.stdout | default('') | lower)
|
||||
changed_when: true
|
||||
|
||||
- name: Daemon | Read daemon status
|
||||
ansible.builtin.command:
|
||||
argv: "{{ _multica_cmd + ['daemon', 'status', '--output', 'json'] }}"
|
||||
become: true
|
||||
become_user: "{{ multica_user }}"
|
||||
environment: "{{ _multica_user_env }}"
|
||||
register: _multica_daemon_status
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Daemon | Start it if it is not already running
|
||||
ansible.builtin.command:
|
||||
argv: "{{ _multica_cmd + ['daemon', 'start'] }}"
|
||||
become: true
|
||||
become_user: "{{ multica_user }}"
|
||||
environment: "{{ _multica_user_env }}"
|
||||
when: >-
|
||||
_multica_daemon_status.rc != 0
|
||||
or ((_multica_daemon_status.stdout | from_json).status | default('')) != 'running'
|
||||
changed_when: true
|
||||
|
||||
# Anything that changed the binary, the config or the credentials restarts the
|
||||
# daemon here, before verification reads its state back.
|
||||
- name: Daemon | Flush handlers
|
||||
ansible.builtin.meta: flush_handlers
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
- name: Preflight
|
||||
ansible.builtin.import_tasks: preflight.yml
|
||||
# Prerequisite for every other block: always runs, even under a --tags subset.
|
||||
tags: [always]
|
||||
|
||||
- name: Service account
|
||||
ansible.builtin.import_tasks: user.yml
|
||||
# Builds _multica_uid / _multica_user_env, which every later become_user task
|
||||
# needs; creating an existing user is a no-op.
|
||||
tags: [always]
|
||||
|
||||
- name: Workspaces root
|
||||
ansible.builtin.import_tasks: workspaces_root.yml
|
||||
tags: [multica, multica_workspaces]
|
||||
|
||||
- name: Node.js
|
||||
ansible.builtin.import_tasks: node.yml
|
||||
when: _multica_node_required | bool
|
||||
tags: [multica, multica_node]
|
||||
|
||||
- name: Multica CLI
|
||||
ansible.builtin.import_tasks: multica_cli.yml
|
||||
tags: [multica, multica_cli]
|
||||
|
||||
- name: Agent CLIs
|
||||
ansible.builtin.import_tasks: agent_clis.yml
|
||||
tags: [multica, multica_agents]
|
||||
|
||||
- name: CLI configuration
|
||||
ansible.builtin.import_tasks: config.yml
|
||||
tags: [multica, multica_config]
|
||||
|
||||
- name: Authentication
|
||||
ansible.builtin.import_tasks: auth.yml
|
||||
tags: [multica, multica_auth]
|
||||
|
||||
- name: Daemon
|
||||
ansible.builtin.import_tasks: daemon.yml
|
||||
tags: [multica, multica_daemon]
|
||||
|
||||
- name: Verify
|
||||
ansible.builtin.import_tasks: verify.yml
|
||||
when: multica_verify | bool
|
||||
tags: [multica, multica_verify]
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
- name: CLI | Check whether the binary is already present
|
||||
ansible.builtin.stat:
|
||||
path: "{{ _multica_bin }}"
|
||||
register: _multica_cli_stat
|
||||
check_mode: false
|
||||
|
||||
- name: CLI | Read the installed version
|
||||
ansible.builtin.command:
|
||||
argv: ["{{ _multica_bin }}", "--version"]
|
||||
register: _multica_cli_installed
|
||||
when: _multica_cli_stat.stat.exists
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
# `multica --version` prints e.g. "multica 0.6.5 (commit: ..., built: ...)".
|
||||
- name: CLI | Extract the installed version string
|
||||
ansible.builtin.set_fact:
|
||||
_multica_cli_version_found: >-
|
||||
{{ ((_multica_cli_installed.stdout_lines | default([]) | first | default('')).split() + ['', ''])[1]
|
||||
if (_multica_cli_installed.rc | default(1)) == 0 else '' }}
|
||||
|
||||
- name: CLI | Decide whether an install is needed
|
||||
ansible.builtin.set_fact:
|
||||
_multica_cli_needs_install: "{{ (_multica_cli_version_found | trim) != multica_version }}"
|
||||
|
||||
- name: CLI | Install the pinned release
|
||||
when: _multica_cli_needs_install | bool
|
||||
block:
|
||||
- name: CLI | Create a staging directory
|
||||
ansible.builtin.tempfile:
|
||||
state: directory
|
||||
suffix: multica-cli
|
||||
register: _multica_stage
|
||||
become: true
|
||||
|
||||
- name: CLI | Download the release tarball
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ _multica_release_url }}"
|
||||
dest: "{{ _multica_stage.path }}/{{ _multica_tarball }}"
|
||||
checksum: "{{ ('sha256:' ~ _multica_checksums_url) if multica_verify_checksum | bool else omit }}"
|
||||
mode: "0644"
|
||||
become: true
|
||||
|
||||
- name: CLI | Unpack the binary
|
||||
ansible.builtin.unarchive:
|
||||
src: "{{ _multica_stage.path }}/{{ _multica_tarball }}"
|
||||
dest: "{{ _multica_stage.path }}"
|
||||
remote_src: true
|
||||
become: true
|
||||
|
||||
# Replacing the binary while the daemon runs is fine — the handler restarts
|
||||
# it, and the daemon also watches its own binary for version changes.
|
||||
- name: CLI | Install it onto the PATH
|
||||
ansible.builtin.copy:
|
||||
src: "{{ _multica_stage.path }}/multica"
|
||||
dest: "{{ _multica_bin }}"
|
||||
remote_src: true
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
become: true
|
||||
notify: restart multica daemon
|
||||
|
||||
always:
|
||||
- name: CLI | Remove the staging directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ _multica_stage.path }}"
|
||||
state: absent
|
||||
become: true
|
||||
when: _multica_stage.path is defined
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
# Installed from the official nodejs.org tarball rather than a distro repo so
|
||||
# that multica_node_version is a real pin on every distribution.
|
||||
|
||||
- name: Node | Check whether the pinned runtime is already present
|
||||
ansible.builtin.stat:
|
||||
path: "{{ multica_install_dir }}/node"
|
||||
register: _multica_node_stat
|
||||
check_mode: false
|
||||
|
||||
- name: Node | Read the installed version
|
||||
ansible.builtin.command:
|
||||
argv: ["{{ multica_install_dir }}/node", "--version"]
|
||||
register: _multica_node_installed
|
||||
when: _multica_node_stat.stat.exists
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Node | Decide whether an install is needed
|
||||
ansible.builtin.set_fact:
|
||||
_multica_node_needs_install: >-
|
||||
{{ (not _multica_node_stat.stat.exists)
|
||||
or (_multica_node_installed.rc | default(1)) != 0
|
||||
or (_multica_node_installed.stdout | default('') | trim) != ('v' ~ multica_node_version) }}
|
||||
|
||||
- name: Node | Install the pinned runtime
|
||||
when: _multica_node_needs_install | bool
|
||||
block:
|
||||
- name: Node | Create the install prefix
|
||||
ansible.builtin.file:
|
||||
path: "{{ multica_node_prefix }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
become: true
|
||||
|
||||
# get_url resolves `sha256:<url>` against the file's basename in SHASUMS256.txt.
|
||||
- name: Node | Download the tarball with checksum verification
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ multica_node_dist_base_url }}/v{{ multica_node_version }}/node-v{{ multica_node_version }}-linux-{{ _multica_node_arch }}.tar.xz"
|
||||
dest: "/tmp/node-v{{ multica_node_version }}-linux-{{ _multica_node_arch }}.tar.xz"
|
||||
checksum: "sha256:{{ multica_node_dist_base_url }}/v{{ multica_node_version }}/SHASUMS256.txt"
|
||||
mode: "0644"
|
||||
become: true
|
||||
|
||||
- name: Node | Unpack it
|
||||
ansible.builtin.unarchive:
|
||||
src: "/tmp/node-v{{ multica_node_version }}-linux-{{ _multica_node_arch }}.tar.xz"
|
||||
dest: "{{ multica_node_prefix }}"
|
||||
remote_src: true
|
||||
owner: root
|
||||
group: root
|
||||
creates: "{{ multica_node_prefix }}/node-v{{ multica_node_version }}-linux-{{ _multica_node_arch }}/bin/node"
|
||||
become: true
|
||||
|
||||
- name: Node | Remove the downloaded tarball
|
||||
ansible.builtin.file:
|
||||
path: "/tmp/node-v{{ multica_node_version }}-linux-{{ _multica_node_arch }}.tar.xz"
|
||||
state: absent
|
||||
become: true
|
||||
|
||||
# Symlinked into /usr/local/bin, which is on systemd's default PATH — the
|
||||
# daemon's autostart unit does not source /etc/profile.d.
|
||||
- name: Node | Link node, npm and npx onto the default PATH
|
||||
ansible.builtin.file:
|
||||
src: "{{ multica_node_prefix }}/node-v{{ multica_node_version }}-linux-{{ _multica_node_arch }}/bin/{{ item }}"
|
||||
dest: "{{ multica_install_dir }}/{{ item }}"
|
||||
state: link
|
||||
force: true
|
||||
become: true
|
||||
loop: [node, npm, npx]
|
||||
|
||||
- name: Node | Create the daemon user's npm global prefix
|
||||
ansible.builtin.file:
|
||||
path: "{{ multica_npm_prefix }}"
|
||||
state: directory
|
||||
owner: "{{ multica_user }}"
|
||||
group: "{{ multica_group }}"
|
||||
mode: "0755"
|
||||
become: true
|
||||
|
||||
- name: Node | Point the daemon user's npm at that prefix
|
||||
ansible.builtin.lineinfile:
|
||||
path: "{{ multica_user_home }}/.npmrc"
|
||||
regexp: '^prefix='
|
||||
line: "prefix={{ multica_npm_prefix }}"
|
||||
create: true
|
||||
owner: "{{ multica_user }}"
|
||||
group: "{{ multica_group }}"
|
||||
mode: "0644"
|
||||
become: true
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
- name: Preflight | Require a Linux target
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ansible_facts['system'] == 'Linux'
|
||||
fail_msg: >-
|
||||
multica_agent_host supports Linux hosts only
|
||||
(found {{ ansible_facts['system'] }}).
|
||||
quiet: true
|
||||
|
||||
- name: Preflight | Require a supported CPU architecture
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ansible_facts['architecture'] in _multica_arch_map
|
||||
fail_msg: >-
|
||||
No multica CLI release asset for architecture
|
||||
{{ ansible_facts['architecture'] }}.
|
||||
Supported: {{ _multica_arch_map.keys() | list | join(', ') }}.
|
||||
quiet: true
|
||||
|
||||
- name: Preflight | Require the server URL
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- multica_server_url | length > 0
|
||||
- multica_server_url is match('^https?://')
|
||||
fail_msg: "multica_server_url must be set to an http(s) URL."
|
||||
quiet: true
|
||||
|
||||
# no_log: a failed assert would otherwise render the variables it inspected.
|
||||
- name: Preflight | Require a Multica personal access token
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- multica_pat | length > 0
|
||||
- multica_pat is match('^(mul_|mcn_)')
|
||||
fail_msg: >-
|
||||
multica_pat must be set to a Multica personal access token
|
||||
(mul_... user PAT, or mcn_... Cloud Node PAT). Supply it from the
|
||||
playbook; see this role's README for Vault / 1Password / env examples.
|
||||
quiet: true
|
||||
no_log: true
|
||||
|
||||
- name: Preflight | Require every selected agent CLI to be in the catalog
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- multica_agent_clis | difference(multica_agent_cli_catalog.keys() | list) | length == 0
|
||||
fail_msg: >-
|
||||
Unknown agent CLI(s):
|
||||
{{ multica_agent_clis | difference(multica_agent_cli_catalog.keys() | list) | join(', ') }}.
|
||||
Add them to multica_agent_cli_catalog or drop them from multica_agent_clis.
|
||||
quiet: true
|
||||
|
||||
- name: Preflight | Require at least one agent CLI
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- multica_agent_clis | length > 0
|
||||
fail_msg: >-
|
||||
multica_agent_clis is empty; the daemon would register no runtimes and
|
||||
the host could never claim a run.
|
||||
quiet: true
|
||||
|
||||
- name: Preflight | Resolve release artefact names and the invocation prefix
|
||||
ansible.builtin.set_fact:
|
||||
_multica_arch: "{{ _multica_arch_map[ansible_facts['architecture']] }}"
|
||||
_multica_node_arch: "{{ _multica_node_arch_map[ansible_facts['architecture']] }}"
|
||||
_multica_bin: "{{ multica_install_dir }}/multica"
|
||||
_multica_cmd: >-
|
||||
{{ [multica_install_dir ~ '/multica']
|
||||
+ (['--profile', multica_profile] if multica_profile | length > 0 else []) }}
|
||||
_multica_node_required: >-
|
||||
{{ multica_agent_clis
|
||||
| map('extract', multica_agent_cli_catalog, 'method')
|
||||
| select('equalto', 'npm') | list | length > 0 }}
|
||||
|
||||
- name: Preflight | Name the release tarball
|
||||
ansible.builtin.set_fact:
|
||||
_multica_tarball: "multica-cli-{{ multica_version }}-linux-{{ _multica_arch }}.tar.gz"
|
||||
_multica_release_url: >-
|
||||
{{ multica_release_download_base_url }}/v{{ multica_version }}/multica-cli-{{ multica_version }}-linux-{{ _multica_arch }}.tar.gz
|
||||
_multica_checksums_url: >-
|
||||
{{ multica_release_download_base_url }}/v{{ multica_version }}/checksums.txt
|
||||
|
||||
- name: Preflight | Require base tooling on the target
|
||||
ansible.builtin.package:
|
||||
name: "{{ ['tar', 'xz-utils' if ansible_facts['os_family'] == 'Debian' else 'xz', 'ca-certificates', 'git'] }}"
|
||||
state: present
|
||||
become: true
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
- name: User | Create the daemon group
|
||||
ansible.builtin.group:
|
||||
name: "{{ multica_group }}"
|
||||
state: present
|
||||
become: true
|
||||
when: multica_manage_user | bool
|
||||
|
||||
# Deliberately not a system account: the daemon needs a real home for CLI
|
||||
# config and agent CLI credentials, and systemd linger needs a real user.
|
||||
- name: User | Create the daemon user
|
||||
ansible.builtin.user:
|
||||
name: "{{ multica_user }}"
|
||||
group: "{{ multica_group }}"
|
||||
groups: "{{ multica_user_extra_groups }}"
|
||||
append: true
|
||||
home: "{{ multica_user_home }}"
|
||||
shell: "{{ multica_user_shell }}"
|
||||
create_home: true
|
||||
state: present
|
||||
become: true
|
||||
when: multica_manage_user | bool
|
||||
|
||||
- name: User | Look up the daemon user
|
||||
ansible.builtin.getent:
|
||||
database: passwd
|
||||
key: "{{ multica_user }}"
|
||||
become: true
|
||||
|
||||
- name: User | Record uid and the per-user runtime environment
|
||||
ansible.builtin.set_fact:
|
||||
_multica_uid: "{{ ansible_facts.getent_passwd[multica_user][1] }}"
|
||||
|
||||
# systemd user units — which is what `multica daemon autostart enable` writes on
|
||||
# Linux — are unreachable over a plain `sudo -u` shell without these. Without
|
||||
# XDG_RUNTIME_DIR, `systemctl --user` fails with "Failed to connect to bus".
|
||||
- name: User | Build the daemon user's command environment
|
||||
ansible.builtin.set_fact:
|
||||
_multica_user_env:
|
||||
HOME: "{{ multica_user_home }}"
|
||||
USER: "{{ multica_user }}"
|
||||
LOGNAME: "{{ multica_user }}"
|
||||
XDG_RUNTIME_DIR: "/run/user/{{ _multica_uid }}"
|
||||
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ _multica_uid }}/bus"
|
||||
PATH: "{{ multica_npm_prefix }}/bin:{{ multica_user_home }}/.local/bin:{{ multica_install_dir }}:/usr/local/bin:/usr/bin:/bin"
|
||||
|
||||
- name: User | Ensure the user's local bin directory exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ multica_user_home }}/.local/bin"
|
||||
state: directory
|
||||
owner: "{{ multica_user }}"
|
||||
group: "{{ multica_group }}"
|
||||
mode: "0755"
|
||||
become: true
|
||||
|
||||
- name: User | Ensure the user's cache directory exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ multica_user_home }}/.cache"
|
||||
state: directory
|
||||
owner: "{{ multica_user }}"
|
||||
group: "{{ multica_group }}"
|
||||
mode: "0700"
|
||||
become: true
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
# Assertions, not hope. Each of these has failed in practice for a different
|
||||
# reason, so each gets its own check and its own error message.
|
||||
|
||||
- name: Verify | Daemon reports running
|
||||
ansible.builtin.command:
|
||||
argv: "{{ _multica_cmd + ['daemon', 'status', '--output', 'json'] }}"
|
||||
become: true
|
||||
become_user: "{{ multica_user }}"
|
||||
environment: "{{ _multica_user_env }}"
|
||||
register: _multica_verify_daemon
|
||||
until:
|
||||
- _multica_verify_daemon.rc == 0
|
||||
- (_multica_verify_daemon.stdout | from_json).status | default('') == 'running'
|
||||
retries: "{{ multica_verify_retries }}"
|
||||
delay: "{{ multica_verify_delay }}"
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Verify | Daemon is the pinned version and sees the configured workspaces root
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- (_multica_verify_daemon.stdout | from_json).cli_version == multica_version
|
||||
- (_multica_verify_daemon.stdout | from_json).server_url | regex_replace('/$', '')
|
||||
== multica_server_url | regex_replace('/$', '')
|
||||
fail_msg: >-
|
||||
Daemon is running but does not match declared state:
|
||||
{{ _multica_verify_daemon.stdout }}
|
||||
quiet: true
|
||||
|
||||
- name: Verify | Every selected agent CLI is registered as an online runtime
|
||||
ansible.builtin.command:
|
||||
argv: "{{ _multica_cmd + ['runtime', 'list', '--output', 'json'] }}"
|
||||
become: true
|
||||
become_user: "{{ multica_user }}"
|
||||
environment: "{{ _multica_user_env }}"
|
||||
register: _multica_verify_runtimes
|
||||
until:
|
||||
- _multica_verify_runtimes.rc == 0
|
||||
- _multica_expected_providers
|
||||
| difference(_multica_verify_runtimes.stdout | from_json
|
||||
| selectattr('daemon_id', 'equalto', (_multica_verify_daemon.stdout | from_json).daemon_id)
|
||||
| selectattr('status', 'equalto', 'online')
|
||||
| map(attribute='provider') | list)
|
||||
| length == 0
|
||||
retries: "{{ multica_verify_retries }}"
|
||||
delay: "{{ multica_verify_delay }}"
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
vars:
|
||||
_multica_expected_providers: >-
|
||||
{{ multica_agent_clis | map('extract', multica_agent_cli_catalog, 'provider') | list }}
|
||||
|
||||
- name: Verify | Runtimes name this host
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- _multica_verify_runtimes.stdout | from_json
|
||||
| selectattr('daemon_id', 'equalto', (_multica_verify_daemon.stdout | from_json).daemon_id)
|
||||
| rejectattr('device_info', 'search', '^' ~ (multica_device_name | regex_escape))
|
||||
| list | length == 0
|
||||
fail_msg: >-
|
||||
Runtimes registered under this daemon do not report device_name
|
||||
"{{ multica_device_name }}".
|
||||
quiet: true
|
||||
|
||||
- name: Verify | Stat the workspaces root
|
||||
ansible.builtin.stat:
|
||||
path: "{{ multica_workspaces_root }}"
|
||||
register: _multica_verify_ws
|
||||
become: true
|
||||
|
||||
- name: Verify | Re-prove the daemon user can write to the workspaces root
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- 'd="$1"; f="$d/.multica-write-test.$$"; : > "$f" && rm -f "$f"'
|
||||
- sh
|
||||
- "{{ multica_workspaces_root }}"
|
||||
become: true
|
||||
become_user: "{{ multica_user }}"
|
||||
register: _multica_verify_ws_write
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Verify | Workspaces root assertions
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- _multica_verify_ws.stat.exists
|
||||
- _multica_verify_ws.stat.isdir
|
||||
- _multica_verify_ws.stat.pw_name == multica_user
|
||||
- _multica_verify_ws_write.rc == 0
|
||||
fail_msg: >-
|
||||
{{ multica_workspaces_root }} is missing, not a directory, or not
|
||||
owned/writable by {{ multica_user }}.
|
||||
quiet: true
|
||||
|
||||
- name: Verify | Workspaces root is on the expected mount
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- multica_workspaces_root_mount in (ansible_facts['mounts'] | map(attribute='mount') | list)
|
||||
fail_msg: "{{ multica_workspaces_root_mount }} is not mounted."
|
||||
quiet: true
|
||||
when: multica_workspaces_root_mount | length > 0
|
||||
|
||||
- name: Verify | Report what still needs a human
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
{{ multica_device_name }} is provisioned: multica {{ multica_version }},
|
||||
daemon running, runtimes online for
|
||||
{{ multica_agent_clis | join(', ') }}.
|
||||
Remaining manual step(s):
|
||||
{{ multica_agent_clis
|
||||
| select('in', multica_agent_cli_catalog)
|
||||
| map('extract', multica_agent_cli_catalog)
|
||||
| selectattr('manual_login', 'defined')
|
||||
| selectattr('manual_login')
|
||||
| map(attribute='manual_login_hint')
|
||||
| list | join('; ') | default('none', true) }}
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
# Guard against the mount not being present: without it the daemon happily
|
||||
# writes run workspaces onto the root filesystem and nobody notices until it
|
||||
# fills up.
|
||||
- name: Workspaces root | Assert it lands on the expected mount
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- multica_workspaces_root_mount in (ansible_facts['mounts'] | map(attribute='mount') | list)
|
||||
- multica_workspaces_root == multica_workspaces_root_mount
|
||||
or multica_workspaces_root.startswith(multica_workspaces_root_mount.rstrip('/') ~ '/')
|
||||
fail_msg: >-
|
||||
multica_workspaces_root ({{ multica_workspaces_root }}) is not on
|
||||
{{ multica_workspaces_root_mount }}, which is not currently mounted or
|
||||
does not contain it. Mount it before provisioning.
|
||||
quiet: true
|
||||
when: multica_workspaces_root_mount | length > 0
|
||||
|
||||
- name: Workspaces root | Create the directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ multica_workspaces_root }}"
|
||||
state: directory
|
||||
owner: "{{ multica_user }}"
|
||||
group: "{{ multica_group }}"
|
||||
mode: "{{ multica_workspaces_root_mode }}"
|
||||
become: true
|
||||
|
||||
# Ownership is not writability: a read-only or root-squashed network mount
|
||||
# passes the file module and fails the first run.
|
||||
- name: Workspaces root | Prove the daemon user can write to it
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- 'd="$1"; f="$d/.multica-write-test.$$"; : > "$f" && rm -f "$f"'
|
||||
- sh
|
||||
- "{{ multica_workspaces_root }}"
|
||||
become: true
|
||||
become_user: "{{ multica_user }}"
|
||||
changed_when: false
|
||||
register: _multica_ws_write
|
||||
failed_when: _multica_ws_write.rc != 0
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
# Internal, not meant to be overridden.
|
||||
|
||||
# ansible_architecture -> release asset arch
|
||||
_multica_arch_map: # noqa: var-naming[no-role-prefix]
|
||||
x86_64: amd64
|
||||
amd64: amd64
|
||||
aarch64: arm64
|
||||
arm64: arm64
|
||||
|
||||
# ansible_architecture -> nodejs.org dist arch
|
||||
_multica_node_arch_map: # noqa: var-naming[no-role-prefix]
|
||||
x86_64: x64
|
||||
amd64: x64
|
||||
aarch64: arm64
|
||||
arm64: arm64
|
||||
Reference in new issue
Block a user