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(
|
||||
|
||||
@@ -212,7 +212,7 @@ def read_playbook(source_path: str, relative_path: str) -> str | None:
|
||||
return target.read_text(errors="replace")
|
||||
|
||||
|
||||
def _resolve_writable(source_path: str, relative_path: str) -> tuple[Path | None, str | None]:
|
||||
def resolve_writable(source_path: str, relative_path: str) -> tuple[Path | None, str | None]:
|
||||
"""Resolve relative_path under source_path, guarding traversal + extension.
|
||||
|
||||
Returns (target_path, None) on success or (None, error). The root need not
|
||||
@@ -233,7 +233,7 @@ def _resolve_writable(source_path: str, relative_path: str) -> tuple[Path | None
|
||||
|
||||
def write_playbook(source_path: str, relative_path: str, content: str) -> tuple[bool, str | None]:
|
||||
"""Write a playbook into a source, creating parent dirs. Traversal-guarded."""
|
||||
target, err = _resolve_writable(source_path, relative_path)
|
||||
target, err = resolve_writable(source_path, relative_path)
|
||||
if err:
|
||||
return False, err
|
||||
try:
|
||||
@@ -246,7 +246,7 @@ def write_playbook(source_path: str, relative_path: str, content: str) -> tuple[
|
||||
|
||||
def delete_playbook(source_path: str, relative_path: str) -> tuple[bool, str | None]:
|
||||
"""Delete a playbook from a source. Traversal-guarded; no-op if absent."""
|
||||
target, err = _resolve_writable(source_path, relative_path)
|
||||
target, err = resolve_writable(source_path, relative_path)
|
||||
if err:
|
||||
return False, err
|
||||
try:
|
||||
|
||||
@@ -229,6 +229,88 @@ async def get_playbook(source: str, playbook: str) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def _editable(name: str) -> dict:
|
||||
s = _source(name)
|
||||
if not src_module.is_editable_source(s):
|
||||
why = ("it holds Steward's own playbooks, which are changed in Steward's repository"
|
||||
if s["name"] == src_module.BUILTIN_SOURCE_NAME else
|
||||
"it is read from a git repository, which would overwrite a change on its next pull"
|
||||
if s["type"] == "git" else "it is not editable")
|
||||
raise ValueError(f"source {s['name']!r} can't be written here: {why}. Write this "
|
||||
f"instance's own playbooks into {src_module.LOCAL_SOURCE_NAME!r}.")
|
||||
return s
|
||||
|
||||
|
||||
async def save_playbook(playbook: str, content: str, source: str = src_module.LOCAL_SOURCE_NAME,
|
||||
replace: bool = False) -> dict:
|
||||
"""Write a playbook of this instance's own: automation for these hosts that
|
||||
isn't part of Steward itself. The same as saving in Steward's editor.
|
||||
|
||||
playbook: a path in the source ending .yml or .yaml, e.g. "upkeep/prune-runner.yml".
|
||||
content: the whole playbook. It must parse as YAML and be a list of plays,
|
||||
or nothing is saved and the error (with its line) comes back.
|
||||
source: "steward-local" (default), or another editable folder source.
|
||||
steward-builtin is refused: Steward's own playbooks are changed in
|
||||
Steward's repository. Git sources are refused: their repo owns them.
|
||||
replace: an existing playbook is overwritten only with replace=true, so
|
||||
a typo in a name never clobbers another one.
|
||||
|
||||
After saving, `ansible-playbook --syntax-check` runs on it (no host is
|
||||
contacted); `syntax` says whether it passed and, if not, why, e.g. a
|
||||
module name Ansible can't resolve. The file is saved either way.
|
||||
|
||||
To test it: run_playbook(source, playbook, scope="target:<one host>") runs
|
||||
it in check mode, get_ansible_run shows what would change; fix, save with
|
||||
replace=true, run again; apply with check=false once it is right.
|
||||
|
||||
Admin only. Recorded in the audit log (its size, never its content).
|
||||
"""
|
||||
s = _editable(source)
|
||||
path = (playbook or "").strip().lstrip("/")
|
||||
target, err = src_module.resolve_writable(s["path"], path)
|
||||
if err:
|
||||
raise ValueError(f"{err}. Nothing was saved.")
|
||||
ok, err = src_module.validate_playbook_yaml(content or "")
|
||||
if not ok:
|
||||
raise ValueError(f"{err}\nNothing was saved.")
|
||||
existed = target.exists()
|
||||
if existed and not replace:
|
||||
raise ValueError(f"{s['name']} already has {path!r}; pass replace=true to overwrite it, "
|
||||
"or choose another name. Nothing was saved.")
|
||||
ok, err = src_module.write_playbook(s["path"], path, content)
|
||||
if not ok:
|
||||
raise ValueError(f"{err}. Nothing was saved.")
|
||||
await audit_mcp("ansible.playbook.updated" if existed else "ansible.playbook.created",
|
||||
"ansible_playbook", f"{s['name']}:{path}",
|
||||
{"source": s["name"], "playbook": path, "bytes": len(content)})
|
||||
passed, output = await executor.syntax_check(s["path"], path)
|
||||
syntax = {"ok": passed}
|
||||
if output:
|
||||
syntax["output" if passed is False else "note"] = output
|
||||
return {"source": s["name"], "playbook": path, "replaced": existed, "bytes": len(content),
|
||||
"syntax": syntax,
|
||||
"next": f'run_playbook(source="{s["name"]}", playbook="{path}", '
|
||||
'scope="target:<one host>") to see what it would change.'}
|
||||
|
||||
|
||||
async def delete_playbook(playbook: str, source: str = src_module.LOCAL_SOURCE_NAME) -> dict:
|
||||
"""Delete a playbook written into steward-local (or another editable folder
|
||||
source). Its past runs stay. A schedule or alert action that names it
|
||||
will fail until it is pointed elsewhere.
|
||||
|
||||
Admin only. Recorded in the audit log.
|
||||
"""
|
||||
s = _editable(source)
|
||||
path = (playbook or "").strip().lstrip("/")
|
||||
_playbook(s, path)
|
||||
ok, err = src_module.delete_playbook(s["path"], path)
|
||||
if not ok:
|
||||
raise ValueError(err)
|
||||
await audit_mcp("ansible.playbook.deleted", "ansible_playbook", f"{s['name']}:{path}",
|
||||
{"source": s["name"], "playbook": path})
|
||||
return {"deleted": path, "source": s["name"]}
|
||||
|
||||
|
||||
async def list_inventory() -> dict:
|
||||
"""Steward's Ansible inventory: targets, groups, and how to scope a run to them.
|
||||
|
||||
@@ -787,4 +869,5 @@ def register(register_tool) -> None:
|
||||
for fn in (run_playbook, cancel_ansible_run, create_schedule, update_schedule, delete_schedule,
|
||||
update_group_members):
|
||||
register_tool(fn, role=UserRole.operator, read_only=False)
|
||||
register_tool(set_alert_action, role=UserRole.admin, read_only=False)
|
||||
for fn in (set_alert_action, save_playbook, delete_playbook):
|
||||
register_tool(fn, role=UserRole.admin, read_only=False)
|
||||
|
||||
Reference in New Issue
Block a user