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,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
|
||||
Reference in new issue
Block a user