Files
homelab/ansible/tests/expressions.yml
T
2026-10-22 12:30:35 -05:00

276 lines
12 KiB
YAML

---
# Offline regression tests for the role's Jinja expressions, run against
# fixtures captured from a real `multica` CLI. No target host required:
#
# ansible-playbook -i localhost, tests/expressions.yml
#
# This does NOT replace the scratch-VM run described in the role README; it
# catches the class of bug where a filter chain silently returns the wrong
# type or quietly matches nothing.
- name: Expressions | parsing real multica CLI output
hosts: localhost
gather_facts: false
vars:
multica_server_url: "https://multica.wdw-labs.cloud"
multica_app_url: ""
multica_workspaces_root: "/srv/multica/workspaces"
multica_device_name: "rblundon--Mac16"
multica_runtime_name: ""
multica_max_concurrent_tasks: ""
multica_poll_interval: ""
multica_heartbeat_interval: ""
multica_agent_timeout: ""
multica_disable_auto_update: true
multica_version: "0.6.5"
multica_agent_clis: [claude, hermes]
multica_agent_cli_catalog:
claude: {provider: claude, method: npm}
hermes: {provider: hermes, method: script}
cursor: {provider: cursor, method: npm}
_multica_arch_map: {x86_64: amd64, aarch64: arm64}
tasks:
- name: Load fixtures
ansible.builtin.set_fact:
rt: "{{ lookup('file', '{{ playbook_dir }}/fixtures/runtime-list.json') }}"
ds: "{{ lookup('file', '{{ playbook_dir }}/fixtures/daemon-status.json') }}"
cs_lines: "{{ lookup('file', '{{ playbook_dir }}/fixtures/config-show.txt').splitlines() }}"
ver_out: "multica 0.6.5 (commit: 654b8ca95, built: 2026-10-21T09:41:38Z)"
# --- CLI version extraction (multica_cli.yml) ---------------------------
- name: Extract installed CLI version
ansible.builtin.set_fact:
found: >-
{{ ((([ver_out] | first | default('')).split() + ['', ''])[1]) }}
- name: Assert version extraction
ansible.builtin.assert:
that: [(found | trim) == multica_version]
success_msg: "CLI version extraction OK -> {{ found }}"
# --- config show parsing (config.yml) -----------------------------------
- name: Parse config show
ansible.builtin.set_fact:
current: >-
{{ dict(
cs_lines
| select('match', '^[a-z_]+:')
| map('regex_replace', '^([a-z_]+):\s*(.*?)\s*$', '\1|\2')
| map('split', '|')
) }}
- name: Build desired config
ansible.builtin.set_fact:
desired: >-
{{ {
'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: Show parse results
ansible.builtin.debug:
msg:
parsed_keys: "{{ current.keys() | list }}"
disable_auto_update_now: "{{ current['disable_auto_update'] | default('MISSING') }}"
server_url_now: "{{ current['server_url'] | default('MISSING') }}"
desired: "{{ desired }}"
would_change: >-
{{ desired | dict2items
| selectattr('key', 'in', current.keys() | list)
| rejectattr('value', 'equalto', 'IMPOSSIBLE')
| map(attribute='key') | list }}
- name: Assert config parsing found the expected keys
ansible.builtin.assert:
that:
- "'server_url' in current"
- "'disable_auto_update' in current"
- current['server_url'] == '(not set)'
- current['disable_auto_update'] == 'false'
- desired | length == 4
- "'app_url' not in desired"
success_msg: "config show parsing + desired-config filtering OK"
# --- runtime verification (verify.yml) ----------------------------------
- name: Compute online providers for this daemon
ansible.builtin.set_fact:
expected: "{{ multica_agent_clis | map('extract', multica_agent_cli_catalog, 'provider') | list }}"
online: >-
{{ rt | from_json
| selectattr('daemon_id', 'equalto', (ds | from_json).daemon_id)
| selectattr('status', 'equalto', 'online')
| map(attribute='provider') | list }}
- name: Assert every expected provider is online
ansible.builtin.assert:
that: [(expected | difference(online)) | length == 0]
success_msg: "runtime check OK: expected={{ expected }} online={{ online }}"
- name: Assert device_info naming check
ansible.builtin.assert:
that:
- rt | from_json
| selectattr('daemon_id', 'equalto', (ds | from_json).daemon_id)
| rejectattr('device_info', 'search', '^' ~ (multica_device_name | regex_escape))
| list | length == 0
success_msg: "device_info check OK for {{ multica_device_name }}"
- name: Assert daemon status fields
ansible.builtin.assert:
that:
- (ds | from_json).status == 'running'
- (ds | from_json).cli_version == multica_version
- ((ds | from_json).server_url | regex_replace('/$','')) == (multica_server_url | regex_replace('/$',''))
success_msg: "daemon status assertions OK"
# --- negative case: a provider that is NOT online ------------------------
- name: Assert a missing provider is detected
ansible.builtin.assert:
that:
- (['codex'] | difference(online)) | length == 1
success_msg: "missing-provider detection OK"
- name: Expressions | preflight fact-building
hosts: localhost
gather_facts: false
vars:
multica_install_dir: /usr/local/bin
multica_agent_cli_catalog:
claude: {provider: claude, method: npm}
hermes: {provider: hermes, method: script}
multica_release_download_base_url: "https://github.com/multica-ai/multica/releases/download"
multica_version: "0.6.5"
_multica_arch_map: {x86_64: amd64, aarch64: arm64}
tasks:
- name: Case - profile unset, npm CLI selected
ansible.builtin.set_fact:
multica_profile: ""
multica_agent_clis: [claude, hermes]
_multica_arch: "{{ _multica_arch_map['x86_64'] }}"
- name: Build facts
ansible.builtin.set_fact:
_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 }}
_multica_release_url: >-
{{ multica_release_download_base_url }}/v{{ multica_version }}/multica-cli-{{ multica_version }}-linux-{{ _multica_arch }}.tar.gz
- name: Assert types and values
ansible.builtin.assert:
that:
- _multica_cmd is sequence
- _multica_cmd is not string
- _multica_cmd == ['/usr/local/bin/multica']
- (_multica_cmd + ['daemon', 'status']) == ['/usr/local/bin/multica', 'daemon', 'status']
- _multica_node_required | bool
- _multica_release_url == 'https://github.com/multica-ai/multica/releases/download/v0.6.5/multica-cli-0.6.5-linux-amd64.tar.gz'
success_msg: "argv prefix + node detection + URL OK: {{ _multica_cmd }}"
- name: Case - profile set, script-only CLI selected
ansible.builtin.set_fact:
multica_profile: "haunted"
multica_agent_clis: [hermes]
- name: Rebuild facts
ansible.builtin.set_fact:
_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: Assert profile + no-node case
ansible.builtin.assert:
that:
- _multica_cmd == ['/usr/local/bin/multica', '--profile', 'haunted']
- not (_multica_node_required | bool)
success_msg: "profile prefix + node-not-required OK: {{ _multica_cmd }}"
- name: Assert PAT prefix validation accepts both token kinds and rejects junk
ansible.builtin.assert:
that:
- "'mul_abc123' is match('^(mul_|mcn_)')"
- "'mcn_abc123' is match('^(mul_|mcn_)')"
- "not ('ghp_abc123' is match('^(mul_|mcn_)'))"
- "not ('' is match('^(mul_|mcn_)'))"
success_msg: "PAT validation OK"
- name: Assert unknown-agent detection
ansible.builtin.assert:
that:
- "['claude', 'bogus'] | difference(multica_agent_cli_catalog.keys() | list) == ['bogus']"
- "['claude', 'hermes'] | difference(multica_agent_cli_catalog.keys() | list) | length == 0"
success_msg: "catalog validation OK"
- name: Assert workspaces-root-under-mount logic
vars:
root: "/mnt/utilidor/multica/workspaces"
mnt: "/mnt/utilidor"
bad_root: "/mnt/utilidor-other/workspaces"
ansible.builtin.assert:
that:
- root == mnt or root.startswith(mnt.rstrip('/') ~ '/')
- not (bad_root == mnt or bad_root.startswith(mnt.rstrip('/') ~ '/'))
success_msg: "mount containment check OK (rejects /mnt/utilidor-other)"
- name: Expressions | pinned agent-CLI version guard
hosts: localhost
gather_facts: false
vars:
# Mirrors the expression in tasks/agent_cli/npm.yml.
guard: >-
{{ rc != 0
or (pin | default('') | length > 0
and not ((out | default('')) is search(
'(^|[^0-9.])' ~ (pin | regex_escape) ~ '([^0-9.]|$)'))) }}
tasks:
- name: Pinned version already installed - must NOT reinstall
ansible.builtin.assert:
that: [not (guard | bool)]
success_msg: "2.1.304 pin vs '2.1.304 (Claude Code)' -> no install"
vars: {rc: 0, pin: "2.1.304", out: "2.1.304 (Claude Code)"}
- name: Different version installed - must reinstall
ansible.builtin.assert:
that: [guard | bool]
success_msg: "2.1.304 pin vs 2.1.306 installed -> install"
vars: {rc: 0, pin: "2.1.304", out: "2.1.306 (Claude Code)"}
- name: Longer version must not satisfy a shorter pin
ansible.builtin.assert:
that: [guard | bool]
success_msg: "2.1.304 pin vs 2.1.3045 installed -> install (no substring false-match)"
vars: {rc: 0, pin: "2.1.304", out: "2.1.3045 (Claude Code)"}
- name: Prefix of installed version must not satisfy the pin
ansible.builtin.assert:
that: [guard | bool]
success_msg: "2.1.30 pin vs 2.1.304 installed -> install"
vars: {rc: 0, pin: "2.1.30", out: "2.1.304 (Claude Code)"}
- name: Version embedded mid-string still matches (hermes-style output)
ansible.builtin.assert:
that: [not (guard | bool)]
success_msg: "0.21.5 pin vs 'Hermes Agent v0.21.5+2024.gb609c74' -> no install"
vars: {rc: 0, pin: "0.21.5", out: "Hermes Agent v0.21.5+2024.gb609c74 (2026.9.24)"}
- name: Binary absent - must install regardless of pin
ansible.builtin.assert:
that: [guard | bool]
success_msg: "rc!=0 -> install"
vars: {rc: 1, pin: "2.1.304", out: ""}
- name: Unpinned tracks latest - never reinstalls once present
ansible.builtin.assert:
that: [not (guard | bool)]
success_msg: "empty pin + present -> no install"
vars: {rc: 0, pin: "", out: "2.1.306 (Claude Code)"}