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:
@@ -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
|
||||
Reference in New Issue
Block a user