Files
computer-playbook/tests/unit/python/utils/update/test_repository.py
T
kevinveenbirkenbachandClaude Opus 5.5 89b95d751c fix(update): stop bumping pins of eol roles
The update bot opened a version PR for web-app-jira (#821) although the role is end of life: none of the updaters looked at the lifecycle. utils.update.base.is_maintained now answers that question once, and the collectors for image versions, declared version sources, pip pins, repository refs and the addon check skip a role whose lifecycle is eol.

The lint that requires every semver pin to be watched shares that scope, otherwise it would report the pins of the eol roles as unwatched. Every other stage, deprecated included, stays in the update scope.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 01:09:10 +02:00

282 lines
9.6 KiB
Python

from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from utils.cache.files import read_text
from utils.roles.mapping import ROLE_DIR_META_ADDONS, ROLE_FILE_META_SERVICES
from utils.update.repository import (
RepositoryRefEntry,
RepositoryRefUpdate,
apply_updates,
collect_entries,
suppressed_ref_lines,
update_config_refs,
walk_repo_ref_pairs,
)
class TestWalkRepoRefPairs(unittest.TestCase):
def test_finds_top_level_entity(self) -> None:
data = {
"bookwyrm": {
"repository": "https://github.com/bookwyrm-social/bookwyrm.git",
"ref": "v0.8.5",
},
}
pairs = list(walk_repo_ref_pairs(data, ()))
self.assertEqual(
pairs,
[
(
("bookwyrm",),
"https://github.com/bookwyrm-social/bookwyrm.git",
"v0.8.5",
),
],
)
def test_descends_into_nested_plugin_map(self) -> None:
data = {
"discourse": {
"plugins": {
"discourse-ldap-auth": {
"repository": "https://github.com/jonmbake/discourse-ldap-auth.git",
"ref": "master",
},
"discourse-prometheus": {
"repository": "https://github.com/discourse/discourse-prometheus.git",
"ref": "v1.0.0",
},
},
},
}
pairs = list(walk_repo_ref_pairs(data, ()))
entity_paths = {pair[0] for pair in pairs}
self.assertIn(("discourse", "plugins", "discourse-ldap-auth"), entity_paths)
self.assertIn(("discourse", "plugins", "discourse-prometheus"), entity_paths)
def test_skips_when_only_repository_or_only_ref(self) -> None:
data = {
"only-repo": {"repository": "https://example.invalid/x.git"},
"only-ref": {"ref": "v1.0.0"},
"both": {
"repository": "https://example.invalid/y.git",
"ref": "v2.0.0",
},
}
pairs = list(walk_repo_ref_pairs(data, ()))
self.assertEqual(len(pairs), 1)
self.assertEqual(pairs[0][0], ("both",))
def test_walks_lists(self) -> None:
data = {
"repos": [
{
"repository": "https://example.invalid/a.git",
"ref": "v1",
},
{
"repository": "https://example.invalid/b.git",
"ref": "v2",
},
],
}
pairs = list(walk_repo_ref_pairs(data, ()))
self.assertEqual(len(pairs), 2)
self.assertEqual(pairs[0][0], ("repos", "[0]"))
self.assertEqual(pairs[1][0], ("repos", "[1]"))
class TestUpdateConfigRefs(unittest.TestCase):
def test_rewrites_target_line_and_preserves_others(self) -> None:
original = """plugin-a:
repository: https://example.invalid/a.git
ref: v1.0.0
plugin-b:
repository: https://example.invalid/b.git
ref: v2.0.0
"""
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "services.yml"
config_path.write_text(original, encoding="utf-8")
changed = update_config_refs(config_path, {3: "v1.1.0"})
self.assertTrue(changed)
updated = read_text(str(config_path))
self.assertIn(" ref: v1.1.0\n", updated)
self.assertIn(" ref: v2.0.0\n", updated)
def test_preserves_quoting_and_trailing_comment(self) -> None:
original = ' ref: "v0.7.5" # pin\n'
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "services.yml"
config_path.write_text(original, encoding="utf-8")
changed = update_config_refs(config_path, {1: "v0.8.5"})
self.assertTrue(changed)
self.assertEqual(
read_text(str(config_path)),
' ref: "v0.8.5" # pin\n',
)
def test_no_op_when_target_value_already_matches(self) -> None:
original = " ref: v1.0.0\n"
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "services.yml"
config_path.write_text(original, encoding="utf-8")
changed = update_config_refs(config_path, {1: "v1.0.0"})
self.assertFalse(changed)
self.assertEqual(read_text(str(config_path)), original)
def test_skips_out_of_range_line_numbers(self) -> None:
original = " ref: v1.0.0\n"
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "services.yml"
config_path.write_text(original, encoding="utf-8")
changed = update_config_refs(config_path, {99: "v2.0.0"})
self.assertFalse(changed)
self.assertEqual(read_text(str(config_path)), original)
class TestSuppressedRefLines(unittest.TestCase):
def test_recognises_line_above_marker(self) -> None:
content = """plugin-a:
repository: https://example.invalid/a.git
# nocheck: repository-version
ref: v1.0.0
plugin-b:
repository: https://example.invalid/b.git
ref: v2.0.0
"""
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "services.yml"
config_path.write_text(content, encoding="utf-8")
suppressed = suppressed_ref_lines(config_path)
self.assertEqual(suppressed, {4})
class TestApplyUpdates(unittest.TestCase):
def test_groups_updates_per_config_file(self) -> None:
file_a_content = " ref: v1.0.0\n"
file_b_content = " ref: v2.0.0\n"
with tempfile.TemporaryDirectory() as tmpdir:
config_a = Path(tmpdir) / "a" / "services.yml"
config_b = Path(tmpdir) / "b" / "services.yml"
config_a.parent.mkdir()
config_b.parent.mkdir()
config_a.write_text(file_a_content, encoding="utf-8")
config_b.write_text(file_b_content, encoding="utf-8")
updates = [
RepositoryRefUpdate(
entry=RepositoryRefEntry(
role="role-a",
entity_path=("plugin-a",),
repository="https://example.invalid/a.git",
ref="v1.0.0",
config_path=config_a,
line=1,
),
latest="v1.1.0",
),
RepositoryRefUpdate(
entry=RepositoryRefEntry(
role="role-b",
entity_path=("plugin-b",),
repository="https://example.invalid/b.git",
ref="v2.0.0",
config_path=config_b,
line=1,
),
latest="v2.1.0",
),
]
changed = apply_updates(updates)
self.assertEqual(set(changed), {config_a, config_b})
self.assertEqual(read_text(str(config_a)), " ref: v1.1.0\n")
self.assertEqual(read_text(str(config_b)), " ref: v2.1.0\n")
class TestCollectEntriesCoversAddons(unittest.TestCase):
def _role(self, root: Path) -> Path:
role = root / "roles" / "web-app-example"
(role / ROLE_DIR_META_ADDONS).mkdir(parents=True)
return role
def test_addon_ref_is_collected_beside_the_service_ref(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
role = self._role(root)
(role / ROLE_FILE_META_SERVICES).write_text(
"app:\n repository: https://example.test/app.git\n ref: v1.0.0\n",
encoding="utf-8",
)
(role / ROLE_DIR_META_ADDONS / "plug.yml").write_text(
"config:\n repository: https://example.test/plug.git\n ref: v2.0.0\n",
encoding="utf-8",
)
by_ref = {entry.ref: entry for entry in collect_entries(root)}
self.assertEqual(set(by_ref), {"v1.0.0", "v2.0.0"})
addon = by_ref["v2.0.0"]
self.assertEqual(addon.entity_path[:2], ("addons", "plug"))
self.assertEqual(addon.config_path.name, "plug.yml")
def test_addon_ref_honours_its_own_suppression_marker(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
role = self._role(root)
(role / ROLE_DIR_META_ADDONS / "plug.yml").write_text(
"config:\n"
" repository: https://example.test/plug.git\n"
" # nocheck: repository-version\n"
" ref: v2.0.0\n",
encoding="utf-8",
)
self.assertEqual(collect_entries(root), [])
def test_refs_of_an_eol_role_are_not_collected(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
role = self._role(root)
(role / ROLE_FILE_META_SERVICES).write_text(
"example:\n"
" lifecycle: eol\n"
" repository: https://example.test/app.git\n"
" ref: v1.0.0\n",
encoding="utf-8",
)
(role / ROLE_DIR_META_ADDONS / "plug.yml").write_text(
"config:\n repository: https://example.test/plug.git\n ref: v2.0.0\n",
encoding="utf-8",
)
self.assertEqual(collect_entries(root), [])
if __name__ == "__main__":
unittest.main()