Files
computer-playbook/tests/unit/python/utils/roles/applications/test_variant_pins.py
T
kevinveenbirkenbachandClaude Opus 5 b83ab8e596 feat(variants): pin a partner's variant, and provision the OpenProject GitLab addon
Two concerns, staged together on request.

Variant pins
------------
A matrix round hands every role the round index and clamps to variant 0 when
the role has fewer entries, so a cross-role pairing holds only while two
independently maintained variant lists agree on an index, and it breaks
silently once the partner runs out of variants.

'# variant-pin: <role>#<index|reciprocal>' above a service entry states the
dependency instead of inheriting it. It is a comment, so it never reaches the
merged services map or host_vars the way a YAML key under the service would.
'#reciprocal' resolves to the partner's variant that switches this role back
on, so neither side tracks the other's index. Two roles pinning one partner to
different variants raises VariantPinConflictError before an inventory is
written.

web-app-openproject is the first user: a fourth variant carries the nextcloud
round, pinned reciprocally. The fourth round exists because folding it into
variant 2 next to gitlab was measured at 35.58 GB against the 32 GB
reservation cap, and it has to be the last round because a role with fewer
variants falls back to variant 0 in a later one.

Measured: the pin changes no role's index in any of the 14 rounds today, so
the mechanism sits in the deploy path without moving a single inventory.

OpenProject addons
------------------
c83c00da7d gave the role meta/addons/ and three task hooks, but the role never
included tasks/addons/ at all, so all three hooks were dead files. 05_addons.yml
now resolves the web host and container id and runs the hook of every addon
whose gate resolves true.

gitlab_integration is provisioned: a bot account, its API token and the plugin
setting carrying gitlab_user_id and webhook_secret. The GitLab-side webhook is
not created here and the hook prints the URL to register it against.

storages keeps an empty hook deliberately. web-app-nextcloud owns both halves
of that integration and runs eleven positions later in the deploy order, so at
this hook's dispatch point the storage it would touch does not exist yet.
openid_connect stays off: OIDC is Enterprise-gated at this version.

Probing the pinned openproject/openproject:17 image corrected two defects
before they could ship: the API token was minted without the name the script
fetched but never used, and the Playwright webhook probe omitted the
X-Gitlab-Event header that HookHandler dereferences before any auth check, so
the smoke request answered 500 where the spec demanded less than 500.

The addon specs now assert the integration rather than the route: storages
requires a registered storage addressing the partner Nextcloud, and
gitlab_integration requires the bot account the webhook authenticates as.

Deploying this needs a regenerated inventory: gitlab-web-app-openproject is a
new user key and users_generator pins it at inventory build time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 04:30:30 +02:00

194 lines
6.6 KiB
Python

"""Unit tests for ``# variant-pin:`` parsing and resolution."""
from __future__ import annotations
import textwrap
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from utils.roles.applications.variant_pins import (
VariantPinConflictError,
VariantPinError,
pins_of,
resolve_pins,
)
OPENPROJECT_VARIANTS = """\
---
- services:
nextcloud:
enabled: false
- services:
# variant-pin: web-app-nextcloud#{target}
nextcloud:
enabled: true
gitlab:
enabled: false
"""
NEXTCLOUD_SERVICES = """\
---
openproject:
enabled: "{{ 'web-app-openproject' in group_names }}"
matomo:
enabled: "{{ 'web-app-matomo' in group_names }}"
"""
GITLAB_VARIANTS = """\
---
- services:
# variant-pin: web-app-nextcloud#{target}
nextcloud:
enabled: true
"""
class VariantPinTestCase(unittest.TestCase):
def setUp(self) -> None:
self._tmp = TemporaryDirectory()
self.root = Path(self._tmp.name)
self.roles_dir = str(self.root / "roles")
self.write("web-app-nextcloud", "services.yml", NEXTCLOUD_SERVICES)
self.variants: dict[str, list[dict]] = {
"web-app-openproject": [{}, {}],
"web-app-nextcloud": [
{"services": {"openproject": {"enabled": False}}},
{"services": {"openproject": {"enabled": False}}},
{"services": {"openproject": {"enabled": False}}},
{"services": {"openproject": {"enabled": True}}},
],
}
self.addCleanup(self._tmp.cleanup)
def write(self, role: str, name: str, body: str) -> None:
path = self.root / "roles" / role / "meta" / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body)
def pin_openproject(self, target: str) -> None:
self.write(
"web-app-openproject",
"variants.yml",
OPENPROJECT_VARIANTS.format(target=target),
)
def pin_gitlab(self, target: str) -> None:
self.write(
"web-app-gitlab", "variants.yml", GITLAB_VARIANTS.format(target=target)
)
self.variants["web-app-gitlab"] = [{}]
def resolve(self, active: dict[str, int]) -> dict[str, int]:
return resolve_pins(
active, roles_dir=self.roles_dir, variants_per_app=self.variants
)
def test_the_marker_binds_to_the_entry_below_it(self) -> None:
self.pin_openproject("3")
self.assertEqual(
pins_of("web-app-openproject", roles_dir=self.roles_dir),
{1: {"web-app-nextcloud": "3"}},
)
def test_the_symbolic_target_survives_parsing(self) -> None:
self.pin_openproject("reciprocal")
self.assertEqual(
pins_of("web-app-openproject", roles_dir=self.roles_dir),
{1: {"web-app-nextcloud": "reciprocal"}},
)
def test_a_role_without_variants_contributes_nothing(self) -> None:
self.assertEqual(pins_of("web-app-absent", roles_dir=self.roles_dir), {})
def test_a_marker_without_an_entry_below_it_is_ignored(self) -> None:
self.write(
"web-app-openproject",
"variants.yml",
textwrap.dedent("""\
---
# variant-pin: web-app-nextcloud#3
- services:
nextcloud:
enabled: true
"""),
)
self.assertEqual(pins_of("web-app-openproject", roles_dir=self.roles_dir), {})
def test_an_active_variant_applies_its_index_pin(self) -> None:
self.pin_openproject("3")
self.assertEqual(
self.resolve({"web-app-openproject": 1}), {"web-app-nextcloud": 3}
)
def test_reciprocal_finds_the_variant_switching_this_role_on(self) -> None:
self.pin_openproject("reciprocal")
self.assertEqual(
self.resolve({"web-app-openproject": 1}), {"web-app-nextcloud": 3}
)
def test_a_variant_the_round_does_not_run_is_ignored(self) -> None:
self.pin_openproject("3")
self.assertEqual(self.resolve({"web-app-openproject": 0}), {})
def test_an_out_of_range_index_raises(self) -> None:
self.pin_openproject("3")
self.variants["web-app-nextcloud"] = self.variants["web-app-nextcloud"][:2]
with self.assertRaises(VariantPinError) as caught:
self.resolve({"web-app-openproject": 1})
self.assertIn("declares 2 variant(s)", str(caught.exception))
def test_reciprocal_without_a_candidate_raises(self) -> None:
self.pin_openproject("reciprocal")
self.variants["web-app-nextcloud"] = self.variants["web-app-nextcloud"][:2]
with self.assertRaises(VariantPinError) as caught:
self.resolve({"web-app-openproject": 1})
self.assertIn("reciprocal needs exactly one", str(caught.exception))
def test_reciprocal_with_two_candidates_raises(self) -> None:
self.pin_openproject("reciprocal")
self.variants["web-app-nextcloud"][0] = {
"services": {"openproject": {"enabled": True}}
}
with self.assertRaises(VariantPinError) as caught:
self.resolve({"web-app-openproject": 1})
self.assertIn("in 2 variants", str(caught.exception))
def test_reciprocal_without_a_back_reference_raises(self) -> None:
self.pin_openproject("reciprocal")
self.write(
"web-app-nextcloud", "services.yml", "---\nmatomo:\n enabled: true\n"
)
with self.assertRaises(VariantPinError) as caught:
self.resolve({"web-app-openproject": 1})
self.assertIn("declares no service gating on", str(caught.exception))
def test_two_roles_pinning_one_partner_differently_raises(self) -> None:
self.pin_openproject("3")
self.pin_gitlab("1")
with self.assertRaises(VariantPinConflictError) as caught:
self.resolve({"web-app-openproject": 1, "web-app-gitlab": 0})
self.assertIn(
"cannot deploy web-app-nextcloud in two variants", str(caught.exception)
)
def test_agreeing_pins_do_not_conflict(self) -> None:
self.pin_openproject("3")
self.pin_gitlab("3")
self.assertEqual(
self.resolve({"web-app-openproject": 1, "web-app-gitlab": 0}),
{"web-app-nextcloud": 3},
)
def test_an_index_pin_and_a_reciprocal_pin_agreeing_do_not_conflict(self) -> None:
self.pin_openproject("reciprocal")
self.pin_gitlab("3")
self.assertEqual(
self.resolve({"web-app-openproject": 1, "web-app-gitlab": 0}),
{"web-app-nextcloud": 3},
)
if __name__ == "__main__":
unittest.main()