mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
Workers failed 'docker build: no Dockerfile': the build context, compose file, .env and runner binary were staged on the stack host only while every node deploys its own node-local runner stack. Build and push the image once on the manager (RUNNER_IMAGE_REF via the swarm registry, 04_image.yml) and let workers pull it; render compose.yml (template with lstrip_blocks, matching the replaced stack_host_template action), .env and the extracted run.sh on every node. Add a node_local kwarg to the compose_networks/container_networks/compose_restart/compose_only lookups so this node-local compose stack renders a bridge default network (workers cannot create overlays), keeps restart: unless-stopped (ephemeral runners died after one job) and keeps container_name under swarm; default is unchanged for every other role. Treat build/install.stamp as stale when the venv is missing and exclude ./build from the nested-src copy, closing the nested-e2e ruamel failure class. Regression tests for the node_local render paths; the network include tests now accept lookup kwargs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
174 lines
6.4 KiB
Python
174 lines
6.4 KiB
Python
"""Enforce the Compose network-attachment convention.
|
|
|
|
Each role's ``templates/compose.yml.j2`` MUST attach networks via the shared
|
|
lookup plugins so the shared LDAP/DB/Ollama/Proxy networks stay consistent
|
|
across every role:
|
|
|
|
* Per service block ->
|
|
``{{ lookup('container_networks') | indent(4) }}`` (or matching indent)
|
|
* Once at the end (top-level ``networks:`` block) ->
|
|
``{{ lookup('compose_networks') }}``
|
|
|
|
Writing a literal ``networks:`` mapping key by hand is forbidden -- the
|
|
lookups already derive the correct network name from the service registry
|
|
and the role's ``networks.overlay`` metadata. The rule applies to
|
|
ANY ``*.yml.j2`` under ``roles/*/templates/`` because compose.yml.j2 often
|
|
``{% include %}``-pulls service definitions from sibling templates. The
|
|
legacy ``{% include 'roles/sys-svc-{compose,container}/templates/networks.yml.j2' %}``
|
|
form is also forbidden -- the underlying Jinja templates were replaced by
|
|
the lookup plugins.
|
|
|
|
Genuine exceptions (e.g. local-DB sidecars whose only attachment is
|
|
``- default``) declare a ``# nocheck: networks-literal`` marker on the
|
|
``networks:`` line itself.
|
|
|
|
Templates whose only service uses host networking (``network_mode: host``)
|
|
are exempt from the top-level lookup 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 utils.roles.mapping import ROLE_FILE_TEMPL_COMPOSE
|
|
|
|
from . import PROJECT_ROOT
|
|
|
|
NETWORKS_KEY_RE = re.compile(r"^[ \t]*networks\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*$")
|
|
NOCHECK_RE = re.compile(r"#\s*nocheck:\s*networks-literal\b")
|
|
|
|
CONTAINER_LOOKUP = "{{ lookup('container_networks') | indent(4) }}"
|
|
COMPOSE_LOOKUP = "{{ lookup('compose_networks') }}"
|
|
COMPOSE_LOOKUP_RE = re.compile(r"\{\{\s*lookup\(\s*'compose_networks'[^)]*\)\s*\}\}")
|
|
LEGACY_INCLUDE_RE = re.compile(
|
|
r"\{%\s*include\s+['\"]roles/sys-svc-(?:compose|container)"
|
|
r"/templates/networks\.yml\.j2['\"]\s*%\}"
|
|
)
|
|
|
|
|
|
def _scan_literals(path) -> list[int]:
|
|
text = read_text(str(path))
|
|
offenders: list[int] = []
|
|
for idx, line in enumerate(text.splitlines(), start=1):
|
|
if BLANK_RE.match(line) or COMMENT_RE.match(line):
|
|
continue
|
|
if JINJA_TAG_RE.match(line):
|
|
continue
|
|
if not NETWORKS_KEY_RE.match(line):
|
|
continue
|
|
if NOCHECK_RE.search(line):
|
|
continue
|
|
offenders.append(idx)
|
|
return offenders
|
|
|
|
|
|
def _scan_legacy_includes(path) -> list[int]:
|
|
text = read_text(str(path))
|
|
offenders: list[int] = []
|
|
for idx, line in enumerate(text.splitlines(), start=1):
|
|
if LEGACY_INCLUDE_RE.search(line):
|
|
offenders.append(idx)
|
|
return offenders
|
|
|
|
|
|
def _scan_compose_lookup_count(path) -> tuple[int, bool]:
|
|
text = read_text(str(path))
|
|
compose_count = 0
|
|
has_network_mode = False
|
|
for line in text.splitlines():
|
|
if NETWORK_MODE_RE.match(line):
|
|
has_network_mode = True
|
|
if COMPOSE_LOOKUP_RE.search(line):
|
|
compose_count += 1
|
|
return compose_count, has_network_mode
|
|
|
|
|
|
def _all_role_template_yml_j2() -> list:
|
|
return sorted((PROJECT_ROOT / "roles").glob("*/templates/**/*.yml.j2"))
|
|
|
|
|
|
def _compose_templates() -> list:
|
|
return sorted((PROJECT_ROOT / "roles").glob(f"*/{ROLE_FILE_TEMPL_COMPOSE}"))
|
|
|
|
|
|
class TestComposeNetworkLookups(unittest.TestCase):
|
|
def test_no_literal_networks_key_in_any_compose_template(self) -> None:
|
|
"""Detect literal `networks:` in any *.yml.j2 under roles/*/templates/.
|
|
|
|
Compose.yml.j2 often includes sibling templates, so the literal
|
|
rule applies to the entire tree, not only the canonical entry
|
|
file. Use `# nocheck: networks-literal` on the offending line to
|
|
declare a deliberate exception.
|
|
"""
|
|
offenders: list[str] = []
|
|
for path in _all_role_template_yml_j2():
|
|
lines = _scan_literals(path)
|
|
if lines:
|
|
rel = path.relative_to(PROJECT_ROOT)
|
|
joined = ", ".join(str(n) for n in lines)
|
|
offenders.append(f"- {rel} (line(s) {joined})")
|
|
|
|
if offenders:
|
|
self.fail(
|
|
"Templates contain a literal `networks:` mapping key.\n"
|
|
"Each service block MUST attach networks via\n"
|
|
f" {CONTAINER_LOOKUP}\n"
|
|
"and the top-level `networks:` block MUST be rendered via\n"
|
|
f" {COMPOSE_LOOKUP}\n"
|
|
"Declare deliberate exceptions with `# nocheck: networks-literal`.\n"
|
|
"Offenders:\n" + "\n".join(offenders)
|
|
)
|
|
|
|
def test_no_legacy_networks_include(self) -> None:
|
|
offenders: list[str] = []
|
|
for path in _all_role_template_yml_j2():
|
|
lines = _scan_legacy_includes(path)
|
|
if lines:
|
|
rel = path.relative_to(PROJECT_ROOT)
|
|
joined = ", ".join(str(n) for n in lines)
|
|
offenders.append(f"- {rel} (line(s) {joined})")
|
|
|
|
if offenders:
|
|
self.fail(
|
|
"Templates still use the legacy include form. Replace\n"
|
|
" {% include 'roles/sys-svc-compose/templates/networks.yml.j2' %}\n"
|
|
" -> "
|
|
f"{COMPOSE_LOOKUP}\n"
|
|
" {% include 'roles/sys-svc-container/templates/networks.yml.j2' %}\n"
|
|
" -> "
|
|
f"{CONTAINER_LOOKUP}\n"
|
|
"Offenders:\n" + "\n".join(offenders)
|
|
)
|
|
|
|
def test_top_level_compose_networks_lookup_present_once(self) -> None:
|
|
offenders: list[str] = []
|
|
for path in _compose_templates():
|
|
compose_count, host_mode = _scan_compose_lookup_count(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} lookup(s); expected exactly 1)"
|
|
)
|
|
|
|
if offenders:
|
|
self.fail(
|
|
"Each compose.yml.j2 MUST contain exactly one\n"
|
|
f" {COMPOSE_LOOKUP}\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()
|