diff --git a/ansible/roles/multica_agent_host/README.md b/ansible/roles/multica_agent_host/README.md index 5900b1c..7ec6937 100644 --- a/ansible/roles/multica_agent_host/README.md +++ b/ansible/roles/multica_agent_host/README.md @@ -307,13 +307,19 @@ Every step reads before it writes: | CLI install | `multica --version` equals the pin | | Agent CLI install | `command -v ` and its version command | | Config | `multica config show` parsed and compared key by key | -| Login | `multica auth status` already names `multica_server_url` | +| Login | `multica auth status` already names `multica_server_url` (it prints to **stderr**; the guard reads both streams) | | Autostart | `multica daemon autostart status` | -| Daemon start | `multica daemon status --output json` reports `running` | +| Daemon start | `systemctl --user is-active `; a daemon running *outside* the unit is stopped first, then the unit is started | -A binary, config or credential change notifies a `multica daemon restart` +A binary, config or credential change notifies a `systemctl --user restart` handler, which is flushed *before* verification reads the daemon's state back. +The role never calls `multica daemon start` or `multica daemon restart`. Both +fork the daemon inside the caller's session (Ansible's SSH session scope), +outside the unit's cgroup. The unit then shows `inactive`, nothing supervises +the process, and a later `systemctl --user start` collides with it on +`daemon.lock`. + A converged host reports zero changed tasks. ## Verification diff --git a/ansible/roles/multica_agent_host/handlers/main.yml b/ansible/roles/multica_agent_host/handlers/main.yml index cdd73d2..055bcfb 100644 --- a/ansible/roles/multica_agent_host/handlers/main.yml +++ b/ansible/roles/multica_agent_host/handlers/main.yml @@ -1,7 +1,9 @@ --- +# Through systemd, not `multica daemon restart`: that respawns the daemon in +# the caller's session, outside the unit (see tasks/daemon.yml). - name: Restart multica daemon ansible.builtin.command: - argv: "{{ _multica_cmd + ['daemon', 'restart'] }}" + argv: [systemctl, --user, restart, "{{ _multica_unit | default('multica-daemon.service') }}"] become: true become_user: "{{ multica_user }}" environment: "{{ _multica_user_env }}" diff --git a/ansible/roles/multica_agent_host/tasks/auth.yml b/ansible/roles/multica_agent_host/tasks/auth.yml index d5fb803..0fc8b0a 100644 --- a/ansible/roles/multica_agent_host/tasks/auth.yml +++ b/ansible/roles/multica_agent_host/tasks/auth.yml @@ -1,6 +1,10 @@ --- # `multica auth status` prints the server and user when authenticated and exits # non-zero otherwise, so it is the idempotency guard for login. +# +# It prints to STDERR, not stdout (0.6.5). A stdout-only guard never matches, +# so every run re-logs in and restarts the daemon. It also echoes a token +# prefix, hence no_log. - name: Auth | Read the current authentication state ansible.builtin.command: argv: "{{ _multica_cmd + ['auth', 'status'] }}" @@ -11,13 +15,15 @@ changed_when: false failed_when: false check_mode: false + no_log: true - 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('')) }} + or multica_server_url.rstrip('/') not in + ((_multica_auth_status.stdout | default('')) ~ (_multica_auth_status.stderr | default(''))) }} # no_log is mandatory: without it Ansible records the full argv — including the # PAT — in play output, CI logs, and every callback plugin. diff --git a/ansible/roles/multica_agent_host/tasks/daemon.yml b/ansible/roles/multica_agent_host/tasks/daemon.yml index bbd4f31..85ef03a 100644 --- a/ansible/roles/multica_agent_host/tasks/daemon.yml +++ b/ansible/roles/multica_agent_host/tasks/daemon.yml @@ -43,8 +43,61 @@ - multica_autostart | bool - _multica_autostart_status.rc != 0 or 'enabled' not in (_multica_autostart_status.stdout | default('') | lower) + register: _multica_autostart_enable changed_when: true +# Lifecycle goes through systemd, never `multica daemon start|restart`. Those +# fork the daemon inside the CALLER's session (here, Ansible's SSH session +# scope), outside the unit's cgroup: the unit stays inactive, nothing +# supervises the process, and the next `systemctl --user start` collides with +# it on daemon.lock. Seen on haunted-mansion (2026-10-21) and spaceship-earth +# (2026-10-22). +# +# The unit name comes from `autostart status` ("Location: .../.service") +# so a --profile host resolves its own unit. +- name: Daemon | Re-read the autostart registration + ansible.builtin.command: + argv: "{{ _multica_cmd + ['daemon', 'autostart', 'status'] }}" + become: true + become_user: "{{ multica_user }}" + environment: "{{ _multica_user_env }}" + register: _multica_autostart_reread + changed_when: false + failed_when: false + check_mode: false + when: _multica_autostart_enable is changed + +- name: Daemon | Resolve the systemd user unit + ansible.builtin.set_fact: + _multica_unit: >- + {{ ((_s.stdout | default('')) ~ '\n' ~ (_s.stderr | default(''))) + | regex_search('Location:\s*\S*/([^/\s]+\.service)', '\1') + | default(['multica-daemon.service'], true) | first }} + vars: + _s: "{{ _multica_autostart_reread if _multica_autostart_enable is changed else _multica_autostart_status }}" + +# `autostart enable` may rewrite the unit file while the user manager still +# holds the old definition. +- name: Daemon | Reload the user manager after a (re)registration + ansible.builtin.command: + argv: [systemctl, --user, daemon-reload] + become: true + become_user: "{{ multica_user }}" + environment: "{{ _multica_user_env }}" + when: _multica_autostart_enable is changed + changed_when: true + +- name: Daemon | Read the unit state + ansible.builtin.command: + argv: [systemctl, --user, is-active, "{{ _multica_unit }}"] + become: true + become_user: "{{ multica_user }}" + environment: "{{ _multica_user_env }}" + register: _multica_unit_state + changed_when: false + failed_when: false + check_mode: false + - name: Daemon | Read daemon status ansible.builtin.command: argv: "{{ _multica_cmd + ['daemon', 'status', '--output', 'json'] }}" @@ -56,15 +109,27 @@ failed_when: false check_mode: false -- name: Daemon | Start it if it is not already running +# Running, but not under the unit: an orphan. Stop it so the unit can own the +# lock and the process. +- name: Daemon | Stop a daemon running outside its unit ansible.builtin.command: - argv: "{{ _multica_cmd + ['daemon', 'start'] }}" + argv: "{{ _multica_cmd + ['daemon', 'stop'] }}" 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' + when: + - (_multica_unit_state.stdout | trim) != 'active' + - _multica_daemon_status.rc == 0 + - ((_multica_daemon_status.stdout | from_json).status | default('')) == 'running' + changed_when: true + +- name: Daemon | Start the unit if it is not active + ansible.builtin.command: + argv: [systemctl, --user, start, "{{ _multica_unit }}"] + become: true + become_user: "{{ multica_user }}" + environment: "{{ _multica_user_env }}" + when: (_multica_unit_state.stdout | trim) != 'active' changed_when: true # Anything that changed the binary, the config or the credentials restarts the