mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
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>
231 lines
8.8 KiB
Python
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()
|