import hashlib
import os
import tarfile
import tempfile
import unittest
from pathlib import Path
from typing import ClassVar
from unittest.mock import patch
from ansible.plugins.action import copy as copy_action
from plugins.action.stack_host_copy import (
ActionModule,
_walk_dirs_without_bytecode_caches,
)
class _FakeTask:
def __init__(self, *, args=None):
self.args = {} if args is None else dict(args)
self.async_val = 0
self.check_mode = False
self.diff = False
self.no_log = False
self.action = "stack_host_copy"
def get_search_path(self):
return []
class _FakeTemplar:
def __init__(self, *, is_stack_host=True):
self._is_stack_host = is_stack_host
self.available_variables: dict[str, object] = {}
def template(self, raw):
return self._is_stack_host
def _make_action(task, templar):
action = object.__new__(ActionModule)
action._task = task
action._templar = templar
return action
class TestStackHostCopy(unittest.TestCase):
def test_skips_silently_on_non_stack_host(self):
task = _FakeTask(args={"src": "x", "dest": "/opt/compose/app/x"})
action = _make_action(task, _FakeTemplar(is_stack_host=False))
result = action.run(task_vars={})
self.assertTrue(result["skipped"])
self.assertFalse(result["changed"])
self.assertIn("IS_STACK_HOST", result["skip_reason"])
def test_skips_when_templar_returns_string_false(self):
task = _FakeTask(args={"src": "x", "dest": "/opt/compose/app/x"})
action = _make_action(task, _FakeTemplar(is_stack_host="False"))
result = action.run(task_vars={})
self.assertTrue(result["skipped"])
def test_delegates_to_copy_on_stack_host(self):
task = _FakeTask(args={"src": "x", "dest": "/opt/compose/app/x"})
action = _make_action(task, _FakeTemplar(is_stack_host=True))
with patch(
"plugins.action.stack_host_copy.CopyActionModule.run",
return_value={"changed": True},
) as super_run:
action.run(task_vars={})
super_run.assert_called_once()
def test_seeds_templar_with_task_vars_before_rendering(self):
task = _FakeTask(args={"src": "x", "dest": "/opt/compose/app/x"})
templar = _FakeTemplar(is_stack_host=True)
action = _make_action(task, templar)
with patch(
"plugins.action.stack_host_copy.CopyActionModule.run",
return_value={"changed": True},
):
action.run(task_vars={"IS_STACK_HOST": True, "ANSIBLE_VERSION": "9.0"})
self.assertEqual(templar.available_variables.get("IS_STACK_HOST"), True)
self.assertEqual(templar.available_variables.get("ANSIBLE_VERSION"), "9.0")
def test_defaults_mode_to_0644_when_unset(self):
task = _FakeTask(args={"src": "x", "dest": "/opt/compose/app/x"})
action = _make_action(task, _FakeTemplar(is_stack_host=True))
with patch(
"plugins.action.stack_host_copy.CopyActionModule.run",
return_value={"changed": True},
):
action.run(task_vars={})
self.assertEqual(task.args["mode"], "0644")
def test_keeps_caller_supplied_mode(self):
task = _FakeTask(
args={"src": "x", "dest": "/opt/compose/app/x", "mode": "0600"}
)
action = _make_action(task, _FakeTemplar(is_stack_host=True))
with patch(
"plugins.action.stack_host_copy.CopyActionModule.run",
return_value={"changed": True},
):
action.run(task_vars={})
self.assertEqual(task.args["mode"], "0600")
def test_file_source_never_takes_the_tree_check(self):
task = _FakeTask(args={"src": "x", "dest": "/opt/compose/app/x"})
action = _make_action(task, _FakeTemplar(is_stack_host=True))
with patch(
"plugins.action.stack_host_copy.CopyActionModule.run",
return_value={"changed": True},
) as super_run:
result = action.run(task_vars={})
super_run.assert_called_once()
self.assertTrue(result["changed"])
def test_is_stack_host_expr_carries_trust_tag(self):
from ansible.template import is_trusted_as_template
from plugins.action.stack_host_copy import _IS_STACK_HOST_EXPR
self.assertTrue(
is_trusted_as_template(_IS_STACK_HOST_EXPR),
"The IS_STACK_HOST template expression must carry the TrustedAsTemplate "
"tag; otherwise Ansible 2.19+ returns the literal string unchanged.",
)
class _FakeShell:
def __init__(self):
self.tmpdir = None
def join_path(self, *parts):
return os.path.join(*parts) # noqa: PTH118 - mirrors the sh shell plugin, whose join_path keeps a trailing slash
def path_has_trailing_slash(self, path):
return path.endswith("/")
class _FakeConnection:
def __init__(self):
self._shell = _FakeShell()
self.become = None
class _FakeLoader:
def get_real_file(self, path, decrypt=True):
return path
def cleanup_tmp_file(self, path):
return None
def path_dwim_relative_stack(self, paths, dirname, needle):
return needle
class _FakePlayContext:
no_log = False
def _sha1(data):
return hashlib.sha1(data).hexdigest() # noqa: S324 - the checksum Ansible copy compares is SHA-1
class _Dir:
def __init__(self, mode):
self.mode = mode
class _Link:
def __init__(self, data):
self.data = data
def _mode_bits(mode):
return mode if isinstance(mode, int) else int(mode, 8)
class _FakeRemote:
def __init__(self, entries):
self.entries = dict(entries)
self.calls = []
self.args = []
self.transfers = []
self.fixups = 0
def transfer(self, local, remote_path):
if not remote_path.endswith(".tar"):
self.transfers.append(Path(local).read_bytes()) # nocheck: cache-read
return remote_path
with tarfile.open(local) as bundle:
self.transfers.append(
{
member.name: bundle.extractfile(member).read()
for member in bundle.getmembers()
}
)
return remote_path
def fixup(self, *args, **kwargs):
self.fixups += 1
def __call__(self, module_name=None, module_args=None, task_vars=None, **kwargs):
self.calls.append(module_name)
self.args.append(module_args)
if module_name == "ansible.legacy.file_checksums":
return {
"changed": False,
"states": {p: self._state(p) for p in module_args["paths"]},
}
if module_name == "ansible.legacy.copy_batch":
return self._copy_batch(module_args)
if module_name == "ansible.legacy.stat":
entry = self.entries.get(module_args["path"])
if entry is None:
return {"stat": {"exists": False}}
if isinstance(entry, _Dir):
return {"stat": {"exists": True, "isdir": True}}
if isinstance(entry, _Link):
digest = _sha1(entry.data) if module_args["follow"] else ""
return {
"stat": {
"exists": True,
"isdir": False,
"islnk": True,
"checksum": digest,
}
}
return {
"stat": {
"exists": True,
"isdir": False,
"islnk": False,
"checksum": _sha1(entry[0]),
}
}
if module_name == "ansible.legacy.file" and module_args["state"] == "directory":
return self._ensure_directory(module_args["path"], module_args["mode"])
if module_name == "ansible.legacy.file":
entry = self.entries[
module_args["dest"] + module_args["_original_basename"]
]
return {"changed": entry[1] != _mode_bits(module_args["mode"])}
if module_name == "ansible.legacy.copy":
return {"changed": True}
raise AssertionError(f"unexpected module {module_name}")
def _ensure_directory(self, path, mode):
entry = self.entries.get(path)
if entry is None:
return {"changed": True, "path": path}
if not isinstance(entry, _Dir):
return {"failed": True, "msg": f"{path} already exists as a file"}
return {
"changed": mode is not None and entry.mode != _mode_bits(mode),
"path": path,
}
def _copy_batch(self, args):
dest = args["dest"]
files = [entry["path"] for entry in args["files"]]
refused = [
dest + rel
for rel in [*files, *args["settled"]]
if isinstance(self.entries.get(dest + rel), (_Dir, _Link))
] + [
dest + rel
for rel in args["directories"]
if dest + rel in self.entries
and not isinstance(self.entries[dest + rel], _Dir)
]
if refused:
return {"changed": False, "refused": refused}
archive = self.transfers[-1]
if list(archive) != files or any(
_sha1(archive[entry["path"]]) != entry["checksum"]
for entry in args["files"]
):
return {"failed": True, "msg": "archive does not match files"}
mode = _mode_bits(args["mode"])
for rel in files:
self.entries[dest + rel] = (archive[rel], mode)
for rel in args["settled"]:
self.entries[dest + rel] = (self.entries[dest + rel][0], mode)
for rel in args["directories"]:
self.entries[dest + rel] = _Dir(_mode_bits(args["directory_mode"] or 0o755))
return {"changed": True}
def _state(self, path):
entry = self.entries.get(path)
if entry is None or isinstance(entry, _Link):
return {"isreg": False, "isdir": False, "mode": None, "checksum": None}
if isinstance(entry, _Dir):
return {
"isreg": False,
"isdir": True,
"mode": f"{entry.mode:04o}",
"checksum": None,
}
return {
"isreg": True,
"isdir": False,
"mode": f"{entry[1]:04o}",
"checksum": _sha1(entry[0]),
}
class _TreeHarness(unittest.TestCase):
FILES: ClassVar[dict[str, bytes]] = {}
DEST = "/var/www/public_cdn/roles/app/latest/img/design/"
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.src = Path(self._tmp.name)
for rel, data in self.FILES.items():
(self.src / rel).parent.mkdir(parents=True, exist_ok=True)
(self.src / rel).write_bytes(data)
def tearDown(self):
self._tmp.cleanup()
def _synced(self, mode=0o644):
return {self.DEST + rel: (data, mode) for rel, data in self.FILES.items()}
def _run(self, remote, *, check_mode=False, diff=False, loader=None, **extra):
task = _FakeTask(args={"src": f"{self.src}/", "dest": self.DEST, **extra})
task.check_mode = check_mode
task.diff = diff
action = _make_action(task, _FakeTemplar(is_stack_host=True))
action._connection = _FakeConnection()
action._loader = _FakeLoader() if loader is None else loader
action._play_context = _FakePlayContext()
action._execute_module = remote
action._transfer_file = getattr(
remote, "transfer", lambda local, remote_path: remote_path
)
action._fixup_perms2 = getattr(remote, "fixup", lambda *a, **k: None)
action._remove_tmp_path = lambda *a, **k: None
action._remote_expand_user = lambda path, **k: path
def make_tmp_path(*a, **k):
action._connection._shell.tmpdir = "/remote/tmp"
return "/remote/tmp"
action._make_tmp_path = make_tmp_path
return action.run(task_vars={})
BATCH: ClassVar[list[str]] = [
"ansible.legacy.file_checksums",
"ansible.legacy.copy_batch",
]
@staticmethod
def _batch_args(remote):
return remote.args[remote.calls.index("ansible.legacy.copy_batch")]
@staticmethod
def _copies(remote):
return [
args
for name, args in zip(remote.calls, remote.args, strict=True)
if name == "ansible.legacy.copy"
]
class TestStackHostCopyTree(_TreeHarness):
FILES: ClassVar[dict[str, bytes]] = {
"favicon.ico": b"ico",
"icon.png": b"png",
"nested/icon.svg": b"",
}
def _changed(self):
entries = self._synced()
entries[self.DEST + "icon.png"] = (b"old", 0o644)
return entries
def test_unchanged_tree_costs_one_remote_call(self):
remote = _FakeRemote(self._synced())
result = self._run(remote)
self.assertFalse(result["changed"])
self.assertEqual(remote.calls, ["ansible.legacy.file_checksums"])
self.assertEqual(result["dest"], self.DEST)
self.assertEqual(result["src"], f"{self.src}/")
def test_changed_file_travels_alone_in_one_batch(self):
entries = self._synced()
entries[self.DEST + "icon.png"] = (b"old", 0o644)
remote = _FakeRemote(entries)
result = self._run(remote)
self.assertTrue(result["changed"])
self.assertEqual(remote.calls, self.BATCH)
self.assertEqual(remote.transfers, [{"icon.png": b"png"}])
self.assertEqual(
self._batch_args(remote)["files"],
[{"path": "icon.png", "checksum": _sha1(b"png")}],
)
self.assertEqual(sorted(self._batch_args(remote)["tree"]), sorted(self.FILES))
self.assertEqual(remote.entries, self._synced())
def test_missing_file_travels_in_one_batch(self):
entries = self._synced()
del entries[self.DEST + "nested/icon.svg"]
remote = _FakeRemote(entries)
result = self._run(remote)
self.assertTrue(result["changed"])
self.assertEqual(remote.calls, self.BATCH)
self.assertEqual(remote.transfers, [{"nested/icon.svg": b""}])
def test_mode_drift_is_settled_without_sending_the_file(self):
entries = self._synced()
entries[self.DEST + "favicon.ico"] = (b"ico", 0o600)
remote = _FakeRemote(entries)
result = self._run(remote)
self.assertTrue(result["changed"])
self.assertEqual(remote.calls, self.BATCH)
self.assertEqual(remote.transfers, [{}])
self.assertEqual(self._batch_args(remote)["settled"], ["favicon.ico"])
self.assertEqual(self._batch_args(remote)["files"], [])
def test_directory_in_place_of_a_file_is_refused_then_copied_regularly(self):
entries = self._synced()
entries[self.DEST + "icon.png"] = _Dir(0o755)
remote = _FakeRemote(entries)
self._run(remote)
self.assertEqual(remote.calls[:2], self.BATCH)
self.assertIn("ansible.legacy.stat", remote.calls)
self.assertIn("ansible.legacy.copy", remote.calls)
def test_symlink_in_place_of_a_file_is_refused_then_copied_regularly(self):
entries = self._synced()
entries[self.DEST + "icon.png"] = _Link(b"png")
remote = _FakeRemote(entries)
self._run(remote)
self.assertEqual(remote.calls[:2], self.BATCH)
copies = self._copies(remote)
self.assertEqual([args["_original_basename"] for args in copies], ["icon.png"])
self.assertFalse(copies[0]["follow"])
def test_check_mode_keeps_the_regular_copy(self):
remote = _FakeRemote(self._changed())
result = self._run(remote, check_mode=True)
self.assertTrue(result["changed"])
self.assertNotIn("ansible.legacy.copy_batch", remote.calls)
self.assertNotIn("ansible.legacy.copy", remote.calls)
self.assertIn("ansible.legacy.stat", remote.calls)
self.assertEqual(remote.transfers, [])
def test_diff_mode_keeps_the_regular_copy(self):
remote = _FakeRemote(self._changed())
with patch(
"plugins.action.stack_host_copy.CopyActionModule.run",
return_value={"changed": True, "diff": [{"after": "png"}]},
) as super_run:
result = self._run(remote, diff=True)
super_run.assert_called_once()
self.assertEqual(result["diff"], [{"after": "png"}])
self.assertEqual(remote.calls, ["ansible.legacy.file_checksums"])
self.assertEqual(remote.transfers, [])
def test_failed_batch_stays_failed_without_a_second_copy(self):
failure = {
"failed": True,
"changed": True,
"msg": f"Failed to write {self.DEST}icon.png: boom",
}
class _FailingBatch(_FakeRemote):
def _copy_batch(self, args):
return dict(failure)
remote = _FailingBatch(self._changed())
with patch("plugins.action.stack_host_copy.CopyActionModule.run") as super_run:
result = self._run(remote)
super_run.assert_not_called()
self.assertTrue(result["failed"])
self.assertIn(f"{self.DEST}icon.png", result["msg"])
self.assertEqual(remote.calls, self.BATCH)
def test_batch_result_has_the_keys_of_the_regular_copy(self):
batch = self._run(_FakeRemote(self._changed()))
with patch.object(ActionModule, "_write_tree", return_value=None):
regular = self._run(_FakeRemote(self._changed()))
self.assertEqual(set(batch), set(regular))
self.assertEqual(
{key: batch[key] for key in ("changed", "dest", "src")},
{key: regular[key] for key in ("changed", "dest", "src")},
)
def test_unprivileged_become_keeps_the_regular_copy(self):
remote = _FakeRemote(self._changed())
with patch.object(ActionModule, "_is_become_unprivileged", return_value=True):
result = self._run(remote)
self.assertTrue(result["changed"])
self.assertNotIn("ansible.legacy.copy_batch", remote.calls)
self.assertIn("ansible.legacy.copy", remote.calls)
def test_dest_with_a_variable_skips_the_tree_check(self):
remote = _FakeRemote(self._changed())
task = _FakeTask(args={"src": f"{self.src}/", "dest": "/srv/$APP/img/"})
action = _make_action(task, _FakeTemplar(is_stack_host=True))
action._execute_module = remote
with patch(
"plugins.action.stack_host_copy.CopyActionModule.run",
return_value={"changed": True},
) as super_run:
action.run(task_vars={})
super_run.assert_called_once()
self.assertEqual(remote.calls, [])
def test_archive_over_the_limit_keeps_the_regular_copy(self):
entries = self._synced()
entries[self.DEST + "icon.png"] = (b"old", 0o644)
entries[self.DEST + "favicon.ico"] = (b"old", 0o644)
remote = _FakeRemote(entries)
with patch("plugins.action.stack_host_copy._ARCHIVE_LIMIT", 5):
result = self._run(remote)
self.assertTrue(result["changed"])
self.assertNotIn("ansible.legacy.copy_batch", remote.calls)
self.assertEqual(sorted(remote.transfers), [b"ico", b"png"])
def test_files_travel_in_the_order_the_copy_action_walks(self):
remote = _FakeRemote({})
self._run(remote)
walked = [
rel
for _, rel in _walk_dirs_without_bytecode_caches(
f"{self.src}/", local_follow=True
)["files"]
]
sent = [entry["path"] for entry in self._batch_args(remote)["files"]]
self.assertEqual(sent, walked)
self.assertEqual(list(remote.transfers[0]), walked)
def test_checksum_is_taken_from_the_packed_bytes(self):
remote = _FakeRemote(self._changed())
icon = self.src / "icon.png"
loader = _FakeLoader()
reads = []
def get_real_file(path, decrypt=True):
reads.append(path)
if path == str(icon) and reads.count(path) == 2:
icon.write_bytes(b"edited")
return path
loader.get_real_file = get_real_file
self._run(remote, loader=loader)
self.assertEqual(remote.transfers, [{"icon.png": b"edited"}])
self.assertEqual(
self._batch_args(remote)["files"],
[{"path": "icon.png", "checksum": _sha1(b"edited")}],
)
def test_requested_mode_is_what_the_tree_check_compares(self):
remote = _FakeRemote(self._synced(mode=0o600))
result = self._run(remote, mode="0600")
self.assertFalse(result["changed"])
self.assertEqual(remote.calls, ["ansible.legacy.file_checksums"])
def test_attribute_args_skip_the_tree_check(self):
remote = _FakeRemote(self._synced())
self._run(remote, owner="root")
self.assertNotIn("ansible.legacy.file_checksums", remote.calls)
self.assertIn("ansible.legacy.stat", remote.calls)
def test_symbolic_mode_skips_the_tree_check(self):
remote = _FakeRemote(self._synced())
with patch(
"plugins.action.stack_host_copy.CopyActionModule.run",
return_value={"changed": False},
) as super_run:
self._run(remote, mode="u=rw,g=r,o=r")
super_run.assert_called_once()
self.assertEqual(remote.calls, [])
def test_failed_tree_check_falls_back_to_the_regular_copy(self):
remote = _FakeRemote(self._synced())
def failing(module_name=None, **kwargs):
if module_name == "ansible.legacy.file_checksums":
remote.calls.append(module_name)
return {"failed": True, "msg": "boom"}
return remote(module_name=module_name, **kwargs)
result = self._run(failing)
self.assertFalse(result["changed"])
self.assertIn("ansible.legacy.stat", remote.calls)
def test_source_symlink_skips_the_tree_check(self):
(self.src / "dangling").symlink_to("/nonexistent-target")
remote = _FakeRemote(self._synced())
with patch(
"plugins.action.stack_host_copy.CopyActionModule.run",
return_value={"changed": False},
) as super_run:
self._run(remote, local_follow=False)
super_run.assert_called_once()
self.assertEqual(remote.calls, [])
LEAVES: ClassVar[tuple[str, ...]] = (
".claude",
".claude/.cc-writes",
"nested/.claude",
"nested/.claude/.cc-writes",
)
def _add_empty_directories(self):
for rel in self.LEAVES:
(self.src / rel).mkdir(parents=True, exist_ok=True)
def _with_leaves(self, mode):
entries = self._synced()
entries.update({self.DEST + rel: _Dir(mode) for rel in self.LEAVES})
return entries
def _leaf_paths(self):
return sorted(self.DEST + rel for rel in self.LEAVES)
@staticmethod
def _directory_calls(remote):
return sorted(
args["path"]
for name, args in zip(remote.calls, remote.args, strict=True)
if name == "ansible.legacy.file" and args["state"] == "directory"
)
def test_existing_empty_directories_settle_in_one_remote_call(self):
self._add_empty_directories()
remote = _FakeRemote(self._with_leaves(0o755))
result = self._run(remote)
self.assertFalse(result["changed"])
self.assertEqual(remote.calls, ["ansible.legacy.file_checksums"])
self.assertEqual(
sorted(remote.args[0]["paths"]),
sorted([*self._synced(), *self._leaf_paths()]),
)
def test_unset_directory_mode_accepts_any_remote_directory_mode(self):
self._add_empty_directories()
remote = _FakeRemote(self._with_leaves(0o700))
regular = _FakeRemote(self._with_leaves(0o700))
settled = self._run(remote)
with patch.object(ActionModule, "_settled_tree", return_value=None):
copied = self._run(regular)
self.assertFalse(settled["changed"])
self.assertEqual(remote.calls, ["ansible.legacy.file_checksums"])
self.assertFalse(copied["changed"])
self.assertEqual(self._directory_calls(regular), self._leaf_paths())
def test_requested_directory_mode_settles_in_one_remote_call(self):
self._add_empty_directories()
remote = _FakeRemote(self._with_leaves(0o750))
result = self._run(remote, directory_mode="0750")
self.assertFalse(result["changed"])
self.assertEqual(remote.calls, ["ansible.legacy.file_checksums"])
def test_missing_remote_directory_is_ensured_in_one_batch(self):
self._add_empty_directories()
entries = self._with_leaves(0o755)
del entries[self.DEST + "nested/.claude/.cc-writes"]
remote = _FakeRemote(entries)
result = self._run(remote)
self.assertTrue(result["changed"])
self.assertEqual(remote.calls, self.BATCH)
self.assertEqual(
self._batch_args(remote)["directories"], ["nested/.claude/.cc-writes"]
)
self.assertIsNone(self._batch_args(remote)["directory_mode"])
def test_directory_mode_drift_is_settled_in_one_batch(self):
self._add_empty_directories()
remote = _FakeRemote(self._with_leaves(0o755))
result = self._run(remote, directory_mode="0750")
self.assertTrue(result["changed"])
self.assertEqual(remote.calls, self.BATCH)
self.assertEqual(self._batch_args(remote)["directories"], sorted(self.LEAVES))
self.assertEqual(self._batch_args(remote)["directory_mode"], "0750")
def test_file_in_place_of_a_directory_is_refused_then_fails_regularly(self):
self._add_empty_directories()
entries = self._with_leaves(0o755)
entries[self.DEST + ".claude/.cc-writes"] = (b"", 0o644)
remote = _FakeRemote(entries)
result = self._run(remote)
self.assertEqual(remote.calls[:2], self.BATCH)
self.assertTrue(result["failed"])
self.assertIn(self.DEST + ".claude/.cc-writes", self._directory_calls(remote))
def test_symbolic_directory_mode_with_empty_directories_skips_the_tree_check(self):
self._add_empty_directories()
remote = _FakeRemote(self._with_leaves(0o755))
with patch(
"plugins.action.stack_host_copy.CopyActionModule.run",
return_value={"changed": False},
) as super_run:
self._run(remote, directory_mode="u=rwx,g=rx,o=")
super_run.assert_called_once()
self.assertEqual(remote.calls, [])
def test_symbolic_directory_mode_without_empty_directories_keeps_the_check(self):
remote = _FakeRemote(self._synced())
result = self._run(remote, directory_mode="u=rwx,g=rx,o=")
self.assertFalse(result["changed"])
self.assertEqual(remote.calls, ["ansible.legacy.file_checksums"])
def test_integer_mode_is_compared_as_octal(self):
remote = _FakeRemote(self._synced())
result = self._run(remote, mode=0o644)
self.assertFalse(result["changed"])
self.assertEqual(remote.calls, ["ansible.legacy.file_checksums"])
def _add_bytecode_caches(self):
for rel in (
"__pycache__/mod.cpython-314.pyc",
"nested/__pycache__/mod.cpython-313.pyc",
):
(self.src / rel).parent.mkdir(parents=True, exist_ok=True)
(self.src / rel).write_bytes(b"bytecode")
def test_bytecode_caches_stay_out_of_the_tree_check(self):
self._add_bytecode_caches()
remote = _FakeRemote(self._synced())
result = self._run(remote)
self.assertFalse(result["changed"])
self.assertEqual(remote.calls, ["ansible.legacy.file_checksums"])
self.assertEqual(sorted(remote.args[0]["paths"]), sorted(self._synced()))
def test_bytecode_caches_are_not_transferred(self):
self._add_bytecode_caches()
entries = self._synced()
entries[self.DEST + "icon.png"] = (b"old", 0o644)
remote = _FakeRemote(entries)
result = self._run(remote)
self.assertTrue(result["changed"])
self.assertEqual(remote.calls, self.BATCH)
touched = [str(value) for args in remote.args for value in args.values()]
self.assertEqual([value for value in touched if "__pycache__" in value], [])
self.assertEqual(remote.transfers, [{"icon.png": b"png"}])
def test_bytecode_outside_a_cache_directory_stays_in_the_copy(self):
(self.src / "legacy.pyc").write_bytes(b"bytecode")
remote = _FakeRemote(self._synced())
result = self._run(remote)
self.assertTrue(result["changed"])
self.assertIn(self.DEST + "legacy.pyc", remote.args[0]["paths"])
self.assertEqual(remote.transfers, [{"legacy.pyc": b"bytecode"}])
def test_symlinked_root_passes_the_cache_filter_unchanged(self):
with tempfile.TemporaryDirectory() as links:
root = Path(links) / "root"
root.symlink_to(self.src)
filtered = _walk_dirs_without_bytecode_caches(str(root), local_follow=False)
self.assertEqual(filtered["symlinks"], (str(self.src), "root"))
class TestStackHostCopyFlatTree(_TreeHarness):
FILES: ClassVar[dict[str, bytes]] = {
f"slot-{index:02d}.png": f"palette-2-{index}".encode() for index in range(15)
}
def _stale(self, names):
entries = self._synced()
entries.update({self.DEST + name: (b"palette-1", 0o644) for name in names})
return entries
def _round_trips(self, entries, *, regular):
remote = _FakeRemote(entries)
if regular:
with patch.object(ActionModule, "_write_tree", return_value=None):
result = self._run(remote)
else:
result = self._run(remote)
self.assertTrue(result["changed"])
return {
"modules": len(remote.calls),
"transfers": len(remote.transfers),
"fixups": remote.fixups,
}
def test_all_files_changed_cost_one_transfer_and_two_module_calls(self):
stale = self._stale(self.FILES)
before = self._round_trips(stale, regular=True)
after = self._round_trips(stale, regular=False)
self.assertEqual(before, {"modules": 31, "transfers": 15, "fixups": 15})
self.assertEqual(after, {"modules": 2, "transfers": 1, "fixups": 1})
def test_one_changed_file_costs_one_transfer_and_two_module_calls(self):
stale = self._stale(["slot-07.png"])
before = self._round_trips(stale, regular=True)
after = self._round_trips(stale, regular=False)
self.assertEqual(before, {"modules": 31, "transfers": 1, "fixups": 1})
self.assertEqual(after, {"modules": 2, "transfers": 1, "fixups": 1})
def test_only_the_changed_file_is_sent(self):
remote = _FakeRemote(self._stale(["slot-07.png"]))
self._run(remote)
self.assertEqual(remote.transfers, [{"slot-07.png": b"palette-2-7"}])
self.assertEqual(remote.entries, self._synced())
def test_follow_without_symlinks_takes_the_batch(self):
remote = _FakeRemote(self._stale(["slot-00.png", "slot-01.png"]))
result = self._run(remote, follow=True)
self.assertTrue(result["changed"])
self.assertEqual(remote.calls, self.BATCH)
def test_follow_onto_a_symlinked_file_keeps_the_copy_semantics(self):
entries = self._synced()
entries[self.DEST + "slot-00.png"] = _Link(b"elsewhere")
remote = _FakeRemote(entries)
self._run(remote, follow=True)
self.assertEqual(remote.calls[:2], self.BATCH)
copies = self._copies(remote)
self.assertEqual(
[args["_original_basename"] for args in copies], ["slot-00.png"]
)
self.assertTrue(copies[0]["follow"])
class TestStackHostCopyWalkSwap(unittest.TestCase):
def _action(self):
task = _FakeTask(args={"src": "x", "dest": "/opt/compose/app/x"})
return _make_action(task, _FakeTemplar(is_stack_host=True))
def test_copy_action_walks_without_caches_only_during_the_run(self):
original = copy_action._walk_dirs
seen = []
def copy_run(**kwargs):
seen.append(copy_action._walk_dirs)
return {"changed": False}
with patch(
"plugins.action.stack_host_copy.CopyActionModule.run",
side_effect=copy_run,
):
self._action().run(task_vars={})
self.assertEqual(seen, [_walk_dirs_without_bytecode_caches])
self.assertIs(copy_action._walk_dirs, original)
def test_copy_action_walk_is_restored_when_the_copy_raises(self):
original = copy_action._walk_dirs
with (
patch(
"plugins.action.stack_host_copy.CopyActionModule.run",
side_effect=RuntimeError("boom"),
),
self.assertRaises(RuntimeError),
):
self._action().run(task_vars={})
self.assertIs(copy_action._walk_dirs, original)
if __name__ == "__main__":
unittest.main()