mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
Replace inline `networks:` blocks in every service of web-app-kix and
web-app-nextcloud with `{% include 'roles/sys-svc-container/templates/networks.yml.j2' %}`,
matching the convention the other ~70 roles already follow. The include
derives shared LDAP/DB/Ollama network attachments from `service_registry`
and the role's `services.*.shared` flags, so the hardcoded `openldap:`
attachment in kix's backend (and the missing shared-network handling in
nextcloud's proxy/HPB/whiteboard/recording services) become consistent.
Also fix a latent indent bug in web-app-nextcloud's HPB service: when
`NEXTCLOUD_HPB_NETWORK_MODE \!= 'bridge'`, `network_mode:` was rendered
at 2-space indent (service-name level) instead of 4 (service-property
level), which would have broken `docker compose config` in host/none
mode. Hidden until now by the `bridge` default.
Add `tests/integration/infrastructure/compose/test_network_includes.py`
to lock in the convention: forbids literal `networks:` keys in
compose.yml.j2 and requires exactly one top-level
`sys-svc-compose/networks.yml.j2` include per file (exempting templates
that exclusively use `network_mode:`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
116 lines
4.0 KiB
Python
116 lines
4.0 KiB
Python
"""Enforce the Compose network-include convention.
|
|
|
|
Each role's ``templates/compose.yml.j2`` MUST attach networks via the shared
|
|
Jinja includes so the shared LDAP/DB/Ollama networks stay consistent across
|
|
every role:
|
|
|
|
* Per service block ->
|
|
``{% include 'roles/sys-svc-container/templates/networks.yml.j2' %}``
|
|
* Once at the end (top-level ``networks:`` block) ->
|
|
``{% include 'roles/sys-svc-compose/templates/networks.yml.j2' %}``
|
|
|
|
Writing a literal ``networks:`` mapping key by hand is forbidden -- the
|
|
includes already derive the correct network name from the service registry
|
|
and the role's ``services.*.shared`` flags.
|
|
|
|
Templates whose only service uses host networking (``network_mode: host``)
|
|
are exempt from the top-level include requirement because no Docker
|
|
``networks:`` block applies in that case.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import unittest
|
|
|
|
from utils.cache.files import read_text
|
|
|
|
from . import PROJECT_ROOT
|
|
|
|
NETWORKS_KEY_RE = re.compile(r"^[ \t]*networks\s*:\s*(#.*)?$")
|
|
NETWORK_MODE_RE = re.compile(r"^[ \t]*network_mode\s*:")
|
|
JINJA_TAG_RE = re.compile(r"^\s*\{%")
|
|
COMMENT_RE = re.compile(r"^\s*#")
|
|
BLANK_RE = re.compile(r"^\s*$")
|
|
|
|
CONTAINER_INCLUDE = "{% include 'roles/sys-svc-container/templates/networks.yml.j2' %}"
|
|
COMPOSE_INCLUDE = "{% include 'roles/sys-svc-compose/templates/networks.yml.j2' %}"
|
|
|
|
|
|
def _scan(path) -> tuple[list[int], int, int, bool]:
|
|
"""Return (literal-line-numbers, compose-include-count,
|
|
container-include-count, has-network-mode)."""
|
|
text = read_text(str(path))
|
|
literal: list[int] = []
|
|
compose_count = 0
|
|
container_count = 0
|
|
has_network_mode = False
|
|
|
|
for idx, line in enumerate(text.splitlines(), start=1):
|
|
if BLANK_RE.match(line) or COMMENT_RE.match(line):
|
|
continue
|
|
if NETWORK_MODE_RE.match(line):
|
|
has_network_mode = True
|
|
if COMPOSE_INCLUDE in line:
|
|
compose_count += 1
|
|
continue
|
|
if CONTAINER_INCLUDE in line:
|
|
container_count += 1
|
|
continue
|
|
if JINJA_TAG_RE.match(line):
|
|
continue
|
|
if NETWORKS_KEY_RE.match(line):
|
|
literal.append(idx)
|
|
|
|
return literal, compose_count, container_count, has_network_mode
|
|
|
|
|
|
def _compose_templates() -> list:
|
|
return sorted((PROJECT_ROOT / "roles").glob("*/templates/compose.yml.j2"))
|
|
|
|
|
|
class TestComposeNetworkIncludes(unittest.TestCase):
|
|
def test_no_literal_networks_key(self) -> None:
|
|
offenders: list[str] = []
|
|
for path in _compose_templates():
|
|
literal, *_ = _scan(path)
|
|
if literal:
|
|
rel = path.relative_to(PROJECT_ROOT)
|
|
lines = ", ".join(str(n) for n in literal)
|
|
offenders.append(f"- {rel} (line(s) {lines})")
|
|
|
|
if offenders:
|
|
self.fail(
|
|
"Compose templates contain a literal `networks:` mapping key.\n"
|
|
"Each service block MUST attach networks via\n"
|
|
f" {CONTAINER_INCLUDE}\n"
|
|
"and the top-level `networks:` block MUST be rendered via\n"
|
|
f" {COMPOSE_INCLUDE}\n"
|
|
"Offenders:\n" + "\n".join(offenders)
|
|
)
|
|
|
|
def test_top_level_compose_networks_include_present_once(self) -> None:
|
|
offenders: list[str] = []
|
|
for path in _compose_templates():
|
|
_, compose_count, _, host_mode = _scan(path)
|
|
if host_mode and compose_count == 0:
|
|
continue
|
|
if compose_count != 1:
|
|
rel = path.relative_to(PROJECT_ROOT)
|
|
offenders.append(
|
|
f"- {rel} (found {compose_count} include(s); expected exactly 1)"
|
|
)
|
|
|
|
if offenders:
|
|
self.fail(
|
|
"Each compose.yml.j2 MUST contain exactly one\n"
|
|
f" {COMPOSE_INCLUDE}\n"
|
|
"to render the top-level `networks:` block (templates whose\n"
|
|
"only service uses `network_mode:` are exempt). Offenders:\n"
|
|
+ "\n".join(offenders)
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|