Files
computer-playbook/tests/unit/python/cli/administration/roles/test_declaration.py
T
kevinveenbirkenbachandClaude Opus 5.5 d92375922d 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>
2026-10-29 00:02:58 +01:00

231 lines
8.8 KiB
Python

from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from cli.administration.roles.declaration import Source, read_declarations
from ._fixtures import ROLE, ROLE_PATH, declaration, inventory, roles_dir, write
GIT_URL = "https://github.com/acme/widgets.git"
class TestReadDeclarations(unittest.TestCase):
def setUp(self) -> None:
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
self.roles = roles_dir(self.root, "web-app-nextcloud")
def read(self, applications: dict, groups: tuple[str, ...] = (ROLE,)):
inv = inventory(self.root / "inventory", list(groups), applications)
return read_declarations(inv, self.roles)
def assert_refused(self, applications: dict, *fragments: str, groups=(ROLE,)):
declared, errors = self.read(applications, groups)
message = "\n".join(errors)
for fragment in fragments:
self.assertIn(fragment, message)
return declared
def test_complete_declaration_is_read(self):
declared, errors = self.read({ROLE: declaration(GIT_URL)})
self.assertEqual([], errors)
self.assertEqual({ROLE: Source(GIT_URL, "v1.0.0", ROLE_PATH)}, declared)
def test_ssh_url_is_accepted(self):
_declared, errors = self.read(
{ROLE: declaration("git@github.com:acme/widgets.git")}
)
self.assertEqual([], errors)
def test_ssh_config_host_alias_is_accepted(self):
_declared, errors = self.read(
{ROLE: declaration("github-store:acme/widgets.git")}
)
self.assertEqual([], errors)
def test_plain_http_url_is_refused(self):
self.assert_refused(
{ROLE: declaration("http://github.com/acme/widgets.git")},
ROLE,
"source.url is neither",
)
def test_missing_url_names_group_and_field(self):
applications = {
ROLE: {"custom": True, "source": {"version": "v1", "path": ROLE_PATH}}
}
declared = self.assert_refused(applications, ROLE, "source.url is missing")
self.assertEqual({}, declared)
def test_missing_version_of_a_git_url_names_the_field(self):
self.assert_refused(
{ROLE: declaration(GIT_URL, version=None)},
ROLE,
"source.version is missing",
)
def test_missing_path_names_the_field(self):
applications = {
ROLE: {"custom": True, "source": {"url": GIT_URL, "version": "v1"}}
}
self.assert_refused(applications, ROLE, "source.path is missing")
def test_missing_source_names_the_field(self):
self.assert_refused(
{ROLE: {"custom": True}}, ROLE, f"applications.{ROLE}.source is missing"
)
def test_local_directory_needs_no_version(self):
declared, errors = self.read({ROLE: declaration(str(self.root), version=None)})
self.assertEqual([], errors)
self.assertTrue(declared[ROLE].local)
self.assertIsNone(declared[ROLE].version)
def test_url_that_is_neither_git_nor_absolute_is_refused(self):
self.assert_refused(
{ROLE: declaration("checkouts/widgets")}, ROLE, "source.url is neither"
)
def test_url_with_credentials_is_refused_without_echoing_it(self):
for url in (
"https://deploy:s3cr3t@github.com/acme/widgets.git",
"https://ghp_s3cr3t@github.com/acme/widgets.git",
"ssh://git:s3cr3t@github.com/acme/widgets.git",
):
with self.subTest(url=url.split("@", 1)[1]):
declared, errors = self.read({ROLE: declaration(url)})
message = "\n".join(errors)
self.assertIn(
f"{ROLE}: applications.{ROLE}.source.url embeds credentials",
message,
)
self.assertNotIn("s3cr3t", message)
self.assertEqual({}, declared)
def test_ssh_user_without_password_is_no_credential(self):
_declared, errors = self.read(
{ROLE: declaration("ssh://git@github.com/acme/widgets.git")}
)
self.assertEqual([], errors)
def test_jinja_in_a_source_field_is_refused(self):
applications = {ROLE: declaration("{{ lookup('env', 'ROLE_URL') }}")}
self.assert_refused(applications, ROLE, "source.url holds a Jinja expression")
def test_jinja_in_custom_is_refused(self):
applications = {ROLE: {**declaration(GIT_URL), "custom": "{{ true }}"}}
declared = self.assert_refused(
applications, ROLE, f"{ROLE}.custom holds a Jinja expression"
)
self.assertEqual({}, declared)
def test_unknown_source_field_is_refused(self):
applications = {ROLE: declaration(GIT_URL)}
applications[ROLE]["source"]["tag"] = "v1"
self.assert_refused(applications, ROLE, "source.tag is not a source field")
def test_path_leaving_the_repository_is_refused(self):
applications = {ROLE: declaration(GIT_URL, path=f"../{ROLE}")}
self.assert_refused(applications, ROLE, "relative path inside the repository")
def test_directory_name_other_than_the_group_names_both(self):
applications = {ROLE: declaration(GIT_URL, path="ansible/roles/web-app-demo")}
self.assert_refused(
applications, "'web-app-demo'", f"'{ROLE}'", "never renames"
)
def test_group_without_custom_true_is_refused(self):
self.assert_refused(
{}, f"{ROLE}: inventory group without applications.{ROLE}.custom: true"
)
def test_custom_false_counts_as_undeclared(self):
applications = {ROLE: {**declaration(GIT_URL), "custom": False}}
declared = self.assert_refused(applications, f"{ROLE}: inventory group without")
self.assertEqual({}, declared)
def test_custom_true_outside_the_prefix_is_refused(self):
applications = {"web-app-demo": declaration(GIT_URL)}
self.assert_refused(
applications, "web-app-demo: custom: true marks a group outside", groups=()
)
def test_entity_name_of_an_in_tree_role_is_refused(self):
applications = {
"web-app-cst-nextcloud": declaration(
GIT_URL, path="roles/web-app-cst-nextcloud"
)
}
self.assert_refused(
applications,
"web-app-cst-nextcloud: entity name 'nextcloud'",
"in-tree role web-app-nextcloud",
groups=("web-app-cst-nextcloud",),
)
def test_declarations_that_differ_between_files_are_refused(self):
write(
self.root / "inventory/group_vars/all.yml",
json.dumps(
{"applications": {ROLE: declaration(GIT_URL, version="v2.0.0")}}
),
)
self.assert_refused(
{ROLE: declaration(GIT_URL)}, f"applications.{ROLE}.source differs between"
)
def read_yaml(self, host_vars: str):
base = self.root / "inventory"
write(base / "host_vars/localhost.yml", host_vars)
devices = {"all": {"children": {ROLE: {"hosts": {"localhost": {}}}}}}
return read_declarations(
write(base / "devices.yml", json.dumps(devices)), self.roles
)
def test_tagged_custom_is_refused_naming_the_tag(self):
declared, errors = self.read_yaml(
f"applications:\n {ROLE}:\n custom: !vault |\n"
" $ANSIBLE_VAULT;1.1;AES256\n 6162\n"
)
self.assertEqual({}, declared)
self.assertIn(
f"{ROLE}: applications.{ROLE}.custom holds a !vault value",
"\n".join(errors),
)
def test_tagged_source_field_is_refused_naming_the_tag(self):
declared, errors = self.read_yaml(
f"applications:\n {ROLE}:\n custom: true\n source:\n"
f" url: {GIT_URL}\n version: !unsafe v1.0.0\n path: {ROLE_PATH}\n"
)
self.assertEqual({}, declared)
self.assertIn(
f"{ROLE}: applications.{ROLE}.source.version holds a !unsafe value",
"\n".join(errors),
)
def test_unparsable_file_declares_nothing_and_is_the_only_error(self):
broken = write(self.root / "inventory/group_vars/all.yml", "applications: [\n")
declared, errors = self.read({ROLE: declaration(GIT_URL)})
self.assertEqual({}, declared)
self.assertEqual(1, len(errors))
self.assertTrue(errors[0].startswith(f"{broken}: cannot read"), errors[0])
def test_settings_without_custom_in_another_file_do_not_conflict(self):
write(
self.root / "inventory/group_vars/all.yml",
json.dumps(
{"applications": {ROLE: {"services": {"web": {"enabled": True}}}}}
),
)
_declared, errors = self.read({ROLE: declaration(GIT_URL)})
self.assertEqual([], errors)
if __name__ == "__main__":
unittest.main()