multica_agent_host: daemon lifecycle via systemd; fix login guard (WDWL-22)

Found provisioning spaceship-earth:

- Login was not idempotent: `multica auth status` prints to stderr (0.6.5),
  and the guard read stdout only, so every run re-logged in and restarted
  the daemon. The guard now reads both streams; the status read is no_log
  because it echoes a token prefix.
- `multica daemon start` / `daemon restart` fork the daemon inside the
  caller's session (Ansible's SSH session scope), outside the systemd user
  unit, which stays inactive: the same orphan seen on haunted-mansion on
  2026-10-21. Start/restart now go through `systemctl --user`, after a
  daemon-reload when autostart was (re)registered. A daemon found running
  outside its unit is stopped first. The unit name is read from
  `autostart status`, so --profile hosts resolve their own unit.

spaceship-earth: second run changed=0, daemon in multica-daemon.service.
Not yet run against haunted-mansion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
JARVISandClaude Opus 5.5 committed 2026-10-22 16:04:46 -05:00
1 parent 61dba61025
commit 94c77daf13
4 files changed
+89 -10

No files matched your search

+9 -3
View File
@@ -307,13 +307,19 @@ Every step reads before it writes:
| CLI install | `multica --version` equals the pin | | CLI install | `multica --version` equals the pin |
| Agent CLI install | `command -v <binary>` and its version command | | Agent CLI install | `command -v <binary>` and its version command |
| Config | `multica config show` parsed and compared key by key | | 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` | | Autostart | `multica daemon autostart status` |
| Daemon start | `multica daemon status --output json` reports `running` | | Daemon start | `systemctl --user is-active <unit>`; a daemon running *outside* the unit is stopped first, then the unit is started |
A binary, config or credential change notifies a `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. 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. A converged host reports zero changed tasks.
## Verification ## Verification
@@ -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 - name: Restart multica daemon
ansible.builtin.command: ansible.builtin.command:
argv: "{{ _multica_cmd + ['daemon', 'restart'] }}" argv: [systemctl, --user, restart, "{{ _multica_unit | default('multica-daemon.service') }}"]
become: true become: true
become_user: "{{ multica_user }}" become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}" environment: "{{ _multica_user_env }}"
@@ -1,6 +1,10 @@
--- ---
# `multica auth status` prints the server and user when authenticated and exits # `multica auth status` prints the server and user when authenticated and exits
# non-zero otherwise, so it is the idempotency guard for login. # 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 - name: Auth | Read the current authentication state
ansible.builtin.command: ansible.builtin.command:
argv: "{{ _multica_cmd + ['auth', 'status'] }}" argv: "{{ _multica_cmd + ['auth', 'status'] }}"
@@ -11,13 +15,15 @@
changed_when: false changed_when: false
failed_when: false failed_when: false
check_mode: false check_mode: false
no_log: true
- name: Auth | Decide whether a login is needed - name: Auth | Decide whether a login is needed
ansible.builtin.set_fact: ansible.builtin.set_fact:
_multica_needs_login: >- _multica_needs_login: >-
{{ multica_force_relogin | bool {{ multica_force_relogin | bool
or _multica_auth_status.rc != 0 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 # no_log is mandatory: without it Ansible records the full argv — including the
# PAT — in play output, CI logs, and every callback plugin. # PAT — in play output, CI logs, and every callback plugin.
@@ -43,8 +43,61 @@
- multica_autostart | bool - multica_autostart | bool
- _multica_autostart_status.rc != 0 - _multica_autostart_status.rc != 0
or 'enabled' not in (_multica_autostart_status.stdout | default('') | lower) or 'enabled' not in (_multica_autostart_status.stdout | default('') | lower)
register: _multica_autostart_enable
changed_when: true 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: .../<name>.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 - name: Daemon | Read daemon status
ansible.builtin.command: ansible.builtin.command:
argv: "{{ _multica_cmd + ['daemon', 'status', '--output', 'json'] }}" argv: "{{ _multica_cmd + ['daemon', 'status', '--output', 'json'] }}"
@@ -56,15 +109,27 @@
failed_when: false failed_when: false
check_mode: 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: ansible.builtin.command:
argv: "{{ _multica_cmd + ['daemon', 'start'] }}" argv: "{{ _multica_cmd + ['daemon', 'stop'] }}"
become: true become: true
become_user: "{{ multica_user }}" become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}" environment: "{{ _multica_user_env }}"
when: >- when:
_multica_daemon_status.rc != 0 - (_multica_unit_state.stdout | trim) != 'active'
or ((_multica_daemon_status.stdout | from_json).status | default('')) != 'running' - _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 changed_when: true
# Anything that changed the binary, the config or the credentials restarts the # Anything that changed the binary, the config or the credentials restarts the