feat(mcp): write, test and delete this instance's own playbooks (#5748)
#5748 "MCP can write, edit and test this instance's own playbooks (steward-local)": two admin tools beside run_playbook, so an agent can build automation for these hosts that isn't part of Steward itself. - save_playbook(playbook, content, source="steward-local", replace=false) goes through the editor's own validate_playbook_yaml and write_playbook. A playbook that isn't a list of plays comes back with the YAML error and its line, and nothing is written. An existing file is overwritten only with replace=true. steward-builtin is refused (Steward's playbooks change in its repository), as are git sources (their repo owns them). - After writing, `ansible-playbook --syntax-check` runs against a throwaway localhost inventory (no host contacted, 60 s), so a module name Ansible can't resolve shows before any run. The result rides the reply; the file is kept either way. - delete_playbook(playbook, source="steward-local"). - Both audit as ansible.playbook.created/updated/deleted, "<user> via MCP", with the size and never the content. The testing loop is the existing one: run_playbook defaults to check mode, on one target, then get_ansible_run. The integration test does that loop with the real ansible-playbook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -329,6 +329,36 @@ def ansible_env(creds: dict | None, base_env) -> dict:
|
||||
return env
|
||||
|
||||
|
||||
# Long enough for Ansible to load its collections on a cold start; a syntax
|
||||
# check reaches no host.
|
||||
SYNTAX_CHECK_SECONDS = 60
|
||||
SYNTAX_OUTPUT_CHARS = 2000
|
||||
|
||||
|
||||
async def syntax_check(source_path: str, relative_path: str) -> tuple[bool | None, str]:
|
||||
"""`ansible-playbook --syntax-check` on a saved playbook (#5748): catches
|
||||
what a YAML parse doesn't, such as a module name Ansible can't resolve or
|
||||
a missing role. Runs in the source's folder against a throwaway localhost
|
||||
inventory, so no host is contacted. Returns (True, ""), (False, output),
|
||||
or (None, why) when the check couldn't run."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ansible-playbook", "--syntax-check", "-i", "localhost,", relative_path,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
|
||||
cwd=source_path, env=ansible_env({}, os.environ), start_new_session=True)
|
||||
except FileNotFoundError:
|
||||
return None, "ansible-playbook isn't installed here, so only the YAML was checked."
|
||||
try:
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), SYNTAX_CHECK_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
await _terminate_then_kill(proc, grace=2.0)
|
||||
return None, f"The syntax check took over {SYNTAX_CHECK_SECONDS} s and was stopped."
|
||||
text = out.decode(errors="replace").strip()
|
||||
if proc.returncode == 0:
|
||||
return True, ""
|
||||
return False, text[-SYNTAX_OUTPUT_CHARS:]
|
||||
|
||||
|
||||
# ── Structured results (parsed from the streamed output) ──────────────────────
|
||||
|
||||
_RECAP_RE = re.compile(
|
||||
|
||||
Reference in New Issue
Block a user