refactor(roles): declare every git URL in meta/services.yml + per-role REPOSITORY/REF vars

Co-locates every git-clone URL with its owning entity in the role's
`meta/services.yml` so the unified `image`/`version` ↔ `repository`/`ref`
naming convention covers secondary repos (plugins, build helpers,
bootstrap sources) just as cleanly as the primary entity source.

Migrations:
- sys-aur, pkgmgr (non-invokable bootstrap roles): `meta/services.yml`
  gains `repository:`/`ref:` on the role's own entity; `vars/main.yml`
  renders them via `lookup('config', role_name, …)` because these
  roles never set `application_id`.
- web-app-{akaunting,bigbluebutton,bluesky,discourse,roulette-wheel,
  taiga}: declare the secondary git URL on the most fitting entity
  (`akaunting` itself for the docker-helper repo; `view` for bluesky's
  social-app web client; `plugins.discourse-ldap-auth` for that
  Discourse plugin; `oidc` for taiga's OIDC plugin). Each role gains
  named `<ROLE>_REPOSITORY` / `<ROLE>_REF` constants in `vars/main.yml`
  so tasks and templates stay readable instead of inlining a
  `lookup('config', application_id, …)` string at every call site.
- svc-db-postgres `templates/Dockerfile.j2`: keep the `pgvector`
  extension clone but mark the line with `{# nocheck: repository-declared #}`
  since pgvector is an extension source, not the role's primary repo.

New integration test
`tests/integration/roles/meta/services/test_repository_declared_in_services.py`
walks every role's text files and fails if a literal `https://…/<…>.git`
URL is not declared as a `repository:` value somewhere in that role's
`meta/services.yml` (recursive — top-level entity, sub-entity, plugin
map). Legitimate exceptions opt out with `# nocheck: repository-declared`
on the same line or the preceding line; Jinja templates may use the
`{# … #}` form so a `\`-continued Dockerfile shell command stays valid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.7 committed 2026-05-15 20:12:07 +02:00
1 parent 7b15c61dfc
commit fd22d34559
24 files changed
+213 -19

No files matched your search

+2
View File
@@ -1,3 +1,5 @@
---
pkgmgr:
lifecycle: beta
repository: https://github.com/kevinveenbirkenbach/package-manager.git
ref: stable
+1 -1
View File
@@ -30,7 +30,7 @@
sys_util_git_pull_dest: "{{ PKGMGR_INSTALL_PATH }}"
sys_util_git_pull_version: "main"
sys_util_git_pull_depth: 1
sys_util_git_pull_pin_tag: "stable"
sys_util_git_pull_pin_tag: "{{ PKGMGR_REF }}"
sys_util_git_pull_remove_tags:
- latest
- stable
+2 -1
View File
@@ -2,7 +2,8 @@
# Variables for Kevin's Package Manager installation
# The Git repository URL for Kevin's Package Manager
PKGMGR_REPO_URL: "https://github.com/kevinveenbirkenbach/package-manager.git"
PKGMGR_REPO_URL: "{{ lookup('config', role_name, 'services.' ~ role_name ~ '.repository') }}"
PKGMGR_REF: "{{ lookup('config', role_name, 'services.' ~ role_name ~ '.ref') }}"
# The directory where executable aliases will be installed (ensure it's in your PATH)
PKGMGR_BINARIES_DIR: "/usr/local/bin"
@@ -6,6 +6,7 @@ RUN apt-get update \
build-essential \
git \
postgresql-server-dev-{{ POSTGRES_VERSION_MAJOR | default('all', true) }} \
{# nocheck: repository-declared — pgvector is an extension source, not the role's primary repo #}
&& git clone https://github.com/pgvector/pgvector.git /tmp/pgvector \
&& cd /tmp/pgvector \
&& make \
+2
View File
@@ -1,3 +1,5 @@
---
aur:
lifecycle: beta
repository: https://aur.archlinux.org/yay.git
ref: master
+2 -1
View File
@@ -36,7 +36,8 @@
become: true
become_user: "{{ AUR_BUILDER_USER }}"
git:
repo: https://aur.archlinux.org/yay.git # nocheck: url — git clone URL, not a browser link
repo: "{{ AUR_YAY_REPOSITORY }}"
version: "{{ AUR_YAY_REF }}"
dest: "/home/{{ AUR_BUILDER_USER }}/yay"
clone: true
update: true
+5
View File
@@ -0,0 +1,5 @@
---
# sys-aur is non-invokable, so it does not set `application_id` —
# the entity config is keyed by `role_name`.
AUR_YAY_REPOSITORY: "{{ lookup('config', role_name, 'services.aur.repository') }}"
AUR_YAY_REF: "{{ lookup('config', role_name, 'services.aur.ref') }}"
@@ -31,6 +31,8 @@ akaunting:
no_stop_required: true
image: docker.io/akaunting/akaunting
version: latest
repository: https://github.com/akaunting/docker.git
ref: master
name: akaunting
min_storage: 8GB
company:
+2 -2
View File
@@ -12,9 +12,9 @@
include_role:
name: sys-stk-full
vars:
docker_git_repository_address: "https://github.com/akaunting/docker.git"
docker_git_repository_address: "{{ AKAUNTING_REPOSITORY }}"
docker_git_repository_pull: true
docker_git_repository_version: "master"
docker_git_repository_version: "{{ AKAUNTING_REF }}"
docker_compose_flush_handlers: true
- name: "Akaunting | Create first-run marker to disable future setup"
+2
View File
@@ -9,6 +9,8 @@ database_password: "{{ lookup('config', application_id, 'credentials.database_pa
AKAUNTING_URL: "{{ lookup('tls', application_id, 'url.base') }}"
AKAUNTING_VERSION: "{{ lookup('config', application_id, 'services.akaunting.version') }}"
AKAUNTING_IMAGE: "{{ lookup('config', application_id, 'services.akaunting.image') }}"
AKAUNTING_REPOSITORY: "{{ lookup('config', application_id, 'services.akaunting.repository') }}"
AKAUNTING_REF: "{{ lookup('config', application_id, 'services.akaunting.ref') }}"
AKAUNTING_CONTAINER: "{{ lookup('config', application_id, 'services.akaunting.name') }}"
AKAUNTING_VOLUME: "{{ lookup('config', application_id, 'volumes.data') }}"
AKAUNTING_COMPANY_NAME: "{{ lookup('config', application_id, 'services.akaunting.company.name') }}"
@@ -5,8 +5,8 @@
vars:
docker_compose_flush_handlers: false
docker_git_repository_pull: true
docker_git_repository_address: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.repository') }}"
docker_git_repository_version: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.ref') }}"
docker_git_repository_address: "{{ BBB_REPOSITORY }}"
docker_git_repository_version: "{{ BBB_REF }}"
webserver_extra_configuration: >-
{{ lookup(
'ansible.builtin.template',
@@ -7,6 +7,10 @@ entity_name: "{{ application_id | get_entity_name }}"
domain: "{{ lookup('domain',application_id) }}"
http_port: "{{ lookup('config', application_id, 'services.bigbluebutton.ports.local.http') }}"
# Source
BBB_REPOSITORY: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.repository') }}"
BBB_REF: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.ref') }}"
# BigBlueButton
_BBB_COTURN_ROLE: "web-svc-coturn"
+2
View File
@@ -54,6 +54,8 @@ web:
# nocheck: playwright-service-flag — internal sub-component (multi-entity role)
view:
enabled: false
repository: https://github.com/bluesky-social/social-app.git
ref: "1.121.0"
ports:
local:
http: 8051
@@ -1,14 +1,9 @@
---
- name: clone social app repository
git:
# Pin to a tagged stable release; tracking `main` would silently
# break the broker's `BSKY_STORAGE` localStorage handoff every
# time upstream changes the persistent-state schema. Bump this
# pin deliberately when bumping social-app (and re-verify the
# handoff in Playwright).
repo: "https://github.com/bluesky-social/social-app.git"
repo: "{{ BLUESKY_SOCIAL_APP_REPOSITORY }}"
dest: "{{ BLUESKY_SOCIAL_APP_DIR }}"
version: "1.121.0"
version: "{{ BLUESKY_SOCIAL_APP_REF }}"
force: true
notify:
- compose up
+2
View File
@@ -6,6 +6,8 @@ application_id: "web-app-bluesky"
## Social App
BLUESKY_SOCIAL_APP_DIR: "{{ [ lookup('container', application_id, 'directories.services'), 'social-app' ] | path_join }}"
BLUESKY_SOCIAL_APP_REPOSITORY: "{{ lookup('config', application_id, 'services.view.repository') }}"
BLUESKY_SOCIAL_APP_REF: "{{ lookup('config', application_id, 'services.view.ref') }}"
BLUESKY_GEOLOCATION_PATH: "{{ [ BLUESKY_SOCIAL_APP_DIR,'src/geolocation/index.tsx' ] | path_join }}"
# This should be removed when the following issue is closed:
@@ -61,6 +61,10 @@ discourse:
enabled: true
discourse-akismet:
enabled: true
discourse-ldap-auth:
enabled: "{{ lookup('config', 'web-app-discourse', 'services.ldap.enabled') | bool }}"
repository: https://github.com/jonmbake/discourse-ldap-auth.git
ref: master
ports:
local:
http: 8020
@@ -138,7 +138,7 @@ hooks:
{% endfor %}
{% if DISCOURSE_LDAP_ENABLED | bool %}
- git clone --depth=1 https://github.com/jonmbake/discourse-ldap-auth.git
- git clone --depth=1 --branch {{ DISCOURSE_LDAP_AUTH_PLUGIN_REF }} {{ DISCOURSE_LDAP_AUTH_PLUGIN_REPOSITORY }}
{% endif %}
## Any custom commands to run after building
+2
View File
@@ -16,6 +16,8 @@ DISCOURSE_PG_NETWORK: "{{ ('svc-db-' ~ DISCOURSE_DATABASE_TYPE) | get_entity_nam
DISCOURSE_REDIS_HOST: "{{ application_id | get_entity_name }}-redis"
DISCOURSE_REPOSITORY_URL: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.repository') }}"
DISCOURSE_REF: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.ref') }}"
DISCOURSE_LDAP_AUTH_PLUGIN_REPOSITORY: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.plugins.discourse-ldap-auth.repository') }}"
DISCOURSE_LDAP_AUTH_PLUGIN_REF: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.plugins.discourse-ldap-auth.ref') }}"
## Database
DISCOURSE_DATABASE: "{{ lookup('database', application_id ) }}"
@@ -27,6 +27,8 @@ css:
roulette-wheel:
image: node
version: latest
repository: https://github.com/kevinveenbirkenbach/roulette-wheel.git
ref: master
min_storage: 1GB
cpus: 0.5
mem_reservation: 126m
+2 -2
View File
@@ -4,6 +4,6 @@
name: sys-stk-full
vars:
docker_compose_flush_handlers: true
docker_git_repository_address: "https://github.com/kevinveenbirkenbach/roulette-wheel.git"
docker_git_repository_address: "{{ ROULETTE_REPOSITORY }}"
docker_git_repository_pull: true
docker_git_repository_version: "master"
docker_git_repository_version: "{{ ROULETTE_REF }}"
@@ -5,4 +5,6 @@ entity_name: "{{ application_id | get_entity_name }}"
ROULETTE_WHEEL_APP_REL: "services/repository"
ROULETTE_IMAGE: "{{ lookup('config', application_id, 'services.roulette-wheel.image') }}"
ROULETTE_VERSION: "{{ lookup('config', application_id, 'services.roulette-wheel.version') }}"
ROULETTE_REPOSITORY: "{{ lookup('config', application_id, 'services.roulette-wheel.repository') }}"
ROULETTE_REF: "{{ lookup('config', application_id, 'services.roulette-wheel.ref') }}"
ROULETTE_IMAGE_CUSTOM: "{{ entity_name }}_custom"
+2
View File
@@ -2,6 +2,8 @@
oidc:
enabled: "{{ 'web-app-keycloak' in group_names }}"
shared: "{{ 'web-app-keycloak' in group_names }}"
repository: https://github.com/infinito-nexus/taiga-oidc.git
ref: c623b753047ce9923f3e38614d86e81fc5eb2bb1
ldap:
enabled: false # nocheck: dynamic-flag
shared: "{{ 'svc-db-openldap' in group_names }}"
+2 -2
View File
@@ -18,8 +18,8 @@ TAIGA_OIDC_ENABLED: "{{ lookup('config', application_id, 'services.oidc.enabled'
TAIGA_LDAP_ENABLED: "{{ lookup('config', application_id, 'services.ldap.enabled', false) }}"
TAIGA_PLUGINS_ENABLED: "{{ TAIGA_OIDC_ENABLED | bool or TAIGA_LDAP_ENABLED | bool }}"
TAIGA_CONFIG_APPEND_MARKER: "INFINITO_TAIGA_AUTH_CONFIG"
TAIGA_OIDC_PLUGIN_REPOSITORY: "https://github.com/taigaio/taiga-contrib-oidc-auth.git"
TAIGA_OIDC_PLUGIN_REF: "c623b753047ce9923f3e38614d86e81fc5eb2bb1"
TAIGA_OIDC_PLUGIN_REPOSITORY: "{{ lookup('config', application_id, 'services.oidc.repository') }}"
TAIGA_OIDC_PLUGIN_REF: "{{ lookup('config', application_id, 'services.oidc.ref') }}"
TAIGA_OIDC_BACKEND_PIP_SPEC: "{{ 'git+' ~ TAIGA_OIDC_PLUGIN_REPOSITORY ~ '@' ~ TAIGA_OIDC_PLUGIN_REF ~ '#egg=taiga-contrib-oidc-auth&subdirectory=back' }}"
TAIGA_OIDC_FRONTEND_BUILD_COMMAND: "./node_modules/.bin/gulp build"
TAIGA_LDAP_PIP_SPEC: "taiga-contrib-ldap-auth-ext==0.5.0"
@@ -0,0 +1,163 @@
"""Integration guard: every git-clone URL referenced inside a role
(Dockerfile, tasks, templates, files) MUST be declared as a
`repository:` value in that role's ``meta/services.yml``.
The same applies symmetrically to git refs: any `docker_git_repository_version`
or `--branch <ref>` (clone) value that ties to a declared
`repository:` MUST come from the same role's ``meta/services.yml``
under `ref:`. Hardcoded git URLs / refs in task files or Dockerfiles
silently bypass the entity contract enforced by
``test_repository_requires_ref.py`` and the unified naming convention.
Scope
=====
Per-role recursive scan of ``roles/<role>/`` for any literal HTTPS
git URL (matches ``https?://…/<…>.git``). Each match must appear
as a ``repository:`` value in ``roles/<role>/meta/services.yml``.
Detection
=========
* `https://github.com/foo/bar.git` literal in source → fail unless
declared in the role's services.yml.
* The same role MAY reference the URL in multiple files; each
reference is fine as long as the URL itself is declared once in
services.yml.
Opt-out
=======
A specific call site that legitimately uses a literal git URL (for
example a plugin / build-helper / extension repo) can add
``# nocheck: repository-declared`` on the same line as the URL OR
on the line immediately preceding it (so Dockerfiles with `\\`
line continuation can put the marker on a standalone comment line).
"""
from __future__ import annotations
import re
import unittest
from utils.cache.files import iter_project_files, read_text
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import ROLE_FILE_META_SERVICES
from . import PROJECT_ROOT
ROLES_DIR = PROJECT_ROOT / "roles"
# Matches HTTPS git URLs of the form `https://host/<…>/<repo>.git`.
# Requires at least one `/<path>/` segment after the host AND that
# `.git` ends the URL path (followed by URL-terminator or end of
# string). Without the path requirement, hostnames like
# `raw.githubusercontent.com` partial-match as `https://raw.git`.
_GIT_URL_RE = re.compile(
r"https?://[A-Za-z0-9.-]+(?:/[A-Za-z0-9._~-]+)+\.git(?=[\s\"'`<>?#/]|$)"
)
_OPTOUT_MARKER = "nocheck: repository-declared"
# File extensions worth scanning. Plus extensionless `Dockerfile`.
_SCAN_EXTENSIONS = (
".yml",
".yaml",
".j2",
".sh",
".py",
".rb",
".js",
".ts",
)
def _walk_repositories(node) -> set[str]:
"""Recursively collect every `repository:` string anywhere in the
services.yml tree (top-level entity, sub-entity, plugin map, etc.)."""
found: set[str] = set()
if isinstance(node, dict):
for key, value in node.items():
if key == "repository" and isinstance(value, str) and value:
found.add(value.strip())
else:
found |= _walk_repositories(value)
elif isinstance(node, list):
for item in node:
found |= _walk_repositories(item)
return found
def _collect_declared_repositories(services_path) -> set[str]:
data = load_yaml_any(str(services_path), default_if_missing=None)
return _walk_repositories(data)
def _iter_role_files(role_dir):
"""Every text file under the role that could plausibly contain a
literal git URL. Uses the project-wide path cache so the scan
re-uses what other tests already enumerated."""
role_prefix = str(role_dir) + "/"
yield from (
p
for p in iter_project_files(extensions=_SCAN_EXTENSIONS)
if p.startswith(role_prefix)
)
# `files/Dockerfile` (no extension) is not picked up by the
# extension filter; iterate the cache once more explicitly.
for p in iter_project_files():
if p.startswith(role_prefix) and p.endswith("/Dockerfile"):
yield p
class TestRepositoryDeclaredInServices(unittest.TestCase):
def test_git_urls_in_role_files_are_declared_in_services_yml(self):
offenders: list[str] = []
for role_dir in sorted(p for p in ROLES_DIR.iterdir() if p.is_dir()):
role_name = role_dir.name
services_path = role_dir / ROLE_FILE_META_SERVICES
declared = (
_collect_declared_repositories(services_path)
if services_path.is_file()
else set()
)
seen_paths: set[str] = set()
for fpath in _iter_role_files(role_dir):
if fpath in seen_paths:
continue
seen_paths.add(fpath)
# Skip the services.yml itself — the declaration is the
# authoritative source we are checking against.
if fpath.endswith("/" + ROLE_FILE_META_SERVICES):
continue
try:
text = read_text(fpath)
except (OSError, UnicodeDecodeError):
continue
if "https://" not in text and "http://" not in text:
continue
lines = text.splitlines()
for line_no, line in enumerate(lines, 1):
if _OPTOUT_MARKER in line:
continue
# Marker on the preceding line also counts (handy
# for Dockerfile `\\` continuations and similar).
if line_no >= 2 and _OPTOUT_MARKER in lines[line_no - 2]:
continue
for m in _GIT_URL_RE.finditer(line):
url = m.group(0)
if url in declared:
continue
rel = fpath.split(str(PROJECT_ROOT) + "/", 1)[-1]
offenders.append(
f"{role_name}: {rel}:{line_no} "
f"references `{url}` but it is not declared "
f"as `repository:` in "
f"{role_name}/{ROLE_FILE_META_SERVICES}"
)
if offenders:
self.fail(
f"{len(offenders)} hardcoded git URL(s) bypass the "
f"`meta/services.yml` repository declaration:\n"
+ "\n".join(f"- {o}" for o in offenders)
)