diff --git a/plugins/host_agent/routes.py b/plugins/host_agent/routes.py index 269296b..da49482 100644 --- a/plugins/host_agent/routes.py +++ b/plugins/host_agent/routes.py @@ -809,3 +809,38 @@ async def provision_via_ansible(): if err: return _error(400, "provision_failed", err) return redirect(url_for("ansible.run_detail", run_id=run.id)) + + +@host_agent_bp.post("/update") +@require_role(UserRole.admin) +async def update_via_ansible(): + """Refresh agent.py on already-installed hosts via the bundled update + playbook. Connects as the managed steward account — no token rotation, no + config rewrite (the host keeps its existing identity).""" + from steward.core.capabilities import has_capability, invoke_capability + from steward.ansible.sources import BUILTIN_SOURCE_NAME + + if not has_capability("ansible.run_playbook"): + return _error(400, "ansible_unavailable", "Ansible is not available") + + form = await request.form + scope = (form.get("inventory_scope", "") or "").strip() + if not (scope.startswith("steward:target:") + or scope.startswith("steward:group:") + or scope == "steward:all"): + return _error(400, "bad_scope", "Choose a target or group") + + url = public_base_url(request) + actor_role = UserRole(session.get("user_role", "viewer")) + run, _source, err = await invoke_capability( + "ansible.run_playbook", actor_role, + current_app._get_current_object(), # type: ignore[attr-defined] + source_name=BUILTIN_SOURCE_NAME, + playbook_path="host_agent/update.yml", + inventory_scope=scope, + params={"extra_vars": [f"steward_url={url}"]}, + triggered_by=session.get("user_id"), + ) + if err: + return _error(400, "update_failed", err) + return redirect(url_for("ansible.run_detail", run_id=run.id)) diff --git a/plugins/host_agent/templates/settings_list.html b/plugins/host_agent/templates/settings_list.html index 77fdf71..7cbff8f 100644 --- a/plugins/host_agent/templates/settings_list.html +++ b/plugins/host_agent/templates/settings_list.html @@ -31,41 +31,63 @@ {% if ansible_available %} +{% set scope_select %} + + +{% endset %} +
-

Provision a fresh host

-

- First contact for a host Steward can't yet reach by key. Creates a - steward login account with passwordless sudo, installs the managed - key, then installs the agent — over a one-time bootstrap password (used for this - run only, never stored). After this, use Deploy below for updates. +

Agent lifecycle via Ansible

+

+ These actions run bundled Ansible playbooks to deploy and maintain the + Steward monitoring agent on your inventory hosts. + Steward generates each host's API token automatically and connects over SSH as the managed + steward account — you'll be dropped into the live Ansible run to watch it.

+ {% if not managed_key_set %} -

- No managed key yet. Generate one under Settings → Ansible first. -

+
+ + + No managed SSH key yet — Steward needs one to log into hosts as steward. + +
+ + +
+
{% elif not (deploy_targets or deploy_groups) %} -

+

No Ansible inventory targets yet. Add some under Ansible → Inventory.

- {% else %} + {% endif %} +
+ +{% if managed_key_set and (deploy_targets or deploy_groups) %} +
+

1 · Provision a fresh host

+

+ First contact for a brand-new host. Connects with a one-time bootstrap + user + password (used for this run only, never stored), creates the steward + login account with passwordless sudo, installs the managed SSH key, then installs the + agent. Run this once per host. +

-
- - -
+
{{ scope_select }}
- +
- +
@@ -73,37 +95,40 @@
- {% endif %}
-

Deploy via Ansible

+

2 · Install / enroll agent

- Install (or update) the agent on Ansible inventory hosts in one run — no per-host curl | sh. - A fresh token is minted per host and injected into the run; existing agents are rotated to the new token. + For a host that's already provisioned (has the steward account) but has no + agent yet — or to re-enroll one. Connects as steward with the managed key, + mints a fresh token, and installs the agent. No curl | sh needed.

- {% if not (deploy_targets or deploy_groups) %} -

- No Ansible inventory targets yet. Add some under Ansible → Browse. -

- {% else %}
-
- - -
+
{{ scope_select }}
- +
- {% endif %}
+ +
+

3 · Update agents

+

+ Refresh the agent binary on hosts already running it. Connects as steward + with the managed key and restarts the service — the token and config are left untouched, + so identity is preserved. +

+
+
{{ scope_select }}
+ +
+
+{% endif %} {% endif %}
diff --git a/steward/ansible/bundled/host_agent/provision.yml b/steward/ansible/bundled/host_agent/provision.yml index db35589..73be606 100644 --- a/steward/ansible/bundled/host_agent/provision.yml +++ b/steward/ansible/bundled/host_agent/provision.yml @@ -50,9 +50,13 @@ group: "{{ steward_user }}" mode: "0700" - - name: Authorize the managed public key + - name: Authorize the managed public key (replace any prior steward-managed key) ansible.builtin.lineinfile: path: "/home/{{ steward_user }}/.ssh/authorized_keys" + # Match on the ' steward-managed' comment so a rotated key REPLACES the + # old one in place rather than stacking a second authorized key. Any + # keys the operator added by hand (different comment) are left untouched. + regexp: ' steward-managed$' line: "{{ steward_pubkey }}" create: true owner: "{{ steward_user }}" diff --git a/steward/ansible/bundled/host_agent/update.yml b/steward/ansible/bundled/host_agent/update.yml new file mode 100644 index 0000000..8dff5ff --- /dev/null +++ b/steward/ansible/bundled/host_agent/update.yml @@ -0,0 +1,47 @@ +--- +# Update an already-installed Steward host agent: refresh agent.py and restart. +# +# Deliberately does NOT touch the registration token or /etc/steward-agent.conf +# — the host already has a working token, so an update leaves identity alone and +# only swaps the agent binary. Connects as the managed `steward` account (set +# globally as the SSH user); no bootstrap password or token injection needed. +# +# For a fresh host with no agent, use provision.yml (new host) or install.yml +# (already-provisioned host) instead — those mint and install the token. +- name: Update Steward host agent + hosts: all + become: true + gather_facts: false + vars: + steward_url: "" + tasks: + - name: Require steward_url + ansible.builtin.assert: + that: + - steward_url | length > 0 + fail_msg: "Pass steward_url as an extra-var (the Update action sets it)." + + - name: Confirm the agent is already installed + ansible.builtin.stat: + path: /usr/local/lib/steward-agent/agent.py + register: agent_file + + - name: Fail clearly if the agent is missing + ansible.builtin.fail: + msg: "No agent at /usr/local/lib/steward-agent — provision or install this host first." + when: not agent_file.stat.exists + + - name: Refresh agent.py + ansible.builtin.get_url: + url: "{{ steward_url }}/plugins/host_agent/agent.py" + dest: /usr/local/lib/steward-agent/agent.py + mode: "0755" + force: true + notify: restart steward-agent + + handlers: + - name: restart steward-agent + ansible.builtin.systemd: + name: steward-agent + state: restarted + daemon_reload: true diff --git a/steward/settings/routes.py b/steward/settings/routes.py index 8944f58..64d623c 100644 --- a/steward/settings/routes.py +++ b/steward/settings/routes.py @@ -197,6 +197,7 @@ async def ansible_generate_key(): """ from steward.core.crypto import generate_ssh_keypair + form = await request.form private_pem, public_line = generate_ssh_keypair() async with current_app.db_sessionmaker() as db: async with db.begin(): @@ -205,6 +206,10 @@ async def ansible_generate_key(): await _reload_app_config() await log_audit(current_app, session.get("user_id"), session.get("username", ""), "settings.saved", detail={"section": "ansible.managed_key"}) + # Allow the inline trigger on other pages (e.g. Host Agents) to return there. + nxt = (form.get("next", "") or "").strip() + if nxt.startswith("/") and not nxt.startswith("//"): + return redirect(nxt) return redirect(url_for("settings.ansible")) diff --git a/steward/templates/base.html b/steward/templates/base.html index 7c082ca..2e81c39 100644 --- a/steward/templates/base.html +++ b/steward/templates/base.html @@ -146,7 +146,9 @@ textarea { resize: vertical; } .empty { color: var(--text-muted); font-size: 0.9rem; padding: 2rem; text-align: center; } /* Breadcrumbs */ -.breadcrumb { display:flex; align-items:center; flex-wrap:wrap; gap:0.35rem; font-size:0.8rem; color:var(--text-dim); margin-bottom:1rem; } +/* Breadcrumb renders as a kicker line tucked directly above the page header + (.page-title), so nested and top-level views share one consistent header. */ +.breadcrumb { display:flex; align-items:center; flex-wrap:wrap; gap:0.35rem; font-size:0.78rem; color:var(--text-dim); margin:0 0 0.45rem; } .breadcrumb a { color:var(--text-muted); } .breadcrumb a:hover { color:var(--text); } .breadcrumb-sep { color:var(--border-mid); } @@ -235,7 +237,6 @@ function setTimeRange(val) {
- {% block breadcrumb %}{% endblock %} {% if plugin_failures and session.user_role == 'admin' %}
{{ error }}
{% endif %} + {% block breadcrumb %}{% endblock %} {% block content %}{% endblock %}