diff --git a/steward/ansible/bundled/host_agent/install.yml b/steward/ansible/bundled/host_agent/install.yml new file mode 100644 index 0000000..bb39dd4 --- /dev/null +++ b/steward/ansible/bundled/host_agent/install.yml @@ -0,0 +1,93 @@ +--- +# Install (or update) the Steward host agent on a target. Mirrors the +# curl-based install.sh. Pass the per-host registration token as extra-vars: +# steward_url=https://steward.example steward_token= +# Re-running updates agent.py in place and restarts the service. +- name: Install Steward host agent + hosts: all + become: true + gather_facts: false + vars: + steward_url: "" + steward_token: "" + agent_interval: 30 + tasks: + - name: Require steward_url and steward_token + ansible.builtin.assert: + that: + - steward_url | length > 0 + - steward_token | length > 0 + fail_msg: "Pass steward_url and steward_token as extra-vars." + + - name: Create steward-agent system user + ansible.builtin.user: + name: steward-agent + system: true + shell: /usr/sbin/nologin + create_home: false + + - name: Create agent directory + ansible.builtin.file: + path: /usr/local/lib/steward-agent + state: directory + mode: "0755" + + - name: Download 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 + + - name: Write agent config + ansible.builtin.copy: + dest: /etc/steward-agent.conf + owner: root + group: steward-agent + mode: "0640" + content: | + url = {{ steward_url }} + token = {{ steward_token }} + interval_seconds = {{ agent_interval }} + notify: restart steward-agent + + - name: Install systemd unit + ansible.builtin.copy: + dest: /etc/systemd/system/steward-agent.service + mode: "0644" + content: | + [Unit] + Description=Steward host agent + After=network-online.target + Wants=network-online.target + + [Service] + Type=simple + User=steward-agent + Environment=STEWARD_AGENT_CONFIG=/etc/steward-agent.conf + ExecStart=/usr/bin/env python3 /usr/local/lib/steward-agent/agent.py + Restart=on-failure + RestartSec=5 + NoNewPrivileges=true + ProtectSystem=strict + ProtectHome=true + PrivateTmp=true + + [Install] + WantedBy=multi-user.target + notify: restart steward-agent + + - name: Enable and start the agent + ansible.builtin.systemd: + name: steward-agent + enabled: true + state: started + daemon_reload: true + + handlers: + - name: restart steward-agent + ansible.builtin.systemd: + name: steward-agent + state: restarted + daemon_reload: true diff --git a/steward/ansible/bundled/maintenance/docker_prune.yml b/steward/ansible/bundled/maintenance/docker_prune.yml new file mode 100644 index 0000000..1fcb8ec --- /dev/null +++ b/steward/ansible/bundled/maintenance/docker_prune.yml @@ -0,0 +1,26 @@ +--- +# Reclaim disk on Docker / Docker Swarm nodes by removing unused data. +# Safe by default: prunes dangling images, stopped containers, unused networks +# and build cache. Set extra-vars to widen scope: +# prune_all_images=true also remove ALL unused images (not just dangling) +# prune_volumes=true also remove unused named volumes (data loss risk) +- name: Docker system prune + hosts: all + gather_facts: false + become: true + vars: + prune_all_images: false + prune_volumes: false + tasks: + - name: Run docker system prune + ansible.builtin.command: + argv: >- + {{ ['docker', 'system', 'prune', '-f'] + + (['-a'] if prune_all_images | bool else []) + + (['--volumes'] if prune_volumes | bool else []) }} + register: prune_result + changed_when: "'Total reclaimed space: 0B' not in prune_result.stdout" + + - name: Report reclaimed space + ansible.builtin.debug: + msg: "{{ prune_result.stdout_lines | select | list }}" diff --git a/steward/ansible/sources.py b/steward/ansible/sources.py index e1b6855..04c0c7a 100644 --- a/steward/ansible/sources.py +++ b/steward/ansible/sources.py @@ -10,6 +10,22 @@ logger = logging.getLogger(__name__) INVENTORY_NAMES = {"hosts", "inventory", "inventory.yml", "inventory.ini"} +# Name of the always-present, read-only source of first-party playbooks shipped +# inside the app (maintenance tasks, host-agent install). Not operator-editable. +BUILTIN_SOURCE_NAME = "steward-builtin" + + +def _builtin_source() -> dict: + return { + "name": BUILTIN_SOURCE_NAME, + "type": "local", + "path": str((Path(__file__).parent / "bundled").resolve()), + "url": None, + "branch": "main", + "pull_interval_seconds": 0, + "http_token": "", + } + def get_sources(ansible_cfg: dict) -> list[dict]: """Return resolved source list from ansible config section. @@ -33,7 +49,8 @@ def get_sources(ansible_cfg: dict) -> list[dict]: """ sources = ansible_cfg.get("sources", []) cache_dir = ansible_cfg.get("cache_dir", "/var/cache/steward/ansible") - result = [] + # Always expose the bundled first-party playbooks as a source. + result = [_builtin_source()] for src in sources: src_type = src.get("type", "local") if src_type == "git": diff --git a/tests/core/test_builtin_source.py b/tests/core/test_builtin_source.py new file mode 100644 index 0000000..323590a --- /dev/null +++ b/tests/core/test_builtin_source.py @@ -0,0 +1,19 @@ +"""The bundled first-party playbook source is always present and discoverable.""" +from steward.ansible.sources import ( + BUILTIN_SOURCE_NAME, + discover_playbooks, + get_sources, +) + + +def test_builtin_source_is_first_and_local(): + sources = get_sources({"sources": []}) + assert sources[0]["name"] == BUILTIN_SOURCE_NAME + assert sources[0]["type"] == "local" + + +def test_bundled_playbooks_are_discoverable(): + builtin = get_sources({"sources": []})[0] + playbooks = discover_playbooks(builtin["path"]) + assert "maintenance/docker_prune.yml" in playbooks + assert "host_agent/install.yml" in playbooks diff --git a/tests/core/test_git_askpass.py b/tests/core/test_git_askpass.py index a2e9a8e..6fb9c53 100644 --- a/tests/core/test_git_askpass.py +++ b/tests/core/test_git_askpass.py @@ -22,7 +22,8 @@ def test_get_sources_includes_http_token(tmp_path): ], } sources = get_sources(cfg) - assert sources[0]["http_token"] == "mytoken123" + src = next(s for s in sources if s["name"] == "repo") + assert src["http_token"] == "mytoken123" def test_get_sources_missing_http_token_defaults_empty(tmp_path): @@ -36,7 +37,8 @@ def test_get_sources_missing_http_token_defaults_empty(tmp_path): ], } sources = get_sources(cfg) - assert sources[0]["http_token"] == "" + src = next(s for s in sources if s["name"] == "r") + assert src["http_token"] == "" @pytest.mark.asyncio