mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-13 14:16:46 +00:00
feat(roles): pull custom roles from inventory-declared sources
An inventory group web-app-cst-<name> with applications.<id>.custom: true and
a pinned source {url, version, path} is pulled into roles/ before the play,
read raw (Jinja, Ansible tags and credential URLs refused), placed through a
staging directory under an exclusive lock, and owned by a marker that names
its own directory, so a pull or roles-clean never replaces or removes a
directory it did not place and a failed pull leaves roles/ as it was.
Directory inventories, host lists and non-UTF-8 files behave as Ansible
reads them; generated test deploys pass --skip-pull. make roles-pull,
roles-clean and roles list, INFINITO_EXTRA_MOUNTS for local sources, the
web-app-cst category, the reserved prefix lint and a central
repository_files() that also yields the gitignored custom roles let every
lint scan them like in-tree roles. Supersedes the uncommitted 045 External
Roles draft and reuses its fetch, sync and mount design.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
cb4d340de9
commit
d92375922d
101 files changed
+3731
-248
No files matched your search
+53
-1
@@ -8,9 +8,11 @@ the module so the real repo tree is never touched.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from utils.cache import files as files_module
|
||||
@@ -20,7 +22,7 @@ from utils.cache.files import (
|
||||
iter_project_files_with_content,
|
||||
read_text,
|
||||
)
|
||||
from utils.roles.mapping import ROLE_FILE_META_SERVICES
|
||||
from utils.roles.mapping import ROLE_FILE_META_SERVICES, ROLE_FILE_TASKS_MAIN
|
||||
|
||||
|
||||
def _touch(path: Path, content: str = "") -> Path:
|
||||
@@ -177,6 +179,56 @@ class TestReset(_ProjectRootFixture, unittest.TestCase):
|
||||
self.assertEqual(read_text(str(self.root / "x.txt")), "v1-changed")
|
||||
|
||||
|
||||
class TestRepositoryFiles(_ProjectRootFixture, unittest.TestCase):
|
||||
TRACKED = f"roles/web-app-nextcloud/{ROLE_FILE_TASKS_MAIN}"
|
||||
CUSTOM = f"roles/web-app-cst-demo/{ROLE_FILE_TASKS_MAIN}"
|
||||
|
||||
def _git(self, *listings: bytes):
|
||||
return patch.object(
|
||||
files_module.subprocess,
|
||||
"run",
|
||||
side_effect=[SimpleNamespace(stdout=listing) for listing in listings],
|
||||
)
|
||||
|
||||
def test_lists_placed_custom_roles_next_to_the_tracked_files(self) -> None:
|
||||
_touch(self.root / self.TRACKED, "- y")
|
||||
_touch(self.root / self.CUSTOM, "- x")
|
||||
_touch(self.root / "roles/web-app-cst-demo/__pycache__/x.pyc", "")
|
||||
with self._git(f"{self.TRACKED}\0".encode()):
|
||||
self.assertEqual(
|
||||
(self.CUSTOM, self.TRACKED),
|
||||
files_module.repository_files(),
|
||||
)
|
||||
|
||||
def test_lists_only_the_tracked_files_without_placed_custom_roles(self) -> None:
|
||||
_touch(self.root / self.TRACKED, "- y")
|
||||
_touch(self.root / "build/install.stamp", "")
|
||||
with self._git(f"{self.TRACKED}\0".encode()):
|
||||
self.assertEqual((self.TRACKED,), files_module.repository_files())
|
||||
|
||||
def test_untracked_adds_the_files_gitignore_does_not_exclude(self) -> None:
|
||||
with self._git(f"{self.TRACKED}\0".encode(), b"new.py\0") as run:
|
||||
self.assertEqual(
|
||||
("new.py", self.TRACKED),
|
||||
files_module.repository_files(untracked=True),
|
||||
)
|
||||
self.assertEqual(
|
||||
["--others", "--exclude-standard"],
|
||||
run.call_args_list[1].args[0][-2:],
|
||||
)
|
||||
|
||||
def test_a_git_failure_propagates(self) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
files_module.subprocess,
|
||||
"run",
|
||||
side_effect=subprocess.CalledProcessError(128, "git"),
|
||||
),
|
||||
self.assertRaises(subprocess.CalledProcessError),
|
||||
):
|
||||
files_module.repository_files()
|
||||
|
||||
|
||||
class TestWalkIsKeyedOnTheRoot(_ProjectRootFixture, unittest.TestCase):
|
||||
def test_a_second_root_is_walked_rather_than_answered_from_the_first(
|
||||
self,
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from utils.cache import gitignore
|
||||
from utils.roles.custom import ROLES_GLOB
|
||||
from utils.roles.mapping import ROLE_FILE_TASKS_MAIN
|
||||
|
||||
|
||||
class TestLoadGitignorePatterns(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.addCleanup(gitignore._reset)
|
||||
self.root = Path(tmp.name)
|
||||
(self.root / ".gitignore").write_text(
|
||||
f"# comment\n*.log\n{ROLES_GLOB}\n!keep.log\nbuild/*\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
def test_reads_plain_patterns(self):
|
||||
patterns = gitignore.load_gitignore_patterns(str(self.root))
|
||||
self.assertEqual(("*.log", "build/*"), patterns)
|
||||
|
||||
def test_pulled_custom_roles_stay_in_the_scanned_tree(self):
|
||||
patterns = gitignore.load_gitignore_patterns(str(self.root))
|
||||
self.assertFalse(
|
||||
gitignore.is_path_gitignored(
|
||||
f"roles/web-app-cst-demo/{ROLE_FILE_TASKS_MAIN}", patterns
|
||||
)
|
||||
)
|
||||
|
||||
def test_ignored_files_inside_a_custom_role_stay_ignored(self):
|
||||
patterns = gitignore.load_gitignore_patterns(str(self.root))
|
||||
self.assertTrue(
|
||||
gitignore.is_path_gitignored("roles/web-app-cst-demo/debug.log", patterns)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+35
@@ -7,11 +7,13 @@ from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from utils.cache.yaml import (
|
||||
TaggedValue,
|
||||
_reset_cache_for_tests,
|
||||
dump_yaml,
|
||||
invalidate,
|
||||
load_yaml,
|
||||
load_yaml_any,
|
||||
load_yaml_tagged,
|
||||
)
|
||||
|
||||
|
||||
@@ -158,5 +160,38 @@ class TestInvalidate(unittest.TestCase):
|
||||
invalidate(self.tmp / "never-loaded.yml")
|
||||
|
||||
|
||||
class TestLoadYamlTagged(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
_reset_cache_for_tests()
|
||||
self._tmp = TemporaryDirectory()
|
||||
self.addCleanup(self._tmp.cleanup)
|
||||
self.path = Path(self._tmp.name) / "inventory.yml"
|
||||
self.path.write_text(
|
||||
"motd: !unsafe '{{ literal }}'\n"
|
||||
"secret: !vault |\n"
|
||||
" $ANSIBLE_VAULT;1.1;AES256\n"
|
||||
" 6162\n"
|
||||
"hosts: !custom [a, b]\n"
|
||||
"plain: value\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def test_every_local_tag_loads_as_its_raw_value(self):
|
||||
self.assertEqual(
|
||||
{
|
||||
"motd": TaggedValue("!unsafe", "{{ literal }}"),
|
||||
"secret": TaggedValue("!vault", "$ANSIBLE_VAULT;1.1;AES256\n6162\n"),
|
||||
"hosts": TaggedValue("!custom", ["a", "b"]),
|
||||
"plain": "value",
|
||||
},
|
||||
load_yaml_tagged(self.path),
|
||||
)
|
||||
|
||||
def test_load_yaml_any_still_reads_vault_as_its_ciphertext(self):
|
||||
self.path.write_text("secret: !vault abc\n", encoding="utf-8")
|
||||
load_yaml_tagged(self.path)
|
||||
self.assertEqual({"secret": "abc"}, load_yaml_any(self.path))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user