feat(ansible): in-app playbook authoring/editor
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m17s
CI / publish (push) Successful in 1m6s

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:
2026-06-16 11:39:47 -04:00
parent f3e919892d
commit c10eae1c74
5 changed files with 323 additions and 4 deletions
+53
View File
@@ -0,0 +1,53 @@
"""Unit tests for in-app playbook authoring helpers (write/delete/validate/guard)."""
from steward.ansible.sources import (
BUILTIN_SOURCE_NAME,
LOCAL_SOURCE_NAME,
delete_playbook,
get_sources,
is_editable_source,
read_playbook,
validate_playbook_yaml,
write_playbook,
)
def test_write_then_read_roundtrip(tmp_path):
ok, err = write_playbook(str(tmp_path), "maint/x.yml", "- name: p\n hosts: all\n")
assert ok and err is None
assert read_playbook(str(tmp_path), "maint/x.yml").startswith("- name: p")
def test_write_rejects_non_yaml_extension(tmp_path):
ok, err = write_playbook(str(tmp_path), "evil.sh", "rm -rf /")
assert not ok and "yml" in err
def test_write_blocks_path_traversal(tmp_path):
ok, err = write_playbook(str(tmp_path), "../escape.yml", "- {}")
assert not ok and err == "Invalid path"
def test_delete_guarded_and_idempotent(tmp_path):
write_playbook(str(tmp_path), "a.yml", "- {}")
assert delete_playbook(str(tmp_path), "a.yml")[0] is True
assert delete_playbook(str(tmp_path), "a.yml")[0] is True # already gone, no error
ok, err = delete_playbook(str(tmp_path), "../x.yml")
assert not ok and err == "Invalid path"
def test_validate_playbook_yaml():
assert validate_playbook_yaml("- name: p\n hosts: all\n")[0] is True
assert validate_playbook_yaml("foo: [unclosed")[0] is False # malformed YAML
assert validate_playbook_yaml("")[0] is False # empty
assert validate_playbook_yaml("key: value")[0] is False # not a play list
def test_is_editable_source():
assert is_editable_source({"type": "local", "name": LOCAL_SOURCE_NAME}) is True
assert is_editable_source({"type": "local", "name": BUILTIN_SOURCE_NAME}) is False
assert is_editable_source({"type": "git", "name": "repo"}) is False
def test_get_sources_includes_writable_local():
names = [s["name"] for s in get_sources({"sources": []})]
assert LOCAL_SOURCE_NAME in names