feat(ansible): host provisioning via steward managed SSH identity
Turn the agent-install playbook into a full provisioning + maintenance path. Solves the bootstrap chicken-and-egg: first contact uses an operator-supplied password (one run, never stored), which creates a dedicated `steward` login account with NOPASSWD sudo + Steward's managed public key. Every run thereafter connects as `steward` with the managed key — fully unattended (scheduled prune, agent updates). - core/crypto: generate_ssh_keypair() — ed25519, OpenSSH formats. - settings: ansible.ssh_public_key (non-secret, displayed) + ansible.ssh_user (default steward); to_ansible_cfg extended. - settings UI + route: "Generate managed key" (private encrypted, public shown to copy) + SSH-user field. - executor: build_bootstrap() writes a 0600 vars file (-e @file) for the per-run user/password — never argv, never DB, never logged; drops the managed key when a bootstrap password is given; --user floor from the global ssh_user when no override. - runner.trigger_run: pass-through `connection` kwarg, deliberately NOT persisted on AnsibleRun.params (password stays out of the DB). - bundled/host_agent/provision.yml: create steward user + authorized_keys + /etc/sudoers.d/steward (visudo-validated) + agent install. - host_agent: /provision route + "Provision a fresh host" card (bootstrap user/password; injects pubkey + user + token as space-safe JSON hostvars). - Dockerfile: add sshpass (Ansible shells out to it for password SSH). - tests: keypair generation + build_bootstrap (secret stays off argv). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -155,6 +155,37 @@ def build_credentials(creds: dict | None, tmpdir: str) -> tuple[list[str], list[
|
||||
return args, files
|
||||
|
||||
|
||||
def build_bootstrap(connection: dict | None, tmpdir: str) -> tuple[list[str], list[tuple[str, str]]]:
|
||||
"""Per-run connection override for first-contact provisioning.
|
||||
|
||||
Pure: returns (extra argv, files to write). The bootstrap user/password are
|
||||
written to a temp vars file (``-e @file``) — never argv, never the DB — so
|
||||
they don't leak via the process list or persist on the AnsibleRun row.
|
||||
A bare connection password doubles as the become password unless one is
|
||||
given explicitly. connection keys: user, password, become_password.
|
||||
"""
|
||||
args: list[str] = []
|
||||
files: list[tuple[str, str]] = []
|
||||
connection = connection or {}
|
||||
user = (connection.get("user") or "").strip()
|
||||
password = connection.get("password") or ""
|
||||
if not user and not password:
|
||||
return args, files
|
||||
|
||||
lines: dict[str, str] = {}
|
||||
if user:
|
||||
lines["ansible_user"] = user
|
||||
if password:
|
||||
lines["ansible_password"] = password
|
||||
lines["ansible_become_password"] = connection.get("become_password") or password
|
||||
# JSON values are valid YAML and keep arbitrary characters safe.
|
||||
content = "".join(f"{k}: {json.dumps(v)}\n" for k, v in lines.items())
|
||||
path = os.path.join(tmpdir, "bootstrap.yml")
|
||||
files.append((path, content))
|
||||
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)
|
||||
@@ -250,12 +281,17 @@ async def start_run(
|
||||
source_path: str,
|
||||
params: dict | None = None,
|
||||
inventory_content: str | None = None,
|
||||
connection: dict | None = None,
|
||||
) -> None:
|
||||
"""Execute ansible-playbook as a subprocess and update the DB run row.
|
||||
|
||||
Respects a global concurrency cap (app._ansible_semaphore): if no slot is
|
||||
free the run shows as 'queued' until one frees. Supports cancellation, a
|
||||
persistent full-log artifact, and a parsed per-host result summary.
|
||||
|
||||
connection is an optional per-run SSH override (user/password) for
|
||||
first-contact provisioning. It is applied to the subprocess only — it is
|
||||
never persisted on the AnsibleRun row, never placed on argv, and not logged.
|
||||
"""
|
||||
from steward.models.ansible import AnsibleRunStatus
|
||||
|
||||
@@ -328,14 +364,31 @@ async def start_run(
|
||||
inv_target = inventory_path
|
||||
cmd = build_ansible_command(playbook_path, inv_target, params)
|
||||
|
||||
cred_args, cred_files = build_credentials(creds, tmpdir)
|
||||
for cred_path, content in cred_files:
|
||||
# First-contact provisioning supplies a password and a bootstrap
|
||||
# user; the managed key isn't on the host yet, so don't offer it
|
||||
# (avoids a failed key attempt before the password fallback).
|
||||
conn = connection or {}
|
||||
effective_creds = dict(creds)
|
||||
if conn.get("password"):
|
||||
effective_creds.pop("ssh_private_key", None)
|
||||
|
||||
cred_args, cred_files = build_credentials(effective_creds, tmpdir)
|
||||
boot_args, boot_files = build_bootstrap(conn, tmpdir)
|
||||
for cred_path, content in cred_files + boot_files:
|
||||
with open(cred_path, "w", encoding="utf-8") as cf:
|
||||
cf.write(content)
|
||||
os.chmod(cred_path, 0o600)
|
||||
|
||||
# Steady-state floor: connect as the managed account unless a target
|
||||
# var or the bootstrap override (extra-vars, higher precedence) wins.
|
||||
user_args: list[str] = []
|
||||
if not conn.get("user"):
|
||||
ssh_user = (creds.get("ssh_user") or "").strip()
|
||||
if ssh_user:
|
||||
user_args += ["--user", ssh_user]
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, *cred_args,
|
||||
*cmd, *cred_args, *boot_args, *user_args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
cwd=cwd,
|
||||
|
||||
Reference in New Issue
Block a user