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:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-29 00:02:58 +01:00
1 parent cb4d340de9
commit d92375922d
101 files changed
+3731 -248

No files matched your search

@@ -1,9 +1,43 @@
# cli/administration/deploy/dedicated/tests/test_command_ids.py
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest import mock
from cli.administration.deploy.dedicated.command import _normalize_app_ids
from cli.administration.deploy.dedicated import command
from cli.administration.deploy.dedicated.command import (
_normalize_app_ids,
pull_custom_roles,
)
class TestPullCustomRoles(unittest.TestCase):
@mock.patch("cli.administration.deploy.dedicated.command.pull_or_exit")
def test_pulls_the_inventory_by_default(self, pull_or_exit):
pull_custom_roles(SimpleNamespace(inventory="inv/devices.yml", skip_pull=False))
pull_or_exit.assert_called_once_with("inv/devices.yml")
@mock.patch("cli.administration.deploy.dedicated.command.pull_or_exit")
def test_skip_pull_keeps_the_placed_roles(self, pull_or_exit):
pull_custom_roles(SimpleNamespace(inventory="inv/devices.yml", skip_pull=True))
pull_or_exit.assert_not_called()
def test_main_pulls_before_anything_reads_roles(self):
calls: list[str] = []
args = SimpleNamespace(inventory="inv/devices.yml", skip_pull=False)
with (
mock.patch.object(command, "parse_args", return_value=(args, [], {})),
mock.patch.object(
command, "pull_or_exit", side_effect=lambda _inv: calls.append("pull")
),
mock.patch.object(
command,
"run_from_args",
side_effect=lambda *_a: calls.append("deploy") or 0,
),
):
self.assertEqual(0, command.main([]))
self.assertEqual(["pull", "deploy"], calls)
class TestNormalizeAppIds(unittest.TestCase):
@@ -7,6 +7,7 @@ from cli.administration.deploy.dedicated import command as dedicated_command
class TestDedicatedCommandPassthrough(unittest.TestCase):
@unittest.mock.patch("cli.administration.deploy.dedicated.command.pull_or_exit")
@unittest.mock.patch(
"cli.administration.deploy.dedicated.command.validate_application_ids"
)
@@ -29,6 +30,7 @@ class TestDedicatedCommandPassthrough(unittest.TestCase):
mock_load_modes_from_yaml,
mock_run_ansible_playbook,
mock_validate_application_ids,
_mock_pull_or_exit,
):
mock_load_modes_from_yaml.return_value = []
mock_add_dynamic_mode_args.return_value = {}
@@ -15,6 +15,7 @@ class TestDedicatedMainParser(unittest.TestCase):
self.assertIn("limit", opts)
self.assertIn("password_file", opts)
self.assertIn("skip_build", opts)
self.assertIn("skip_pull", opts)
self.assertIn("id", opts)
self.assertIn("verbose", opts)
self.assertIn("diff", opts)
@@ -0,0 +1,34 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest import mock
from cli.administration.deploy.development.deploy.run import _run_deploy
class TestRunDeploy(unittest.TestCase):
def deploy_cmd(self) -> list[str]:
compose = mock.Mock()
compose.exec.return_value = SimpleNamespace(returncode=0)
_run_deploy(
compose,
deploy_ids=["web-app-cst-demo"],
debug=False,
passthrough=[],
inventory_dir="/etc/inventories/dev",
container_name="infinito",
)
return compose.exec.call_args.args[0]
def test_deploys_the_generated_inventory(self):
cmd = self.deploy_cmd()
self.assertEqual("/etc/inventories/dev/devices.yml", cmd[3])
self.assertEqual(["--id", "web-app-cst-demo"], cmd[-2:])
def test_generated_inventory_keeps_the_placed_custom_roles(self):
self.assertIn("--skip-pull", self.deploy_cmd())
if __name__ == "__main__":
unittest.main()
@@ -8,7 +8,9 @@ because an unsatisfied `:?` guard inside one of them aborts the whole stack.
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from cli.administration.deploy.development.env import compose_file_args
@@ -23,6 +25,7 @@ _LOCAL_PRIMARY = {
"INFINITO_CACHE_STACK": "",
"INFINITO_GHCR_MIRROR": "",
"INFINITO_PUBLISH_PORTS": "",
"INFINITO_EXTRA_MOUNTS": "",
}
_WORKTREE = {
@@ -99,6 +102,35 @@ class TestComposeFileArgs(unittest.TestCase):
compose_file_args()[-2:], ["-f", "compose/noports.override.yml"]
)
def test_extra_mounts_append_their_generated_override(self) -> None:
with (
tempfile.TemporaryDirectory() as mount,
patch.dict(
os.environ,
{**_LOCAL_PRIMARY, "INFINITO_EXTRA_MOUNTS": mount},
clear=False,
),
patch(
"cli.administration.deploy.development.extra_mounts.write_mounts_override",
return_value="build/compose/extra-mounts.override.yml",
) as write,
):
args = compose_file_args()
self.assertEqual(write.call_args.args[1], [Path(mount)])
self.assertEqual(args[-2:], ["-f", "build/compose/extra-mounts.override.yml"])
def test_an_entry_that_is_no_directory_stops_the_stack_naming_it(self) -> None:
with (
patch.dict(
os.environ,
{**_LOCAL_PRIMARY, "INFINITO_EXTRA_MOUNTS": "/nonexistent/gui"},
clear=False,
),
self.assertRaises(SystemExit) as ctx,
):
compose_file_args()
self.assertIn("/nonexistent/gui", str(ctx.exception.code))
if __name__ == "__main__": # pragma: no cover
unittest.main()
@@ -0,0 +1,65 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from cli.administration.deploy.development.extra_mounts import (
OVERRIDE,
parse_mounts,
write_mounts_override,
)
from utils.cache.yaml import load_yaml
class TestParseMounts(unittest.TestCase):
def test_existing_absolute_directories_are_returned(self) -> None:
with (
tempfile.TemporaryDirectory() as first,
tempfile.TemporaryDirectory() as second,
):
self.assertEqual(
parse_mounts(f"{first}, {second},"), [Path(first), Path(second)]
)
def test_empty_value_mounts_nothing(self) -> None:
self.assertEqual(parse_mounts(""), [])
def test_relative_entry_is_rejected_naming_it(self) -> None:
with self.assertRaises(SystemExit) as ctx:
parse_mounts("relative/dir")
self.assertIn("relative/dir", str(ctx.exception.code))
def test_missing_directory_is_rejected_naming_it(self) -> None:
with tempfile.TemporaryDirectory() as tmp, self.assertRaises(SystemExit) as ctx:
parse_mounts(str(Path(tmp) / "absent"))
self.assertIn("absent", str(ctx.exception.code))
class TestWriteMountsOverride(unittest.TestCase):
def test_each_directory_is_bound_read_only_at_its_own_path(self) -> None:
with tempfile.TemporaryDirectory() as repo:
relative = write_mounts_override(Path(repo), [Path("/srv/gui")])
override = load_yaml(Path(repo) / relative)
self.assertEqual(relative, OVERRIDE)
self.assertEqual(
override,
{
"services": {
"infinito": {
"volumes": [
{
"type": "bind",
"source": "/srv/gui",
"target": "/srv/gui",
"read_only": True,
}
]
}
}
},
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,36 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest import mock
from cli.administration.deploy.swarm import command
class TestSwarmMain(unittest.TestCase):
def test_custom_roles_are_pulled_before_the_closure_reads_roles(self):
calls: list[str] = []
args = SimpleNamespace(id=["web-app-cst-demo"], inventory="inv/devices.yml")
def closure(ids, _inventory):
calls.append("closure")
return ids
with (
mock.patch.object(command, "parse_args", return_value=(args, [], {})),
mock.patch.object(
command,
"pull_custom_roles",
side_effect=lambda _args: calls.append("pull"),
),
mock.patch.object(command, "swarm_deploy_targets", side_effect=closure),
mock.patch.object(command, "run_from_args", return_value=0) as run,
):
self.assertEqual(0, command.main([]))
self.assertEqual(["pull", "closure"], calls)
run.assert_called_once_with(args, [], {})
if __name__ == "__main__":
unittest.main()
@@ -82,6 +82,35 @@ class TestEnsureImage(unittest.TestCase):
)
class TestRunInContainer(unittest.TestCase):
@unittest.mock.patch("subprocess.run")
@unittest.mock.patch(
"cli.administration.deploy.container.command.docker_exec",
return_value=subprocess.CompletedProcess(args=[], returncode=0),
)
@unittest.mock.patch(
"cli.administration.deploy.container.command.start_ci_container",
return_value="infinito-ci-test",
)
def test_generated_inventory_keeps_the_placed_custom_roles(
self, _start, mock_docker_exec, _run
):
deploy_container.run_in_container(
image="infinito:latest",
build=False,
rebuild=False,
no_cache=False,
inventory_dir="/tmp/inventory",
inventory_args=[],
deploy_args=["--debug"],
)
deploy_cmd = mock_docker_exec.call_args_list[-1].args[1]
self.assertIn("cli.administration.deploy.dedicated", deploy_cmd)
self.assertIn("--skip-pull", deploy_cmd)
self.assertEqual("--debug", deploy_cmd[-1])
class TestMain(unittest.TestCase):
@unittest.mock.patch("cli.administration.deploy.container.command.run_in_container")
def test_main_requires_forwarded_args(self, mock_run_in_container):
@@ -94,6 +94,24 @@ class TestCompareApplicationKeys(unittest.TestCase):
self.assertEqual(len(result), 1)
self.assertIn("services.dashbaord", result[0])
def test_custom_role_declaration_keys_are_legal(self):
apps = {
"web-app-cst-demo": {
"custom": True,
"source": {"url": "https://x/y.git", "version": "v1", "path": "r"},
"services": {"port": 8080},
}
}
defaults = {"web-app-cst-demo": {"services": {"port": 8080}}}
self.assertEqual(compare_application_keys(apps, defaults, "src"), [])
def test_declaration_keys_on_an_in_tree_role_are_flagged(self):
apps = {"app1": {"custom": True, "source": {"url": "https://x/y.git"}}}
defaults = {"app1": {"services": {}}}
result = compare_application_keys(apps, defaults, "src")
self.assertEqual(3, len(result))
self.assertTrue(any(err.endswith("app1: custom") for err in result))
if __name__ == "__main__": # pragma: no cover
unittest.main()
Whitespace-only changes.
@@ -0,0 +1,149 @@
"""Throwaway role repositories, inventories and core roles/ trees for the pull tests."""
from __future__ import annotations
import json
import os
import subprocess
from typing import TYPE_CHECKING
from utils.roles.mapping import (
ROLE_FILE_META_MAIN,
ROLE_FILE_TASKS_MAIN,
ROLE_FILE_VARS_MAIN,
)
if TYPE_CHECKING:
from pathlib import Path
ROLE = "web-app-cst-demo"
ROLE_PATH = f"ansible/roles/{ROLE}"
_ENV = {
**{key: value for key, value in os.environ.items() if not key.startswith("GIT_")},
"GIT_CONFIG_GLOBAL": os.devnull,
}
def git(cwd: Path, *args: str) -> str:
"""Run git in *cwd* with a throwaway identity and no signing.
Args:
cwd: the repository.
args: the git subcommand and its arguments.
"""
return subprocess.run(
[
"git",
"-c",
"user.name=test",
"-c",
"user.email=test@example.invalid",
"-c",
"commit.gpgsign=false",
"-c",
"tag.gpgsign=false",
*args,
],
cwd=cwd,
env=_ENV,
capture_output=True,
text=True,
check=True,
).stdout
def write(path: Path, text: str) -> Path:
"""Write *text* to *path*, creating its parents.
Args:
path: the file.
text: its content.
"""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path
def role_repo(root: Path, application_id: str = ROLE) -> Path:
"""A repository whose tag ``v1.0.0`` holds the role and whose branch ``feature`` moved on.
Args:
root: directory the repository is created in.
application_id: the ``vars/main.yml`` application_id of the role.
"""
repo = root / "repo"
write(repo / "README.md", "repository root\n")
write(repo / ROLE_PATH / ROLE_FILE_META_MAIN, "galaxy_info: {}\n")
write(repo / ROLE_PATH / ROLE_FILE_VARS_MAIN, f"application_id: {application_id}\n")
write(repo / ROLE_PATH / ROLE_FILE_TASKS_MAIN, "release: v1\n")
git(repo, "init", "-q", "-b", "main")
git(repo, "add", "-A")
git(repo, "commit", "-q", "-m", "v1")
git(repo, "tag", "v1.0.0")
git(repo, "checkout", "-q", "-b", "feature")
write(repo / ROLE_PATH / ROLE_FILE_TASKS_MAIN, "release: feature\n")
git(repo, "commit", "-q", "-am", "feature")
return repo
def local_role(root: Path, group: str) -> Path:
"""A checkout without Git history that holds the role *group* under ansible/roles/.
Args:
root: directory the checkout is created in.
group: the role's group and application_id.
"""
checkout = root / f"{group}-checkout"
write(checkout / "ansible/roles" / group / ROLE_FILE_META_MAIN, "galaxy_info: {}\n")
write(
checkout / "ansible/roles" / group / ROLE_FILE_VARS_MAIN,
f"application_id: {group}\n",
)
return checkout
def inventory(root: Path, groups: list[str], applications: dict) -> Path:
"""An inventory whose host joins *groups* and whose host_vars set *applications*.
Args:
root: directory the inventory is created in.
groups: inventory groups ``localhost`` joins.
applications: the host_vars ``applications`` block.
"""
write(
root / "host_vars/localhost.yml",
json.dumps({"applications": applications}),
)
children = {group: {"hosts": {"localhost": {}}} for group in groups}
return write(root / "devices.yml", json.dumps({"all": {"children": children}}))
def declaration(
url: str, version: str | None = "v1.0.0", path: str = ROLE_PATH
) -> dict:
"""The ``applications.<group>`` block that declares a custom role.
Args:
url: ``source.url``.
version: ``source.version``; ``None`` leaves it out.
path: ``source.path``.
"""
source = {"url": url, "path": path}
if version is not None:
source["version"] = version
return {"custom": True, "source": source}
def roles_dir(root: Path, *in_tree: str) -> Path:
"""A core roles/ holding the given in-tree role directories.
Args:
root: directory the roles/ is created in.
in_tree: names of in-tree roles to create.
"""
roles = root / "roles"
roles.mkdir(parents=True)
for name in in_tree:
(roles / name).mkdir()
return roles
Whitespace-only changes.
@@ -0,0 +1,39 @@
from __future__ import annotations
import contextlib
import io
import unittest
from unittest import mock
import cli.administration.roles.clean.__main__ as clean_main
class TestCleanMain(unittest.TestCase):
def test_reports_the_roles_it_removed_and_the_entries_it_left(self):
lines = [
"web-app-cst-a: removed",
"web-app-cst-hand: left in place, the pull did not place it",
]
with (
mock.patch.object(clean_main, "clean", return_value=lines) as clean,
contextlib.redirect_stdout(io.StringIO()) as out,
):
self.assertEqual(0, clean_main.main([]))
clean.assert_called_once_with(clean_main.ROLES_DIR, clean_main.WORK_DIR)
self.assertEqual(
"custom role web-app-cst-a: removed\n"
"custom role web-app-cst-hand: left in place, the pull did not place it\n",
out.getvalue(),
)
def test_a_filesystem_error_exits_with_it(self):
with (
mock.patch.object(clean_main, "clean", side_effect=OSError("busy")),
self.assertRaises(SystemExit) as exit_,
):
clean_main.main([])
self.assertIn("busy", str(exit_.exception.code))
if __name__ == "__main__":
unittest.main()
Whitespace-only changes.
@@ -0,0 +1,36 @@
from __future__ import annotations
import contextlib
import io
import tempfile
import unittest
from pathlib import Path
from unittest import mock
import cli.administration.roles.list.__main__ as list_main
from cli.administration.roles.marker import MARKER_FILE, marker_text
ORIGIN = "https://example.invalid/w.git ansible/roles/web-app-cst-a @ v1 (commit abc)"
class TestListMain(unittest.TestCase):
def test_prints_each_placed_role_with_source_and_commit(self):
with tempfile.TemporaryDirectory() as tmp:
roles = Path(tmp)
marker = roles / "web-app-cst-a" / MARKER_FILE
marker.parent.mkdir()
marker.write_text(
marker_text("web-app-cst-a", "web-app-cst-a", ORIGIN), encoding="utf-8"
)
(roles / "web-app-cst-hand").mkdir()
(roles / "web-app-nextcloud").mkdir()
with (
mock.patch.object(list_main, "ROLES_DIR", roles),
contextlib.redirect_stdout(io.StringIO()) as out,
):
self.assertEqual(0, list_main.main([]))
self.assertEqual(f"web-app-cst-a: {ORIGIN}\n", out.getvalue())
if __name__ == "__main__":
unittest.main()
Whitespace-only changes.
@@ -0,0 +1,85 @@
from __future__ import annotations
import contextlib
import io
import tarfile
import unittest
from pathlib import Path
from unittest import mock
from yaml.constructor import ConstructorError
from cli.administration.roles.paths import WORK_DIR
from cli.administration.roles.pull import command
from utils.cache import ROLES_DIR
from utils.roles.custom import CustomRoleError
class TestPullCommand(unittest.TestCase):
@mock.patch.object(
command, "pull", return_value=["web-app-cst-demo: repo path @ v1 (commit abc)"]
)
def test_pulls_the_inventory_into_the_core_roles(self, pull):
with contextlib.redirect_stdout(io.StringIO()) as out:
self.assertEqual(0, command.main(["inv/devices.yml"]))
pull.assert_called_once_with(Path("inv/devices.yml"), ROLES_DIR, WORK_DIR)
self.assertIn("web-app-cst-demo: repo path @ v1 (commit abc)", out.getvalue())
@mock.patch.object(command, "pull", return_value=[])
def test_nothing_declared_or_placed_prints_nothing(self, _pull):
with contextlib.redirect_stdout(io.StringIO()) as out:
command.main(["inv/devices.yml"])
self.assertEqual("", out.getvalue())
def test_unpacking_parsing_and_filesystem_errors_exit_1_naming_them(self):
for error in (
tarfile.AbsoluteLinkError(tarfile.TarInfo(".infinito-custom-role")),
ConstructorError(None, None, "found an undefined tag in inv/hosts.yml"),
PermissionError(13, "Permission denied", "roles/web-app-cst-demo"),
):
with self.subTest(error=type(error).__name__):
with (
mock.patch.object(command, "pull", side_effect=error),
contextlib.redirect_stderr(io.StringIO()) as err,
self.assertRaises(SystemExit) as exit_,
):
command.main(["inv/devices.yml"])
self.assertEqual(1, exit_.exception.code)
self.assertIn(str(error), err.getvalue())
@mock.patch.object(
command,
"pull",
side_effect=CustomRoleError(
"web-app-cst-demo: git fetch of v1 failed: fatal: unable to access "
"'https://deploy:s3cr3t@example.invalid/w.git/'"
),
)
def test_credentials_in_a_url_are_never_echoed(self, _pull):
with (
contextlib.redirect_stderr(io.StringIO()) as err,
self.assertRaises(SystemExit),
):
command.main(["inv/devices.yml"])
self.assertNotIn("s3cr3t", err.getvalue())
self.assertIn("web-app-cst-demo: git fetch of v1 failed", err.getvalue())
@mock.patch.object(
command, "pull", side_effect=CustomRoleError("web-app-cst-demo: refused")
)
def test_refused_pull_exits_1_with_the_reason(self, _pull):
with (
contextlib.redirect_stderr(io.StringIO()) as err,
self.assertRaises(SystemExit) as exit_,
):
command.main(["inv/devices.yml"])
self.assertEqual(1, exit_.exception.code)
self.assertIn("web-app-cst-demo: refused", err.getvalue())
def test_inventory_argument_is_required(self):
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
command.main([])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,230 @@
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()
@@ -0,0 +1,114 @@
from __future__ import annotations
import os
import shutil
import tempfile
import unittest
from pathlib import Path
from cli.administration.roles.marker import (
MARKER_FILE,
custom_entries,
is_placed,
marker_text,
placed_roles,
provenance,
write_marker,
)
from ._fixtures import write
ORIGIN = "https://example.invalid/w.git ansible/roles/web-app-cst-a @ v1 (commit abc)"
PLACED = "web-app-cst-a"
class TestMarker(unittest.TestCase):
def setUp(self) -> None:
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.roles = Path(tmp.name) / "roles"
write(self.roles / PLACED / MARKER_FILE, marker_text(PLACED, PLACED, ORIGIN))
write(self.roles / "web-app-cst-hand" / "mine.yml", "mine: true\n")
write(self.roles / "web-app-cst-forged" / MARKER_FILE, "something else\n")
shutil.copytree(self.roles / PLACED, self.roles / "web-app-cst-copy")
(self.roles / "web-app-cst-linked" / "x").mkdir(parents=True)
(self.roles / "web-app-cst-linked" / MARKER_FILE).symlink_to(
self.roles / PLACED / MARKER_FILE
)
write(
self.roles / "web-app-nextcloud" / MARKER_FILE,
marker_text("web-app-nextcloud", "web-app-nextcloud", ORIGIN),
)
(self.roles / "web-app-cst-link").symlink_to(self.roles / PLACED)
def test_only_a_directory_whose_marker_names_it_is_placed(self):
self.assertTrue(is_placed(self.roles / PLACED))
self.assertFalse(is_placed(self.roles / "web-app-cst-hand"))
self.assertFalse(is_placed(self.roles / "web-app-cst-forged"))
def test_a_copy_under_another_name_is_not_placed(self):
self.assertFalse(is_placed(self.roles / "web-app-cst-copy"))
def test_a_marker_that_is_a_symbolic_link_is_not_read(self):
self.assertFalse(is_placed(self.roles / "web-app-cst-linked"))
def test_a_marker_that_is_a_fifo_is_not_placed(self):
fifo = self.roles / "web-app-cst-fifo"
fifo.mkdir()
os.mkfifo(fifo / MARKER_FILE)
self.assertFalse(is_placed(fifo))
def test_a_symbolic_link_to_a_placed_role_is_not_placed(self):
self.assertFalse(is_placed(self.roles / "web-app-cst-link"))
def test_provenance_reads_back_what_the_pull_recorded(self):
self.assertEqual(ORIGIN, provenance(self.roles / PLACED))
def test_custom_entries_are_every_prefixed_entry(self):
self.assertEqual(
[
PLACED,
"web-app-cst-copy",
"web-app-cst-forged",
"web-app-cst-hand",
"web-app-cst-link",
"web-app-cst-linked",
],
[entry.name for entry in custom_entries(self.roles)],
)
def test_placed_roles_are_the_custom_entries_whose_marker_names_them(self):
self.assertEqual([PLACED], [entry.name for entry in placed_roles(self.roles)])
class TestWriteMarker(unittest.TestCase):
def setUp(self) -> None:
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
self.role = self.root / PLACED
self.role.mkdir()
def test_marker_names_the_directory_and_the_group(self):
write_marker(self.role, PLACED, ORIGIN)
self.assertTrue(is_placed(self.role))
self.assertEqual(ORIGIN, provenance(self.role))
def test_a_symbolic_link_under_the_marker_name_is_replaced_not_followed(self):
victim = write(self.root / "victim.yml", "victim: true\n")
(self.role / MARKER_FILE).symlink_to(victim)
write_marker(self.role, PLACED, ORIGIN)
text = victim.read_text(encoding="utf-8") # nocheck: cache-read tempdir
self.assertEqual("victim: true\n", text)
self.assertFalse((self.role / MARKER_FILE).is_symlink())
self.assertTrue(is_placed(self.role))
def test_a_directory_under_the_marker_name_is_replaced(self):
write(self.role / MARKER_FILE / "nested.yml", "nested: true\n")
write_marker(self.role, PLACED, ORIGIN)
self.assertTrue((self.role / MARKER_FILE).is_file())
self.assertTrue(is_placed(self.role))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,489 @@
from __future__ import annotations
import re
import shutil
import tempfile
import threading
import unittest
from pathlib import Path
from unittest import mock
from cli.administration.roles import placement
from cli.administration.roles.marker import MARKER_FILE, is_placed
from cli.administration.roles.placement import clean, pull
from utils.cache.files import read_text
from utils.roles.custom import CustomRoleError
from utils.roles.mapping import (
ROLE_FILE_META_MAIN,
ROLE_FILE_TASKS_MAIN,
ROLE_FILE_VARS_MAIN,
)
from ._fixtures import (
ROLE,
ROLE_PATH,
declaration,
git,
inventory,
local_role,
role_repo,
roles_dir,
write,
)
OTHER = "web-app-cst-other"
COPY = "web-app-cst-mywork"
LEFT = "left in place, the pull did not place it"
class _PullFixture:
"""Mixin with a core roles/, a role repository and a pull helper.
Combined with ``unittest.TestCase`` on each concrete class so the
test-discovery lint sees the ``TestCase`` base directly.
"""
def setUp(self) -> None:
super().setUp()
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
self.roles = roles_dir(self.root, "web-app-nextcloud")
self.work = self.root / "build" / "custom-roles"
self.repo = role_repo(self.root)
self.inventories = 0
def next_inventory(self) -> Path:
self.inventories += 1
return self.root / f"inventory-{self.inventories}"
def pull(self, applications: dict, groups: tuple[str, ...] = (ROLE,)) -> list[str]:
inv = inventory(self.next_inventory(), list(groups), applications)
return pull(inv, self.roles, self.work)
def pull_yaml(self, devices: str, host_vars: str) -> list[str]:
base = self.next_inventory()
write(base / "host_vars/localhost.yml", host_vars)
return pull(write(base / "devices.yml", devices), self.roles, self.work)
def placed(self) -> list[str]:
return sorted(path.name for path in self.roles.iterdir())
def leftovers(self) -> list[Path]:
if not self.work.exists():
return []
return [path for path in self.work.iterdir() if path.is_dir()]
def tag(self, name: str) -> None:
git(self.repo, "add", "-A")
git(self.repo, "commit", "-q", "-m", name)
git(self.repo, "tag", name)
class TestPullPlacement(_PullFixture, unittest.TestCase):
def test_role_is_placed_at_its_tag_with_the_resolved_commit(self):
lines = self.pull({ROLE: declaration(str(self.repo))})
self.assertEqual(
"release: v1\n", read_text(str(self.roles / ROLE / ROLE_FILE_TASKS_MAIN))
)
self.assertTrue(is_placed(self.roles / ROLE))
self.assertRegex(
lines[0], rf"^{ROLE}: .* @ v1\.0\.0 \(commit [0-9a-f]{{40}}\)$"
)
def test_placed_role_carries_no_git_metadata(self):
self.pull({ROLE: declaration(str(self.repo), path=ROLE_PATH)})
self.assertFalse((self.roles / ROLE / ".git").exists())
def test_local_working_tree_is_placed_without_a_version(self):
write(self.repo / ROLE_PATH / ROLE_FILE_TASKS_MAIN, "release: uncommitted\n")
self.pull({ROLE: declaration(str(self.repo), version=None)})
self.assertEqual(
"release: uncommitted\n",
read_text(str(self.roles / ROLE / ROLE_FILE_TASKS_MAIN)),
)
def test_pull_again_replaces_the_placed_content(self):
self.pull({ROLE: declaration(str(self.repo))})
write(self.roles / ROLE / "stale.yml", "stale: true\n")
self.pull({ROLE: declaration(str(self.repo))})
self.assertFalse((self.roles / ROLE / "stale.yml").exists())
self.assertTrue((self.roles / ROLE / ROLE_FILE_VARS_MAIN).is_file())
def test_role_the_inventory_no_longer_declares_is_removed(self):
self.pull({ROLE: declaration(str(self.repo))})
lines = self.pull({}, groups=())
self.assertEqual(["web-app-nextcloud"], self.placed())
self.assertIn(f"{ROLE}: removed, the inventory no longer declares it", lines)
def test_in_tree_roles_are_never_touched(self):
self.pull({ROLE: declaration(str(self.repo))})
self.assertEqual([ROLE, "web-app-nextcloud"], self.placed())
def test_no_staging_or_leftover_directory_remains(self):
self.pull({ROLE: declaration(str(self.repo))})
self.pull({ROLE: declaration(str(self.repo))})
self.assertEqual([ROLE, "web-app-nextcloud"], self.placed())
self.assertEqual([], self.leftovers())
def test_undeclared_directory_the_pull_did_not_place_is_left_alone(self):
write(self.roles / OTHER / "keep.yml", "keep: true\n")
lines = self.pull({ROLE: declaration(str(self.repo))})
self.assertTrue((self.roles / OTHER / "keep.yml").is_file())
self.assertIn(f"{OTHER}: {LEFT}", lines)
class TestPullRefusal(_PullFixture, unittest.TestCase):
def setUp(self) -> None:
super().setUp()
self.pull({ROLE: declaration(str(self.repo))})
write(self.roles / ROLE / "sentinel.yml", "placed: true\n")
def assert_untouched(self) -> None:
self.assertTrue((self.roles / ROLE / "sentinel.yml").is_file())
self.assertEqual([ROLE, "web-app-nextcloud"], self.placed())
self.assertEqual([], self.leftovers())
def test_branch_as_version_is_refused_before_anything_changes(self):
with self.assertRaisesRegex(CustomRoleError, "'feature' names a branch"):
self.pull({ROLE: declaration(str(self.repo), version="feature")})
self.assert_untouched()
def test_application_id_other_than_the_group_names_both(self):
other = role_repo(self.root / "other", application_id="web-app-demo")
with self.assertRaisesRegex(
CustomRoleError,
f"application_id is 'web-app-demo', but the group is '{ROLE}'",
):
self.pull({ROLE: declaration(str(other))})
self.assert_untouched()
def test_missing_application_id_is_refused(self):
write(self.repo / ROLE_PATH / ROLE_FILE_VARS_MAIN, "other: value\n")
with self.assertRaisesRegex(CustomRoleError, "application_id is None"):
self.pull({ROLE: declaration(str(self.repo), version=None)})
self.assert_untouched()
def test_source_without_meta_main_is_refused(self):
(self.repo / ROLE_PATH / ROLE_FILE_META_MAIN).unlink()
with self.assertRaisesRegex(CustomRoleError, "no role directory"):
self.pull({ROLE: declaration(str(self.repo), version=None)})
self.assert_untouched()
def test_invalid_declaration_is_refused_before_anything_changes(self):
with self.assertRaisesRegex(CustomRoleError, "source.path is missing"):
self.pull(
{
ROLE: {
"custom": True,
"source": {"url": str(self.repo), "version": "v1.0.0"},
}
}
)
self.assert_untouched()
def test_path_that_is_no_directory_in_the_source_is_refused(self):
with self.assertRaisesRegex(CustomRoleError, "source.path is not a directory"):
self.pull({ROLE: declaration(str(self.repo), path=f"absent/{ROLE}")})
self.assert_untouched()
def test_local_source_inside_its_placement_is_refused(self):
with self.assertRaisesRegex(CustomRoleError, "never deletes its own source"):
self.pull(
{ROLE: declaration(str(self.root), version=None, path=f"roles/{ROLE}")}
)
self.assert_untouched()
def test_a_failing_role_leaves_every_placed_role_unchanged(self):
broken = {
"custom": True,
"source": {"url": str(self.root / "absent"), "path": f"x/{OTHER}"},
}
with self.assertRaisesRegex(CustomRoleError, OTHER):
self.pull(
{ROLE: declaration(str(self.repo)), OTHER: broken}, groups=(ROLE, OTHER)
)
self.assert_untouched()
def test_missing_inventory_is_refused(self):
with self.assertRaisesRegex(CustomRoleError, "neither a file nor a directory"):
pull(self.root / "absent/devices.yml", self.roles, self.work)
self.assert_untouched()
def test_unparsable_vars_file_stops_the_pull_naming_it(self):
inv = inventory(self.next_inventory(), [], {})
broken = write(inv.parent / "group_vars/all.yml", "applications:\n x: [open\n")
with self.assertRaisesRegex(
CustomRoleError, rf"{re.escape(str(broken))}: cannot read"
):
pull(inv, self.roles, self.work)
self.assert_untouched()
def test_vars_file_that_is_no_utf8_stops_the_pull_naming_it(self):
inv = inventory(self.next_inventory(), [], {})
latin = inv.parent / "group_vars/all.yml"
latin.parent.mkdir()
latin.write_bytes("applications: {caf\xe9: {}}\n".encode("latin-1"))
with self.assertRaisesRegex(
CustomRoleError, rf"{re.escape(str(latin))}: cannot read"
):
pull(inv, self.roles, self.work)
self.assert_untouched()
def test_inventory_file_that_is_no_utf8_stops_the_pull_naming_it(self):
inv = inventory(self.next_inventory(), [], {})
inv.write_bytes("all:\n hosts:\n caf\xe9: {}\n".encode("latin-1"))
with self.assertRaisesRegex(
CustomRoleError, rf"{re.escape(str(inv))}: cannot read"
):
pull(inv, self.roles, self.work)
self.assert_untouched()
class TestPullInventoryForms(_PullFixture, unittest.TestCase):
def test_directory_inventory_is_read_like_a_file_inventory(self):
inv = inventory(
self.next_inventory(), [ROLE], {ROLE: declaration(str(self.repo))}
)
pull(inv.parent, self.roles, self.work)
self.assertTrue(is_placed(self.roles / ROLE))
def test_group_of_a_hosts_file_in_a_directory_inventory_needs_custom_true(self):
base = self.next_inventory()
write(base / "hosts", f"[{ROLE}]\nlocalhost\n")
write(base / "README.md", "[web-app-cst-ignored]\n")
write(base / "host_vars/localhost.yml", "applications: {}\n")
with self.assertRaises(CustomRoleError) as refused:
pull(base, self.roles, self.work)
self.assertIn(f"{ROLE}: inventory group without", str(refused.exception))
self.assertNotIn("web-app-cst-ignored", str(refused.exception))
def test_host_list_declares_nothing_and_changes_nothing(self):
self.pull({ROLE: declaration(str(self.repo))})
self.assertEqual([], pull(Path("localhost,"), self.roles, self.work))
self.assertTrue(is_placed(self.roles / ROLE))
class TestPullAnsibleTags(_PullFixture, unittest.TestCase):
DEVICES = (
"all:\n"
" hosts:\n"
" localhost:\n"
" motd: !unsafe '{{ literal }}'\n"
" children:\n"
f" {ROLE}:\n"
" hosts:\n"
" localhost: {}\n"
)
def host_vars(self, url: str) -> str:
return (
"secret: !vault |\n"
" $ANSIBLE_VAULT;1.1;AES256\n"
" 6162\n"
"applications:\n"
f" {ROLE}:\n"
" custom: true\n"
" source:\n"
f" url: {url}\n"
" version: v1.0.0\n"
f" path: {ROLE_PATH}\n"
)
def test_tagged_values_elsewhere_in_the_inventory_keep_the_pull_working(self):
self.pull_yaml(self.DEVICES, self.host_vars(str(self.repo)))
self.assertTrue(is_placed(self.roles / ROLE))
def test_tagged_source_value_is_refused_like_a_jinja_value(self):
with self.assertRaisesRegex(
CustomRoleError,
f"{ROLE}: applications.{ROLE}.source.url holds a !unsafe value",
):
self.pull_yaml(self.DEVICES, self.host_vars(f"!unsafe '{self.repo}'"))
self.assertEqual(["web-app-nextcloud"], self.placed())
class TestPullOwnership(_PullFixture, unittest.TestCase):
def test_symbolic_link_under_a_declared_name_is_refused_and_kept(self):
target = self.root / "elsewhere"
write(target / "keep.yml", "keep: true\n")
(self.roles / ROLE).symlink_to(target)
with self.assertRaisesRegex(
CustomRoleError, f"{ROLE}: .* is a symbolic link .* remove the link"
):
self.pull({ROLE: declaration(str(self.repo))})
self.assertTrue((self.roles / ROLE).is_symlink())
self.assertTrue((target / "keep.yml").is_file())
def test_hand_placed_directory_under_a_declared_name_is_refused_and_kept(self):
write(self.roles / ROLE / "mine.yml", "mine: true\n")
with self.assertRaisesRegex(
CustomRoleError, f"{ROLE}: .* was not placed by a pull"
):
self.pull({ROLE: declaration(str(self.repo))})
self.assertTrue((self.roles / ROLE / "mine.yml").is_file())
self.assertFalse((self.roles / ROLE / MARKER_FILE).exists())
def test_copy_of_a_placed_role_is_left_in_place_and_reported(self):
self.pull({ROLE: declaration(str(self.repo))})
shutil.copytree(self.roles / ROLE, self.roles / COPY)
lines = self.pull({}, groups=())
self.assertEqual([COPY, "web-app-nextcloud"], self.placed())
self.assertIn(f"{COPY}: {LEFT}", lines)
def test_declared_name_that_meets_a_copy_is_refused_and_kept(self):
self.pull({ROLE: declaration(str(self.repo))})
shutil.copytree(self.roles / ROLE, self.roles / COPY)
write(self.roles / COPY / "mine.yml", "mine: true\n")
checkout = local_role(self.root, COPY)
mine = declaration(str(checkout), version=None, path=f"ansible/roles/{COPY}")
with self.assertRaisesRegex(
CustomRoleError, f"{COPY}: .* was not placed by a pull"
):
self.pull({ROLE: declaration(str(self.repo)), COPY: mine}, (ROLE, COPY))
self.assertTrue((self.roles / COPY / "mine.yml").is_file())
class TestPullMarker(_PullFixture, unittest.TestCase):
def setUp(self) -> None:
super().setUp()
self.in_tree = write(
self.roles / "web-app-nextcloud" / ROLE_FILE_TASKS_MAIN, "in-tree: true\n"
)
def assert_in_tree_untouched(self) -> None:
text = self.in_tree.read_text(encoding="utf-8") # nocheck: cache-read tempdir
self.assertEqual("in-tree: true\n", text)
self.assertEqual(["web-app-nextcloud"], self.placed())
self.assertEqual([], self.leftovers())
def test_marker_link_in_a_local_source_is_refused_and_never_followed(self):
checkout = local_role(self.root, ROLE)
(checkout / "ansible/roles" / ROLE / MARKER_FILE).symlink_to(self.in_tree)
with self.assertRaisesRegex(
CustomRoleError, f"{ROLE}: .*{MARKER_FILE} is a symbolic link"
):
self.pull(
{
ROLE: declaration(
str(checkout), version=None, path=f"ansible/roles/{ROLE}"
)
}
)
self.assert_in_tree_untouched()
def test_relative_marker_link_in_a_tag_is_refused(self):
(self.repo / ROLE_PATH / MARKER_FILE).symlink_to(ROLE_FILE_TASKS_MAIN)
self.tag("v2.0.0")
with self.assertRaisesRegex(
CustomRoleError, f"{ROLE}: .*{MARKER_FILE} is a symbolic link"
):
self.pull({ROLE: declaration(str(self.repo), version="v2.0.0")})
self.assert_in_tree_untouched()
def test_absolute_link_in_a_tag_is_refused_naming_the_group(self):
(self.repo / ROLE_PATH / MARKER_FILE).symlink_to(self.in_tree)
self.tag("v2.0.0")
with self.assertRaisesRegex(CustomRoleError, f"^{ROLE}: .*absolute"):
self.pull({ROLE: declaration(str(self.repo), version="v2.0.0")})
self.assert_in_tree_untouched()
class TestPullCommit(_PullFixture, unittest.TestCase):
def test_interrupt_while_deleting_the_old_copy_keeps_the_new_one(self):
self.pull({ROLE: declaration(str(self.repo))})
write(self.roles / ROLE / "stale.yml", "stale: true\n")
rmtree = shutil.rmtree
def interrupted(path, *args, **kwargs):
if ".retired." in str(path):
raise KeyboardInterrupt
return rmtree(path, *args, **kwargs)
with (
mock.patch.object(placement.shutil, "rmtree", interrupted),
self.assertRaises(KeyboardInterrupt),
):
self.pull({ROLE: declaration(str(self.repo))})
self.assertTrue(is_placed(self.roles / ROLE))
self.assertFalse((self.roles / ROLE / "stale.yml").exists())
self.pull({ROLE: declaration(str(self.repo))})
self.assertEqual([], self.leftovers())
def test_directory_that_appears_at_a_declared_name_during_the_pull_is_kept(self):
stage = placement.stage
def stage_beside_a_hand_placed_copy(group, source, target):
write(self.roles / group / "mine.yml", "mine: true\n")
return stage(group, source, target)
with (
mock.patch.object(placement, "stage", stage_beside_a_hand_placed_copy),
self.assertRaisesRegex(
CustomRoleError, f"{ROLE}: .* was not placed by a pull"
),
):
self.pull({ROLE: declaration(str(self.repo))})
self.assertTrue((self.roles / ROLE / "mine.yml").is_file())
self.assertFalse(is_placed(self.roles / ROLE))
self.assertEqual([], self.leftovers())
def test_clean_waits_until_a_running_pull_has_finished(self):
events: list[tuple[str, object]] = []
cleaner = threading.Thread(
target=lambda: events.append(("cleaned", clean(self.roles, self.work)))
)
stage = placement.stage
def stage_while_a_clean_starts(group, source, target):
cleaner.start()
cleaner.join(timeout=0.5)
events.append(("clean still waiting", cleaner.is_alive()))
return stage(group, source, target)
with mock.patch.object(placement, "stage", stage_while_a_clean_starts):
self.pull({ROLE: declaration(str(self.repo))})
cleaner.join(timeout=30)
self.assertEqual(("clean still waiting", True), events[0])
self.assertEqual("cleaned", events[1][0])
self.assertEqual(["web-app-nextcloud"], self.placed())
class TestClean(_PullFixture, unittest.TestCase):
def test_removes_only_the_roles_the_pull_placed_and_reports_the_rest(self):
checkout = local_role(self.root, OTHER)
self.pull(
{
ROLE: declaration(str(self.repo)),
OTHER: declaration(
str(checkout), version=None, path=f"ansible/roles/{OTHER}"
),
},
groups=(ROLE, OTHER),
)
shutil.copytree(self.roles / ROLE, self.roles / COPY)
write(self.roles / "web-app-cst-hand" / "mine.yml", "mine: true\n")
target = self.root / "elsewhere"
write(target / "keep.yml", "keep: true\n")
(self.roles / "web-app-cst-link").symlink_to(target)
lines = clean(self.roles, self.work)
self.assertEqual(
[
f"{ROLE}: removed",
f"web-app-cst-hand: {LEFT}",
f"web-app-cst-link: {LEFT}",
f"{COPY}: {LEFT}",
f"{OTHER}: removed",
],
lines,
)
self.assertEqual(
["web-app-cst-hand", "web-app-cst-link", COPY, "web-app-nextcloud"],
self.placed(),
)
self.assertTrue((target / "keep.yml").is_file())
self.assertEqual([], self.leftovers())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,186 @@
from __future__ import annotations
import re
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from cli.administration.roles import source as source_module
from cli.administration.roles.declaration import Source
from cli.administration.roles.source import check, stage
from utils.cache.files import read_text
from utils.roles.custom import CustomRoleError
from utils.roles.mapping import ROLE_FILE_TASKS_MAIN
from ._fixtures import ROLE, ROLE_PATH, git, role_repo, roles_dir, write
class _SourceFixture:
"""Mixin with a core roles/ and a role repository.
Combined with ``unittest.TestCase`` on each concrete class so the
test-discovery lint sees the ``TestCase`` base directly.
"""
def setUp(self) -> None:
super().setUp()
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
self.roles = roles_dir(self.root)
self.repo = role_repo(self.root)
def check(self, source: Source, placements: tuple[Path, ...] = ()) -> None:
check(ROLE, source, list(placements), self.roles)
class TestCheckVersion(_SourceFixture, unittest.TestCase):
def test_tag_is_accepted(self):
self.check(Source(str(self.repo), "v1.0.0", ROLE_PATH))
def test_full_commit_sha_is_accepted(self):
sha = git(self.repo, "rev-parse", "v1.0.0").strip()
self.check(Source(str(self.repo), sha, ROLE_PATH))
def test_full_commit_sha_needs_no_ref_lookup(self):
self.check(Source("https://example.invalid/widgets.git", "a" * 40, ROLE_PATH))
def test_branch_is_refused(self):
with self.assertRaisesRegex(
CustomRoleError, f"{ROLE}: source.version 'feature' names a branch"
):
self.check(Source(str(self.repo), "feature", ROLE_PATH))
def test_abbreviated_sha_is_refused(self):
sha = git(self.repo, "rev-parse", "--short", "v1.0.0").strip()
with self.assertRaisesRegex(
CustomRoleError, "neither a tag nor a full commit SHA"
):
self.check(Source(str(self.repo), sha, ROLE_PATH))
def test_unknown_tag_is_refused(self):
with self.assertRaisesRegex(CustomRoleError, "'v9.9.9' is neither a tag"):
self.check(Source(str(self.repo), "v9.9.9", ROLE_PATH))
class TestCheckLocalSource(_SourceFixture, unittest.TestCase):
def test_missing_directory_is_refused(self):
with self.assertRaisesRegex(CustomRoleError, "is not a directory"):
self.check(Source(str(self.root / "absent"), None, ROLE_PATH))
def test_own_placement_directory_is_refused_naming_the_path(self):
(self.roles / ROLE).mkdir()
placement = re.escape(str((self.roles / ROLE).resolve()))
with self.assertRaisesRegex(
CustomRoleError, f"{placement} lies in the placement directory"
):
self.check(Source(str(self.root), None, f"roles/{ROLE}"))
def test_another_placement_is_refused(self):
other = self.roles / "web-app-cst-other"
other.mkdir()
with self.assertRaisesRegex(CustomRoleError, "never deletes its own source"):
self.check(
Source(str(self.root), None, f"roles/{other.name}/{ROLE}"), (other,)
)
def test_placement_reached_through_a_symlink_is_refused(self):
(self.roles / ROLE).symlink_to(self.repo / ROLE_PATH)
with self.assertRaisesRegex(CustomRoleError, "never deletes its own source"):
self.check(Source(str(self.repo), None, ROLE_PATH))
def test_source_through_a_symlinked_checkout_is_refused(self):
alias = self.root / "alias"
alias.symlink_to(self.root)
with self.assertRaisesRegex(CustomRoleError, "never deletes its own source"):
self.check(Source(str(alias), None, f"roles/{ROLE}"))
def test_checkout_outside_roles_is_accepted(self):
self.check(Source(str(self.repo), None, ROLE_PATH))
class TestStage(_SourceFixture, unittest.TestCase):
def test_tag_stages_only_the_role_path_at_that_tag(self):
target = self.root / "staged"
origin = stage(ROLE, Source(str(self.repo), "v1.0.0", ROLE_PATH), target)
sha = git(self.repo, "rev-parse", "v1.0.0^{commit}").strip()
self.assertEqual("release: v1\n", read_text(str(target / ROLE_FILE_TASKS_MAIN)))
self.assertFalse((target / "README.md").exists())
self.assertIn(f"(commit {sha})", origin)
def test_full_commit_sha_is_staged(self):
sha = git(self.repo, "rev-parse", "v1.0.0^{commit}").strip()
target = self.root / "staged"
stage(ROLE, Source(str(self.repo), sha, ROLE_PATH), target)
self.assertEqual("release: v1\n", read_text(str(target / ROLE_FILE_TASKS_MAIN)))
def test_local_directory_without_version_is_its_working_tree(self):
write(self.repo / ROLE_PATH / ROLE_FILE_TASKS_MAIN, "release: uncommitted\n")
(self.repo / ROLE_PATH / "__pycache__").mkdir()
target = self.root / "staged"
origin = stage(ROLE, Source(str(self.repo), None, ROLE_PATH), target)
self.assertEqual(
"release: uncommitted\n", read_text(str(target / ROLE_FILE_TASKS_MAIN))
)
self.assertFalse((target / "__pycache__").exists())
self.assertIn("(working tree)", origin)
def test_path_that_is_a_file_is_refused(self):
with self.assertRaisesRegex(CustomRoleError, "source.path is not a directory"):
stage(ROLE, Source(str(self.repo), "v1.0.0", "README.md"), self.root / "s")
def test_missing_path_is_refused(self):
with self.assertRaisesRegex(CustomRoleError, "source.path is not a directory"):
stage(
ROLE,
Source(str(self.repo), "v1.0.0", f"absent/{ROLE}"),
self.root / "s",
)
def test_failed_fetch_names_the_group(self):
with self.assertRaisesRegex(
CustomRoleError, f"{ROLE}: git fetch of v1.0.0 failed"
):
stage(
ROLE,
Source(str(self.root / "absent"), "v1.0.0", ROLE_PATH),
self.root / "staged",
)
class TestGitNeverPrompts(unittest.TestCase):
def test_git_runs_detached_without_a_prompt_or_askpass(self):
failed = subprocess.CompletedProcess(
["git"], 128, stdout=b"", stderr=b"fatal: could not read Username"
)
source = Source("git@example.invalid:acme/widgets.git", "v1.0.0", ROLE_PATH)
with (
tempfile.TemporaryDirectory() as tmp,
mock.patch.dict(
"os.environ",
{
"GIT_ASKPASS": "/bin/askpass",
"SSH_ASKPASS": "/bin/askpass",
"SSH_AUTH_SOCK": "/run/agent.sock",
},
),
mock.patch.object(
source_module.subprocess, "run", return_value=failed
) as run,
self.assertRaisesRegex(CustomRoleError, "could not read Username"),
):
stage(ROLE, source, Path(tmp) / "staged")
kwargs = run.call_args.kwargs
self.assertEqual("0", kwargs["env"]["GIT_TERMINAL_PROMPT"])
self.assertEqual("never", kwargs["env"]["SSH_ASKPASS_REQUIRE"])
self.assertNotIn("GIT_ASKPASS", kwargs["env"])
self.assertNotIn("SSH_ASKPASS", kwargs["env"])
self.assertEqual("/run/agent.sock", kwargs["env"]["SSH_AUTH_SOCK"])
self.assertTrue(kwargs["start_new_session"])
self.assertEqual(subprocess.DEVNULL, kwargs["stdin"])
if __name__ == "__main__":
unittest.main()