feat(ansible): credentials — SSH key, become, vault, host-key checking (task 548)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m24s

Global Ansible credentials, applied to every run (manual + alert-triggered):
- core/settings.py: ansible.ssh_private_key / become_password / vault_password
  (plaintext at rest, masked in UI — encryption tracked in #580) + host_key_checking
  (default off); surfaced via to_ansible_cfg into app.config[ANSIBLE]
- executor.py: pure build_credentials() materializes creds into a 0600 temp dir
  (--private-key, --vault-password-file, become via -e @vars-file so the password
  never hits argv) cleaned up in finally; pure ansible_env() sets
  ANSIBLE_HOST_KEY_CHECKING. build_ansible_command stays param-only
- settings/routes.py + ansible.html: admin-only Credentials section, masked-update
  (blank keeps current, explicit Clear checkbox), reload app config on save
- tests: unit (build_credentials per cred + none; ansible_env toggle); integration
  vault round-trip (ansible-vault encrypt a vars file, run via executor with the
  vault password, assert it decrypted)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 18:41:58 -04:00
parent 0a36a57901
commit 0bf007173b
6 changed files with 295 additions and 3 deletions
+58 -1
View File
@@ -1,7 +1,11 @@
# steward/ansible/executor.py # steward/ansible/executor.py
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
import os
import shutil
import tempfile
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -83,6 +87,46 @@ def build_ansible_command(
return cmd return cmd
def build_credentials(creds: dict | None, tmpdir: str) -> tuple[list[str], list[tuple[str, str]]]:
"""Given global Ansible creds + a temp dir, return (extra argv, files to write).
Pure: decides flags + file contents; the caller writes the files (mode 0600).
Secrets are always passed via files — never on argv / the process list.
creds keys: ssh_private_key, vault_password, become_password.
"""
args: list[str] = []
files: list[tuple[str, str]] = []
creds = creds or {}
key = creds.get("ssh_private_key")
if key:
path = os.path.join(tmpdir, "id_key")
files.append((path, key if key.endswith("\n") else key + "\n"))
args += ["--private-key", path]
vault = creds.get("vault_password")
if vault:
path = os.path.join(tmpdir, "vault_pass")
files.append((path, vault + "\n"))
args += ["--vault-password-file", path]
become = creds.get("become_password")
if become:
path = os.path.join(tmpdir, "become.yml")
# A JSON string is valid YAML; keeps the password out of argv.
files.append((path, "ansible_become_password: " + json.dumps(become) + "\n"))
args += ["-e", "@" + path]
return args, files
def ansible_env(creds: dict | None, base_env) -> dict:
"""Return a copy of base_env with ANSIBLE_HOST_KEY_CHECKING set from creds."""
env = dict(base_env)
env["ANSIBLE_HOST_KEY_CHECKING"] = "True" if (creds or {}).get("host_key_checking") else "False"
return env
async def start_run( async def start_run(
app: "Quart", app: "Quart",
run_id: str, run_id: str,
@@ -119,13 +163,24 @@ async def start_run(
cmd = build_ansible_command(playbook_path, inventory_path, params) cmd = build_ansible_command(playbook_path, inventory_path, params)
cwd = source_path if Path(source_path).exists() else None cwd = source_path if Path(source_path).exists() else None
creds = app.config.get("ANSIBLE", {})
# Materialize credentials into a private temp dir; removed in finally.
tmpdir = tempfile.mkdtemp(prefix="steward-ansible-")
os.chmod(tmpdir, 0o700)
try: try:
cred_args, cred_files = build_credentials(creds, tmpdir)
for cred_path, content in cred_files:
with open(cred_path, "w", encoding="utf-8") as cf:
cf.write(content)
os.chmod(cred_path, 0o600)
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
*cmd, *cmd, *cred_args,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT, stderr=asyncio.subprocess.STDOUT,
cwd=cwd, cwd=cwd,
env=ansible_env(creds, os.environ),
) )
assert proc.stdout is not None assert proc.stdout is not None
@@ -152,6 +207,8 @@ async def start_run(
except Exception: except Exception:
logger.exception("Ansible run %s failed with exception", run_id) logger.exception("Ansible run %s failed with exception", run_id)
final_status = "failed" final_status = "failed"
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
await _flush(final_status) await _flush(final_status)
_broadcast(run_id, _Done(status=final_status)) _broadcast(run_id, _Done(status=final_status))
+13 -1
View File
@@ -51,6 +51,12 @@ DEFAULTS: dict[str, Any] = {
"webhook.url": "", "webhook.url": "",
"webhook.template": _DEFAULT_WEBHOOK_TEMPLATE, "webhook.template": _DEFAULT_WEBHOOK_TEMPLATE,
"ansible.sources": [], "ansible.sources": [],
# Ansible credentials — global, used by every run (manual + alert-triggered).
# Plaintext at rest, masked in the UI (encryption-at-rest tracked separately).
"ansible.ssh_private_key": "",
"ansible.become_password": "",
"ansible.vault_password": "",
"ansible.host_key_checking": False,
"ping.threshold.good_ms": 50, "ping.threshold.good_ms": 50,
"ping.threshold.warn_ms": 200, "ping.threshold.warn_ms": 200,
"plugins.index_url": "https://git.fabledsword.com/bvandeusen/Steward-plugins/raw/branch/main/index.yaml", "plugins.index_url": "https://git.fabledsword.com/bvandeusen/Steward-plugins/raw/branch/main/index.yaml",
@@ -152,7 +158,13 @@ def to_webhook_cfg(settings: dict[str, Any]) -> dict:
def to_ansible_cfg(settings: dict[str, Any]) -> dict: def to_ansible_cfg(settings: dict[str, Any]) -> dict:
return {"sources": settings.get("ansible.sources", [])} return {
"sources": settings.get("ansible.sources", []),
"ssh_private_key": settings.get("ansible.ssh_private_key", ""),
"become_password": settings.get("ansible.become_password", ""),
"vault_password": settings.get("ansible.vault_password", ""),
"host_key_checking": settings.get("ansible.host_key_checking", False),
}
def to_oidc_cfg(settings: dict[str, Any]) -> dict: def to_oidc_cfg(settings: dict[str, Any]) -> dict:
+37 -1
View File
@@ -170,7 +170,43 @@ async def _ansible_sources_partial(sources: list[dict]):
@require_role(UserRole.admin) @require_role(UserRole.admin)
async def ansible(): async def ansible():
sources = await _get_ansible_sources() sources = await _get_ansible_sources()
return await render_template("settings/ansible.html", sources=sources) async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
return await render_template(
"settings/ansible.html",
sources=sources,
ssh_key_set=bool(settings.get("ansible.ssh_private_key")),
become_set=bool(settings.get("ansible.become_password")),
vault_set=bool(settings.get("ansible.vault_password")),
host_key_checking=settings.get("ansible.host_key_checking", False),
)
@settings_bp.post("/ansible/credentials")
@require_role(UserRole.admin)
async def ansible_save_credentials():
"""Save global Ansible credentials (masked-update: blank keeps the current value)."""
form = await request.form
async with current_app.db_sessionmaker() as db:
async with db.begin():
for field, key in (
("ssh_private_key", "ansible.ssh_private_key"),
("become_password", "ansible.become_password"),
("vault_password", "ansible.vault_password"),
):
if f"clear_{field}" in form:
await set_setting(db, key, "")
continue
raw = form.get(field, "") or ""
# Preserve PEM whitespace for the key; trim passwords.
val = raw.strip("\n") if field == "ssh_private_key" else raw.strip()
if val:
await set_setting(db, key, val)
await set_setting(db, "ansible.host_key_checking", "host_key_checking" in form)
await _reload_app_config()
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"settings.saved", detail={"section": "ansible.credentials"})
return redirect(url_for("settings.ansible"))
@settings_bp.post("/ansible/sources/add") @settings_bp.post("/ansible/sources/add")
+48
View File
@@ -15,5 +15,53 @@
cloned automatically. Add as many as you need; each can be browsed and run independently. cloned automatically. Add as many as you need; each can be browsed and run independently.
</p> </p>
{% include "settings/_ansible_sources.html" %} {% include "settings/_ansible_sources.html" %}
{# ── Credentials ──────────────────────────────────────────────────────────── #}
{% set cfg_badge = '<span style="color:var(--green);font-size:0.72rem;">(configured)</span>' %}
{% set not_set_badge = '<span style="color:var(--text-muted);font-size:0.72rem;">(not set)</span>' %}
<div style="margin-top:2.25rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Credentials</div>
<p style="color:var(--text-muted);font-size:0.84rem;margin-bottom:1rem;">
Used by every playbook run (manual and alert-triggered). Stored on the server and
never shown again after saving — leave a field blank to keep the current value.
</p>
<form method="post" action="/settings/ansible/credentials">
<div class="form-group">
<label>SSH private key {{ (cfg_badge if ssh_key_set else not_set_badge) | safe }}</label>
<textarea name="ssh_private_key" rows="4"
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- … (blank = keep current)"
style="width:100%;font-family:ui-monospace,monospace;font-size:0.8rem;"></textarea>
{% if ssh_key_set %}
<label style="font-weight:normal;font-size:0.8rem;color:var(--text-muted);">
<input type="checkbox" name="clear_ssh_private_key"> Clear stored key</label>
{% endif %}
</div>
<div class="form-group">
<label>Become / sudo password {{ (cfg_badge if become_set else not_set_badge) | safe }}</label>
<input type="password" name="become_password" autocomplete="new-password"
placeholder="blank = keep current">
{% if become_set %}
<label style="font-weight:normal;font-size:0.8rem;color:var(--text-muted);">
<input type="checkbox" name="clear_become_password"> Clear</label>
{% endif %}
</div>
<div class="form-group">
<label>Vault password {{ (cfg_badge if vault_set else not_set_badge) | safe }}</label>
<input type="password" name="vault_password" autocomplete="new-password"
placeholder="blank = keep current">
{% if vault_set %}
<label style="font-weight:normal;font-size:0.8rem;color:var(--text-muted);">
<input type="checkbox" name="clear_vault_password"> Clear</label>
{% endif %}
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:normal;">
<input type="checkbox" name="host_key_checking" {% if host_key_checking %}checked{% endif %}>
Enforce SSH host-key checking <span style="color:var(--text-muted);font-size:0.78rem;">(off by default for homelab convenience)</span>
</label>
</div>
<button type="submit" class="btn">Save credentials</button>
</form>
</div>
</div> </div>
{% endblock %} {% endblock %}
@@ -0,0 +1,82 @@
"""Integration: the executor wires a configured vault password into a real run (task 548).
Encrypts a vars file with ansible-vault, then runs a playbook through
executor.start_run with the matching vault_password in app config, and asserts
the executor's --vault-password-file decrypted it. (SSH-key/become auth need a
remote host, so they're covered by the unit-level flag construction.)
"""
from __future__ import annotations
import asyncio
import os
import subprocess
import textwrap
import uuid
import pytest
pytestmark = pytest.mark.integration
_NEEDS_DB = pytest.mark.skipif(
not os.environ.get("STEWARD_DATABASE_URL"),
reason="integration test needs a live Postgres (STEWARD_DATABASE_URL)",
)
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
@_NEEDS_DB
def test_vault_password_decrypts_a_run(app, tmp_path):
from sqlalchemy import select
from steward.ansible import executor
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
vault_pw = "test-vault-pw"
pwfile = tmp_path / "pw"
pwfile.write_text(vault_pw + "\n")
secret = tmp_path / "secret.yml"
secret.write_text("vaulted_value: hello-from-vault\n")
subprocess.run(
["ansible-vault", "encrypt", str(secret), "--vault-password-file", str(pwfile)],
check=True, capture_output=True,
)
(tmp_path / "play.yml").write_text(textwrap.dedent("""\
- hosts: localhost
connection: local
gather_facts: false
vars_files:
- secret.yml
tasks:
- debug:
msg: "VAULT-{{ vaulted_value }}"
"""))
(tmp_path / "inv.ini").write_text("localhost ansible_connection=local\n")
# Executor reads credentials from app.config["ANSIBLE"].
app.config["ANSIBLE"] = {"vault_password": vault_pw, "host_key_checking": False}
run_id = str(uuid.uuid4())
async def _go():
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(AnsibleRun(
id=run_id, playbook_path="play.yml", inventory_path="inv.ini",
source_name="t", triggered_by=None, status=AnsibleRunStatus.running,
))
await executor.start_run(app, run_id, "play.yml", "inv.ini", str(tmp_path))
async with app.db_sessionmaker() as s:
return (await s.execute(
select(AnsibleRun).where(AnsibleRun.id == run_id))).scalar_one()
run = asyncio.run(_go())
assert run.status == AnsibleRunStatus.success, run.output
assert "VAULT-hello-from-vault" in (run.output or "") # vault password decrypted it
+57
View File
@@ -0,0 +1,57 @@
"""Unit tests for Ansible credential argv/env construction (task 548)."""
import json
from steward.ansible.executor import ansible_env, build_credentials
def test_no_creds_is_empty():
assert build_credentials({}, "/td") == ([], [])
assert build_credentials(None, "/td") == ([], [])
def test_ssh_key_via_file():
args, files = build_credentials({"ssh_private_key": "KEYDATA"}, "/td")
assert args == ["--private-key", "/td/id_key"]
assert files == [("/td/id_key", "KEYDATA\n")]
def test_ssh_key_keeps_existing_trailing_newline():
_, files = build_credentials({"ssh_private_key": "KEY\n"}, "/td")
assert files == [("/td/id_key", "KEY\n")]
def test_vault_password_via_file():
args, files = build_credentials({"vault_password": "vpw"}, "/td")
assert args == ["--vault-password-file", "/td/vault_pass"]
assert files == [("/td/vault_pass", "vpw\n")]
def test_become_password_uses_vars_file_not_argv():
pw = "p@ss word"
args, files = build_credentials({"become_password": pw}, "/td")
assert args == ["-e", "@/td/become.yml"]
assert files[0][0] == "/td/become.yml"
assert files[0][1] == "ansible_become_password: " + json.dumps(pw) + "\n"
# The password must never appear on the command line.
assert pw not in " ".join(args)
def test_all_three_combined():
args, files = build_credentials(
{"ssh_private_key": "K", "vault_password": "V", "become_password": "B"}, "/td")
assert "--private-key" in args
assert "--vault-password-file" in args
assert "-e" in args
assert len(files) == 3
def test_ansible_env_host_key_checking_toggle():
assert ansible_env({"host_key_checking": True}, {})["ANSIBLE_HOST_KEY_CHECKING"] == "True"
assert ansible_env({"host_key_checking": False}, {})["ANSIBLE_HOST_KEY_CHECKING"] == "False"
assert ansible_env({}, {})["ANSIBLE_HOST_KEY_CHECKING"] == "False"
assert ansible_env(None, {})["ANSIBLE_HOST_KEY_CHECKING"] == "False"
def test_ansible_env_preserves_base():
env = ansible_env({}, {"FOO": "bar"})
assert env["FOO"] == "bar"