feat(ansible): in-app playbook authoring/editor
Adds create/edit/delete of playbooks from the UI (admin only), so a homelab user without a git workflow can author automation in-app. A new always-present writable local source "steward-local" (/data/ansible/playbooks, env-overridable, created on first save) is editable alongside operator local-dir sources; the bundled and git sources stay read-only (git is GitOps, clobbered on pull). sources.py: write_playbook / delete_playbook (traversal-guarded, .yml/.yaml only) + validate_playbook_yaml (YAML + play-list check) + is_editable_source. routes.py: /playbooks/new, /edit, /save, /delete (admin). Browse gains a "New playbook" button and per-playbook + view-page Edit/Delete for editable sources. Plain textarea editor with save-time YAML validation. Unit tests for write/delete/guard/validate. Task #579 — completes milestone #37 (Ansible automation). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,7 @@ async def browse():
|
||||
"source": source,
|
||||
"playbooks": playbooks,
|
||||
"inventories": inventories,
|
||||
"editable": src_module.is_editable_source(source),
|
||||
})
|
||||
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
@@ -90,6 +91,7 @@ async def view_playbook(source_name: str, playbook_path: str):
|
||||
view_source=source_name,
|
||||
view_path=playbook_path,
|
||||
view_contents=contents,
|
||||
view_editable=src_module.is_editable_source(source),
|
||||
)
|
||||
|
||||
|
||||
@@ -354,3 +356,102 @@ async def run_schedule_now(schedule_id: str):
|
||||
if run:
|
||||
return redirect(url_for("ansible.run_detail", run_id=run.id))
|
||||
return redirect(url_for("ansible.schedules"))
|
||||
|
||||
|
||||
# ── In-app playbook authoring/editor (admin only) ─────────────────────────────
|
||||
|
||||
def _editable_sources() -> list[dict]:
|
||||
return [s for s in _get_sources() if src_module.is_editable_source(s)]
|
||||
|
||||
|
||||
def _editable_source(name: str) -> dict | None:
|
||||
return next((s for s in _editable_sources() if s["name"] == name), None)
|
||||
|
||||
|
||||
@ansible_bp.get("/playbooks/new")
|
||||
@require_role(UserRole.admin)
|
||||
async def new_playbook():
|
||||
sources = _editable_sources()
|
||||
return await render_template(
|
||||
"ansible/playbook_editor.html",
|
||||
editable_sources=sources, editing=False,
|
||||
source_name=(sources[0]["name"] if sources else ""),
|
||||
playbook_path="", content=_NEW_PLAYBOOK_TEMPLATE, error=None,
|
||||
)
|
||||
|
||||
|
||||
@ansible_bp.get("/playbooks/edit/<source_name>/<path:playbook_path>")
|
||||
@require_role(UserRole.admin)
|
||||
async def edit_playbook(source_name: str, playbook_path: str):
|
||||
source = _editable_source(source_name)
|
||||
if source is None:
|
||||
return "Source is not editable", 404
|
||||
content = src_module.read_playbook(source["path"], playbook_path)
|
||||
if content is None:
|
||||
return "Playbook not found", 404
|
||||
return await render_template(
|
||||
"ansible/playbook_editor.html",
|
||||
editable_sources=_editable_sources(), editing=True,
|
||||
source_name=source_name, playbook_path=playbook_path,
|
||||
content=content, error=None,
|
||||
)
|
||||
|
||||
|
||||
@ansible_bp.post("/playbooks/save")
|
||||
@require_role(UserRole.admin)
|
||||
async def save_playbook():
|
||||
form = await request.form
|
||||
source_name = (form.get("source_name", "") or "").strip()
|
||||
playbook_path = (form.get("playbook_path", "") or "").strip()
|
||||
content = form.get("content", "") or ""
|
||||
editing = (form.get("editing", "") == "1")
|
||||
|
||||
source = _editable_source(source_name)
|
||||
err = None
|
||||
if source is None:
|
||||
err = "Source is not editable"
|
||||
elif not playbook_path:
|
||||
err = "Filename is required"
|
||||
else:
|
||||
ok, verr = src_module.validate_playbook_yaml(content)
|
||||
if not ok:
|
||||
err = verr
|
||||
if err is None:
|
||||
ok, werr = src_module.write_playbook(source["path"], playbook_path, content)
|
||||
if not ok:
|
||||
err = werr
|
||||
|
||||
if err is not None:
|
||||
return await render_template(
|
||||
"ansible/playbook_editor.html",
|
||||
editable_sources=_editable_sources(), editing=editing,
|
||||
source_name=source_name, playbook_path=playbook_path,
|
||||
content=content, error=err,
|
||||
), 400
|
||||
|
||||
return redirect(url_for(
|
||||
"ansible.view_playbook", source_name=source_name, playbook_path=playbook_path))
|
||||
|
||||
|
||||
@ansible_bp.post("/playbooks/delete")
|
||||
@require_role(UserRole.admin)
|
||||
async def delete_playbook():
|
||||
form = await request.form
|
||||
source = _editable_source((form.get("source_name", "") or "").strip())
|
||||
if source is None:
|
||||
return "Source is not editable", 400
|
||||
ok, err = src_module.delete_playbook(source["path"], (form.get("playbook_path", "") or "").strip())
|
||||
if not ok:
|
||||
return err, 400
|
||||
return redirect(url_for("ansible.browse"))
|
||||
|
||||
|
||||
_NEW_PLAYBOOK_TEMPLATE = """\
|
||||
---
|
||||
- name: My playbook
|
||||
hosts: all
|
||||
gather_facts: false
|
||||
tasks:
|
||||
- name: Ping
|
||||
ansible.builtin.ping:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user