feat(ci): diff-driven app whitelist for deploy tests

Narrow the test-deploy-{server,universal,workstation} matrix to roles
actually impacted by the branch's diff against origin/main, expanded
transitively over run_after + dependencies + shared services.

Why: full-matrix deploy CI on every PR is wasteful; explicit per-PR
whitelisting is error-prone. The resolver derives the affected set
automatically while remaining fail-safe.

Behaviour (in resolve_effective_whitelist.sh precedence order):
- whitelist input "__ALL__"  -> empty whitelist (force full deploy)
- whitelist input non-empty  -> verbatim
- whitelist input empty      -> diff vs origin/main
  - empty diff or any path outside roles/<role>/   -> __ALL__
  - all paths under roles/<role>/                  -> reverse closure

Fail-safe: any seed that is not modellable in CombinedResolver
(no application_id and not referenced by any role's run_after) and
any resolver runtime error MUST trigger a fall-back to __ALL__.
The closure never silently shrinks the deploy matrix.

test-deploy-local.yml is intentionally exempt (local/manual dispatch
keeps explicit whitelist semantics).

Documented in pipeline.md and workflows.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.7 committed 2026-05-05 22:59:45 +02:00
1 parent 196c5bac28
commit 91fadbdcc5
12 files changed
+533 -12

No files matched your search

+2 -2
View File
@@ -1,5 +1,5 @@
name: "⚡ CI: Manual"
run-name: "Manual CI triggered: ${{ inputs.distros }}; ${{ inputs.whitelist != '' && inputs.whitelist || 'all apps' }}"
run-name: "Manual CI triggered: ${{ inputs.distros }}; ${{ inputs.whitelist != '' && inputs.whitelist || 'diff-derived (origin/main)' }}"
concurrency:
group: global-ci-${{ github.repository }}-${{ github.ref_name }}
@@ -14,7 +14,7 @@ on:
default: "debian"
type: string
whitelist:
description: "Optional space-separated whitelist of app ids. Example: 'web-app-matomo web-app-chess'"
description: "Empty: diff vs origin/main. '__ALL__': force full deploy. Else: explicit space-separated app ids."
required: false
default: ""
type: string
+11 -2
View File
@@ -34,6 +34,8 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Resolve repository owner
shell: bash
@@ -54,12 +56,19 @@ jobs:
run: |
set -euo pipefail
make up
- name: Resolve effective whitelist (diff vs origin/main)
id: resolve_wl
shell: bash
env:
INPUT_WHITELIST: ${{ inputs.whitelist }}
run: ./scripts/github/resolve_effective_whitelist.sh
- name: Discover apps
id: discover
shell: bash
env:
WHITELIST: ${{ inputs.whitelist }}
WHITELIST: ${{ steps.resolve_wl.outputs.whitelist }}
run: ./scripts/github/output_apps.sh
server:
+10 -1
View File
@@ -34,6 +34,8 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Resolve repository owner
shell: bash
@@ -51,11 +53,18 @@ jobs:
set -euo pipefail
make up
- name: Resolve effective whitelist (diff vs origin/main)
id: resolve_wl
shell: bash
env:
INPUT_WHITELIST: ${{ inputs.whitelist }}
run: ./scripts/github/resolve_effective_whitelist.sh
- name: Discover apps
id: discover
shell: bash
env:
WHITELIST: ${{ inputs.whitelist }}
WHITELIST: ${{ steps.resolve_wl.outputs.whitelist }}
run: ./scripts/github/output_apps.sh
universal:
+10 -1
View File
@@ -34,6 +34,8 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Resolve repository owner
shell: bash
@@ -51,11 +53,18 @@ jobs:
set -euo pipefail
make up
- name: Resolve effective whitelist (diff vs origin/main)
id: resolve_wl
shell: bash
env:
INPUT_WHITELIST: ${{ inputs.whitelist }}
run: ./scripts/github/resolve_effective_whitelist.sh
- name: Discover apps
id: discover
shell: bash
env:
WHITELIST: ${{ inputs.whitelist }}
WHITELIST: ${{ steps.resolve_wl.outputs.whitelist }}
run: ./scripts/github/output_apps.sh
workstation:
@@ -0,0 +1 @@
# Intentionally empty; executable via: python -m cli.meta.applications.resolution.affected
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
Resolve the set of roles affected by a change to a given set of seed roles.
A role R is "affected" by seed S iff S appears in R's transitive prerequisite
closure (run_after + dependencies + services), as defined by
:class:`cli.meta.applications.resolution.combined.resolver.CombinedResolver`.
Usage:
python -m cli.meta.applications.resolution.affected --changed-roles ROLE [ROLE ...]
Output:
Whitespace-separated, sorted list of affected role names (single line),
including the seed roles themselves.
Exit codes:
0 Success. The closure was computed and printed.
1 Resolver error or unknown seed.
2 At least one seed role is non-modellable in the resolver
(no application_id and not referenced by any role's run_after).
Callers MUST treat this as "fall back to a full deploy" because
the resolver cannot guarantee that downstream consumers are
enumerated for that seed.
"""
from __future__ import annotations
import argparse
import sys
from typing import Iterable, List, Set
from cli.meta.applications.resolution.combined.errors import CombinedResolutionError
from cli.meta.applications.resolution.combined.repo_paths import roles_dir
from cli.meta.applications.resolution.combined.resolver import CombinedResolver
from cli.meta.applications.resolution.combined.role_introspection import (
has_application_id,
load_run_after,
)
EXIT_NON_MODELLABLE_SEED = 2
def _list_role_names() -> List[str]:
rdir = roles_dir()
if not rdir.is_dir():
return []
return sorted(p.name for p in rdir.iterdir() if p.is_dir())
def _non_modellable_seeds(seeds: Set[str], all_roles: List[str]) -> List[str]:
"""Return seeds that the resolver cannot reach as a downstream prereq.
A seed is reachable iff at least one of:
* it has ``application_id`` (then app-deps and shared-service edges
from any consumer can include it), or
* some role lists it in its ``run_after`` (then a run_after edge
from that consumer includes it).
Any other seed (e.g. a non-app helper role only pulled in via
``include_role`` from tasks) is invisible to the resolver. Returning
a partial closure for such a seed silently shrinks the deploy
matrix. Callers MUST fall back to a full deploy in that case.
"""
if not seeds:
return []
run_after_index: Set[str] = set()
for role in all_roles:
try:
for target in load_run_after(role):
run_after_index.add(target)
except CombinedResolutionError:
continue
out: List[str] = []
for seed in sorted(seeds):
if has_application_id(seed):
continue
if seed in run_after_index:
continue
out.append(seed)
return out
def affected_roles(changed: Iterable[str]) -> List[str]:
seeds: Set[str] = {r.strip() for r in changed if r and r.strip()}
if not seeds:
return []
all_roles = _list_role_names()
unknown = seeds - set(all_roles)
if unknown:
raise SystemExit(
f"Unknown role(s) passed via --changed-roles: {sorted(unknown)}"
)
non_modellable = _non_modellable_seeds(seeds, all_roles)
if non_modellable:
print(
"non-modellable seed(s) for resolver: "
f"{non_modellable}; caller must fall back to full deploy",
file=sys.stderr,
)
raise SystemExit(EXIT_NON_MODELLABLE_SEED)
resolver = CombinedResolver()
affected: Set[str] = set(seeds)
for role in all_roles:
if role in affected:
continue
prereqs = resolver.resolve(role)
if any(p in seeds for p in prereqs):
affected.add(role)
return sorted(affected)
def main() -> None:
parser = argparse.ArgumentParser(
description=(
"Print all roles whose transitive prerequisite closure "
"(run_after + dependencies + services) contains any of the "
"given seed roles. Seed roles themselves are included."
)
)
parser.add_argument(
"--changed-roles",
nargs="+",
required=True,
help="Seed role names (folder names under ./roles).",
)
args = parser.parse_args()
print(" ".join(affected_roles(args.changed_roles)))
if __name__ == "__main__":
main()
@@ -66,6 +66,22 @@ GHCR publication uses the workflow `GITHUB_TOKEN`; optional Docker Hub secrets a
The three deploy-test workflows listed in the `Infrastructure tests` table of [workflows.md](../../tools/github/actions/workflows.md) (server, universal, workstation scopes) run in parallel once DNS and mirroring have completed.
#### Diff-driven app selection 🎯
Each of [test-deploy-server.yml](../../../../.github/workflows/test-deploy-server.yml), [test-deploy-universal.yml](../../../../.github/workflows/test-deploy-universal.yml), and [test-deploy-workstation.yml](../../../../.github/workflows/test-deploy-workstation.yml) narrows its app matrix to the set of roles actually impacted by the branch's diff against `origin/main`. The `discover` job resolves an effective whitelist before [output_apps.sh](../../../../scripts/github/output_apps.sh) runs, using the following precedence:
1. **Sentinel `__ALL__` in the `whitelist` input** (case-insensitive). The diff logic MUST be skipped and an empty whitelist MUST be emitted, which deploys everything in the workflow's scope. This is the explicit opt-out from diff narrowing for manual dispatch.
2. **Any other non-empty `whitelist`** (forwarded from `entry-manual.yml` and similar). The explicit value MUST win over the diff and is passed through verbatim.
3. **Diff vs `origin/main`**, applied only when the `whitelist` input is empty:
- **No diff at all** OR **any changed path outside `roles/<role>/...`**: no whitelist is set, so the full deploy across the workflow's scope runs.
- **All changed paths under `roles/<role>/...`**: the changed roles become the seed set, and the whitelist is the transitive closure of those seeds expanded upwards over `run_after`, `dependencies`, and shared services. In other words, every role whose prerequisite set as defined by [combined resolver](../../../../cli/meta/applications/resolution/combined/resolver.py) contains one of the seeds is included, together with the seeds themselves.
The reverse closure is fail-safe: any seed that is not modellable in the resolver (no `application_id` and not referenced by any role's `run_after`) MUST trigger a fall-back to a full deploy, as MUST any resolver runtime error. The closure MUST NOT silently shrink the deploy matrix on partial information.
The PR-scope short-circuits in [scope.sh](../../../../scripts/meta/resolve/pr/scope.sh) (documentation-only, agent-only) still apply at the entry layer. They skip the orchestrator entirely and are independent of the diff-driven whitelist resolution above.
The reverse closure is implemented in [affected resolver](../../../../cli/meta/applications/resolution/affected/__main__.py) and invoked from [affected_roles.sh](../../../../scripts/meta/resolve/diff/affected_roles.sh). The workflow glue lives in [resolve_effective_whitelist.sh](../../../../scripts/github/resolve_effective_whitelist.sh). [test-deploy-local.yml](../../../../.github/workflows/test-deploy-local.yml) MUST NOT apply this resolution. Local dispatch keeps the explicit whitelist semantics.
### 10. Installation Tests 📦
The install-test workflows listed in the `Infrastructure tests` table of [workflows.md](../../tools/github/actions/workflows.md) run in parallel once `code-quality-gate` is green. All of them MUST pass `test-install-gate`.
@@ -1,6 +1,6 @@
# GitHub Actions 🎬
This page is the SPOT (single point of truth) for every GitHub Actions workflow defined under the [workflows directory](../../../../../.github/workflows/). It lists each workflow together with a short description, its triggers, and the inputs it accepts.
This page catalogs every GitHub Actions workflow defined under the [workflows directory](../../../../../.github/workflows/). It lists each workflow together with a short description, its triggers, and the inputs it accepts.
For the CI **flow** (orchestrator stages, gates, fork-PR handling) see [pipeline.md](../../../artefact/git/pipeline.md). For the **repository variables** that control CI behaviour see [configuration.md](configuration.md). For the **naming and shell-extraction conventions** that every workflow file MUST follow see [workflow.md](../../../artefact/files/github/workflow.md). For the **branch-prefix scopes** referenced by the PR entry workflow see [branch.md](../../../artefact/git/branch.md). For the **mirror architecture** referenced by the image workflows see [mirror.md](../../../artefact/image/mirror.md).
@@ -27,7 +27,7 @@ Trigger column legend: **auto** = fires automatically (push, pull_request, sched
|---|---|---|---|
| [entry-pull-request-change.yml](../../../../../.github/workflows/entry-pull-request-change.yml): `⚡ CI: Pull Request` | Detects PR scope from changed files + branch prefix, then conditionally calls the orchestrator. | auto (`pull_request`, `pull_request_target`: opened, synchronize, reopened, ready_for_review) | none |
| [entry-push-latest.yml](../../../../../.github/workflows/entry-push-latest.yml): `⚡ CI: Push` | Runs the orchestrator on pushes to `main`, `feature/**`, `hotfix/**`, `fix/**`, `chore/**`, `alert-autofix-*`; on tagged `main` it also calls `release-version.yml`. Respects the `CI_CANCEL_IN_PROGRESS` and `CI_RUN_ON_MAIN` repository variables, see [configuration.md](configuration.md). | auto (`push`) | none |
| [entry-manual.yml](../../../../../.github/workflows/entry-manual.yml): `⚡ CI: Manual` | Manual dispatch of the full orchestrator for a chosen distro set and app whitelist. | manual | `distros` (default `debian`), `whitelist` (optional) |
| [entry-manual.yml](../../../../../.github/workflows/entry-manual.yml): `⚡ CI: Manual` | Manual dispatch of the full orchestrator for a chosen distro set and app whitelist. The `whitelist` input accepts the `__ALL__` sentinel for forced full deploy; see the "Diff-driven app selection" subsection in [pipeline.md](../../../artefact/git/pipeline.md). | manual | `distros` (default `debian`), `whitelist` (optional; `__ALL__` forces full deploy, empty triggers diff derivation, any other value is verbatim) |
| [entry-pull-request-cancel.yml](../../../../../.github/workflows/entry-pull-request-cancel.yml): `🚫 Cancel: PR Runs on Close` | Cancels active workflow runs for a PR when it is closed or converted to draft. | auto (`pull_request_target`: closed, converted_to_draft) | none |
| [delete-branch.yml](../../../../../.github/workflows/delete-branch.yml): `🚫 Cancel: Runs on Branch Delete` | Enters the push-entry concurrency group for the deleted branch so that any in-flight run on it is cancelled; a fallback step cancels remaining runs via the API. | auto (`delete`) | none |
@@ -76,10 +76,10 @@ Trigger column legend: **auto** = fires automatically (push, pull_request, sched
| Workflow | Description | Trigger | Inputs |
|---|---|---|---|
| [test-dns.yml](../../../../../.github/workflows/test-dns.yml): `💬 Test: DNS` | Validates DNS resolution across target distributions. | reusable, manual | `distros` (required on call; default `debian` on manual) |
| [test-deploy-server.yml](../../../../../.github/workflows/test-deploy-server.yml): `📦 Test: Deploy (server)` | Deploy test for server and `web-*` roles. | reusable | `distros` (required), `whitelist` (optional) |
| [test-deploy-universal.yml](../../../../../.github/workflows/test-deploy-universal.yml): `📦 Test: Deploy (universal)` | Deploy test for shared system roles. | reusable | `distros` (required), `whitelist` (optional) |
| [test-deploy-workstation.yml](../../../../../.github/workflows/test-deploy-workstation.yml): `📦 Test: Deploy (workstation)` | Deploy test for workstation and `desk-*` roles. | reusable | `distros` (required), `whitelist` (optional) |
| [test-deploy-local.yml](../../../../../.github/workflows/test-deploy-local.yml): `📦 Test: Deploy (local)` | Local/self-hosted deploy test. Intended for `act` or manual dispatch, not part of the orchestrator. | manual | `test_deploy_type` (`server`/`universal`/`workstation`, default `server`), `distros` (default `debian`), `whitelist` (optional) |
| [test-deploy-server.yml](../../../../../.github/workflows/test-deploy-server.yml): `📦 Test: Deploy (server)` | Deploy test for server and `web-*` roles. When `whitelist` is empty, the discover job derives one from the branch's diff vs `origin/main`; see the "Diff-driven app selection" subsection in [pipeline.md](../../../artefact/git/pipeline.md). | reusable | `distros` (required), `whitelist` (optional; explicit value wins over diff) |
| [test-deploy-universal.yml](../../../../../.github/workflows/test-deploy-universal.yml): `📦 Test: Deploy (universal)` | Deploy test for shared system roles. Same diff-driven whitelist resolution as `test-deploy-server.yml`. | reusable | `distros` (required), `whitelist` (optional; explicit value wins over diff) |
| [test-deploy-workstation.yml](../../../../../.github/workflows/test-deploy-workstation.yml): `📦 Test: Deploy (workstation)` | Deploy test for workstation and `desk-*` roles. Same diff-driven whitelist resolution as `test-deploy-server.yml`. | reusable | `distros` (required), `whitelist` (optional; explicit value wins over diff) |
| [test-deploy-local.yml](../../../../../.github/workflows/test-deploy-local.yml): `📦 Test: Deploy (local)` | Local/self-hosted deploy test. Intended for `act` or manual dispatch, not part of the orchestrator. Does NOT apply the diff-driven whitelist resolution; the input is used verbatim. | manual | `test_deploy_type` (`server`/`universal`/`workstation`, default `server`), `distros` (default `debian`), `whitelist` (optional) |
| [test-install-make.yml](../../../../../.github/workflows/test-install-make.yml): `📥 Test: Install Make` | Validates the `make install` entry points. | reusable | none |
| [test-install-pkgmgr.yml](../../../../../.github/workflows/test-install-pkgmgr.yml): `📥 Test: Install Package Manager` | Validates installation via the upstream package manager. | reusable | none |
| [test-environment.yml](../../../../../.github/workflows/test-environment.yml): `💻 Test: Development Environment` | Builds and exercises the dev-runtime image matrix. | reusable, manual | none |
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# scripts/github/resolve_effective_whitelist.sh
#
# Resolve the effective WHITELIST that the discover step should pass to
# scripts/github/output_apps.sh, and write it to GITHUB_OUTPUT.
#
# Inputs via env:
# INPUT_WHITELIST caller-provided whitelist (space-separated).
# Three cases, in this order of precedence:
# * "__ALL__" (sentinel, case-insensitive): force
# full deploy across the workflow's scope. Skips
# the diff logic and emits an empty whitelist.
# * any other non-empty value: used verbatim.
# * empty: trigger the diff-based derivation.
#
# Output (GITHUB_OUTPUT):
# whitelist=<value> effective whitelist. An empty string means
# "no restriction; deploy everything in scope".
#
# Diff logic (only when INPUT_WHITELIST is empty):
# 1. Run scripts/meta/resolve/diff/affected_roles.sh.
# 2. If it returns "__ALL__", emit empty whitelist.
# 3. Otherwise emit the resolved (transitively expanded) role list.
set -euo pipefail
: "${GITHUB_OUTPUT:?Missing GITHUB_OUTPUT}"
input="${INPUT_WHITELIST:-}"
input_trimmed="${input//[[:space:]]/}"
shopt -s nocasematch
if [[ "${input_trimmed}" == "__ALL__" ]]; then
shopt -u nocasematch
printf 'whitelist=\n' >>"${GITHUB_OUTPUT}"
echo "Caller forced full deploy via '__ALL__' sentinel."
exit 0
fi
shopt -u nocasematch
if [[ -n "${input_trimmed}" ]]; then
printf 'whitelist=%s\n' "${input}" >>"${GITHUB_OUTPUT}"
echo "Using caller-supplied whitelist: ${input}"
exit 0
fi
resolved="$(./scripts/meta/resolve/diff/affected_roles.sh)"
if [[ "${resolved}" == "__ALL__" ]]; then
printf 'whitelist=\n' >>"${GITHUB_OUTPUT}"
echo "Diff vs origin/main implies full deploy (no whitelist)."
exit 0
fi
printf 'whitelist=%s\n' "${resolved}" >>"${GITHUB_OUTPUT}"
echo "Diff-derived whitelist: ${resolved}"
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# scripts/meta/resolve/diff/affected_roles.sh
#
# Compute which roles are affected by the current branch's diff against
# `origin/main`, expanded transitively over run_after + dependencies +
# services edges via cli.meta.applications.resolution.affected.
#
# Output (single line on stdout):
# __ALL__ full deploy needed. Emitted when:
# * the diff is empty, OR
# * any changed path lives outside
# roles/<role>/, OR
# * any seed role is non-modellable
# in the reverse resolver
# (resolver exit 2), OR
# * the resolver itself errors out
# (any other non-zero exit).
# <role-id> [<role-id> ...] restricted deploy set (whitelist).
#
# Pre-conditions:
# - The container started by `make up` is running (the Python resolver
# is invoked via `docker compose ... exec infinito`, identical to the
# pattern used in scripts/meta/resolve/apps.sh).
# - `git fetch origin main` is reachable (caller already did checkout).
#
# Exit codes:
# 0 in all defined output cases (including the fail-safe __ALL__).
# The script is fail-safe by design: any resolver failure widens the
# deploy matrix to "all", it never narrows it.
set -euo pipefail
PYTHON="${PYTHON:-python3}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
cd "$REPO_ROOT"
if [[ -f "scripts/meta/env/all.sh" ]]; then
# shellcheck source=scripts/meta/env/all.sh
source "scripts/meta/env/all.sh"
fi
compose_ci_exec() {
local -a compose_args=(docker compose --env-file env.ci)
if [[ -f "env.development" ]]; then
compose_args+=(--env-file env.development)
fi
compose_args+=(--profile ci exec -T infinito)
NIX_CONFIG="${NIX_CONFIG:-}" \
INFINITO_DISTRO="${INFINITO_DISTRO}" \
"${compose_args[@]}" "$@"
}
emit_all() {
echo "__ALL__"
exit 0
}
git fetch --quiet --no-tags --prune --depth=50 origin main >/dev/null 2>&1 ||
git fetch --quiet --no-tags --prune origin main
if base="$(git merge-base origin/main HEAD 2>/dev/null)"; then
mapfile -t changed_paths < <(git diff --name-only "${base}" HEAD --)
else
mapfile -t changed_paths < <(git diff --name-only origin/main HEAD --)
fi
if ((${#changed_paths[@]} == 0)); then
emit_all
fi
declare -A seed_roles=()
for path in "${changed_paths[@]}"; do
[[ -z "${path}" ]] && continue
if [[ "${path}" != roles/*/* ]]; then
emit_all
fi
role="${path#roles/}"
role="${role%%/*}"
[[ -z "${role}" ]] && emit_all
seed_roles["${role}"]=1
done
if ((${#seed_roles[@]} == 0)); then
emit_all
fi
mapfile -t seed_list < <(printf '%s\n' "${!seed_roles[@]}" | sort)
set +e
resolved="$(
compose_ci_exec \
"${PYTHON}" -m cli.meta.applications.resolution.affected \
--changed-roles "${seed_list[@]}"
)"
resolver_rc=$?
set -e
# Exit 2 means "non-modellable seed; fall back to full deploy".
# Any other non-zero is a resolver/runtime error; we still fail-safe to
# __ALL__ rather than risk silently shrinking the deploy matrix.
if ((resolver_rc != 0)); then
emit_all
fi
resolved="${resolved//$'\r'/}"
resolved="${resolved#"${resolved%%[![:space:]]*}"}"
resolved="${resolved%"${resolved##*[![:space:]]}"}"
if [[ -z "${resolved}" ]]; then
emit_all
fi
printf '%s\n' "${resolved}"
Whitespace-only changes.
@@ -0,0 +1,163 @@
# tests/unit/cli/meta/applications/resolution/affected/test_main.py
from __future__ import annotations
import io
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch
import yaml
from cli.meta.applications.resolution.affected import __main__ as affected_main
from cli.meta.applications.resolution.combined import repo_paths
def _mk_app_role(root: Path, role: str, app_id: str) -> None:
role_dir = root / "roles" / role
(role_dir / "meta").mkdir(parents=True, exist_ok=True)
(role_dir / "vars").mkdir(parents=True, exist_ok=True)
(role_dir / "vars" / "main.yml").write_text(
f"application_id: {app_id}\n", encoding="utf-8"
)
def _mk_non_app_role(root: Path, role: str) -> None:
"""Create a role folder without ``application_id``."""
(root / "roles" / role / "meta").mkdir(parents=True, exist_ok=True)
def _write_run_after(root: Path, role: str, run_after: list[str]) -> None:
p = root / "roles" / role / "meta" / "services.yml"
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(
yaml.safe_dump({role: {"run_after": run_after}}),
encoding="utf-8",
)
def _write_dependencies(root: Path, role: str, deps: list[str]) -> None:
p = root / "roles" / role / "meta" / "main.yml"
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(
yaml.safe_dump({"dependencies": deps}),
encoding="utf-8",
)
class TestAffected(unittest.TestCase):
def test_seed_only_when_no_consumers(self) -> None:
with tempfile.TemporaryDirectory() as td:
root = Path(td)
_mk_app_role(root, "leaf", "leaf")
_mk_app_role(root, "other", "other")
with patch.object(repo_paths, "repo_root_from_here", return_value=root):
self.assertEqual(
affected_main.affected_roles(["leaf"]),
["leaf"],
)
def test_run_after_consumer_is_included(self) -> None:
with tempfile.TemporaryDirectory() as td:
root = Path(td)
_mk_app_role(root, "leaf", "leaf")
_mk_app_role(root, "consumer", "consumer")
_write_run_after(root, "consumer", ["leaf"])
with patch.object(repo_paths, "repo_root_from_here", return_value=root):
self.assertEqual(
affected_main.affected_roles(["leaf"]),
["consumer", "leaf"],
)
def test_dependencies_consumer_is_included_transitively(self) -> None:
with tempfile.TemporaryDirectory() as td:
root = Path(td)
_mk_app_role(root, "leaf", "leaf")
_mk_app_role(root, "mid", "mid")
_mk_app_role(root, "top", "top")
_write_dependencies(root, "mid", ["leaf"])
_write_dependencies(root, "top", ["mid"])
with patch.object(repo_paths, "repo_root_from_here", return_value=root):
self.assertEqual(
affected_main.affected_roles(["leaf"]),
["leaf", "mid", "top"],
)
def test_shared_service_consumer_is_included(self) -> None:
with tempfile.TemporaryDirectory() as td:
root = Path(td)
_mk_app_role(root, "web-app-keycloak", "keycloak")
_mk_app_role(root, "web-app-consumer", "consumer")
(root / "roles" / "web-app-consumer" / "meta" / "services.yml").write_text(
yaml.safe_dump(
{
"consumer": {},
"oidc": {"enabled": True, "shared": True, "flavor": ""},
}
),
encoding="utf-8",
)
with patch.object(repo_paths, "repo_root_from_here", return_value=root):
got = affected_main.affected_roles(["web-app-keycloak"])
self.assertIn("web-app-keycloak", got)
self.assertIn("web-app-consumer", got)
def test_unknown_role_raises(self) -> None:
with tempfile.TemporaryDirectory() as td:
root = Path(td)
_mk_app_role(root, "leaf", "leaf")
with patch.object(repo_paths, "repo_root_from_here", return_value=root):
with self.assertRaises(SystemExit):
affected_main.affected_roles(["does-not-exist"])
def test_non_modellable_seed_exits_with_sentinel_code(self) -> None:
with tempfile.TemporaryDirectory() as td:
root = Path(td)
_mk_non_app_role(root, "sys-helper")
_mk_app_role(root, "consumer", "consumer")
with patch.object(repo_paths, "repo_root_from_here", return_value=root):
with self.assertRaises(SystemExit) as ctx:
affected_main.affected_roles(["sys-helper"])
self.assertEqual(
ctx.exception.code,
affected_main.EXIT_NON_MODELLABLE_SEED,
)
def test_non_app_seed_reachable_via_run_after_is_modellable(self) -> None:
with tempfile.TemporaryDirectory() as td:
root = Path(td)
_mk_non_app_role(root, "sys-helper")
_mk_app_role(root, "consumer", "consumer")
_write_run_after(root, "consumer", ["sys-helper"])
with patch.object(repo_paths, "repo_root_from_here", return_value=root):
got = affected_main.affected_roles(["sys-helper"])
self.assertIn("sys-helper", got)
self.assertIn("consumer", got)
def test_main_prints_space_separated_sorted(self) -> None:
with tempfile.TemporaryDirectory() as td:
root = Path(td)
_mk_app_role(root, "leaf", "leaf")
_mk_app_role(root, "consumer", "consumer")
_write_run_after(root, "consumer", ["leaf"])
with patch.object(repo_paths, "repo_root_from_here", return_value=root):
buf = io.StringIO()
with redirect_stdout(buf):
with patch(
"sys.argv",
["prog", "--changed-roles", "leaf"],
):
affected_main.main()
self.assertEqual(buf.getvalue().strip(), "consumer leaf")
if __name__ == "__main__":
unittest.main()