diff --git a/.github/workflows/entry-manual.yml b/.github/workflows/entry-manual.yml index de91eb190a..cdc35e0561 100644 --- a/.github/workflows/entry-manual.yml +++ b/.github/workflows/entry-manual.yml @@ -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 diff --git a/.github/workflows/test-deploy-server.yml b/.github/workflows/test-deploy-server.yml index 6a61530cf4..407f663438 100644 --- a/.github/workflows/test-deploy-server.yml +++ b/.github/workflows/test-deploy-server.yml @@ -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: diff --git a/.github/workflows/test-deploy-universal.yml b/.github/workflows/test-deploy-universal.yml index 1f6e96ac7e..2edb699697 100644 --- a/.github/workflows/test-deploy-universal.yml +++ b/.github/workflows/test-deploy-universal.yml @@ -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: diff --git a/.github/workflows/test-deploy-workstation.yml b/.github/workflows/test-deploy-workstation.yml index 3716110a1e..946d1f3247 100644 --- a/.github/workflows/test-deploy-workstation.yml +++ b/.github/workflows/test-deploy-workstation.yml @@ -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: diff --git a/cli/meta/applications/resolution/affected/__init__.py b/cli/meta/applications/resolution/affected/__init__.py new file mode 100644 index 0000000000..3f4caa752a --- /dev/null +++ b/cli/meta/applications/resolution/affected/__init__.py @@ -0,0 +1 @@ +# Intentionally empty; executable via: python -m cli.meta.applications.resolution.affected diff --git a/cli/meta/applications/resolution/affected/__main__.py b/cli/meta/applications/resolution/affected/__main__.py new file mode 100644 index 0000000000..7fce07d125 --- /dev/null +++ b/cli/meta/applications/resolution/affected/__main__.py @@ -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() diff --git a/docs/contributing/artefact/git/pipeline.md b/docs/contributing/artefact/git/pipeline.md index 5f61e5019e..f8981ee244 100644 --- a/docs/contributing/artefact/git/pipeline.md +++ b/docs/contributing/artefact/git/pipeline.md @@ -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//...`**: no whitelist is set, so the full deploy across the workflow's scope runs. + - **All changed paths under `roles//...`**: 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`. diff --git a/docs/contributing/tools/github/actions/workflows.md b/docs/contributing/tools/github/actions/workflows.md index c4ab12b4c0..83909cf3fc 100644 --- a/docs/contributing/tools/github/actions/workflows.md +++ b/docs/contributing/tools/github/actions/workflows.md @@ -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 | diff --git a/scripts/github/resolve_effective_whitelist.sh b/scripts/github/resolve_effective_whitelist.sh new file mode 100755 index 0000000000..1b54bdd49b --- /dev/null +++ b/scripts/github/resolve_effective_whitelist.sh @@ -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= 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}" diff --git a/scripts/meta/resolve/diff/affected_roles.sh b/scripts/meta/resolve/diff/affected_roles.sh new file mode 100755 index 0000000000..274749d3f2 --- /dev/null +++ b/scripts/meta/resolve/diff/affected_roles.sh @@ -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//, 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). +# [ ...] 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}" diff --git a/tests/unit/cli/meta/applications/resolution/affected/__init__.py b/tests/unit/cli/meta/applications/resolution/affected/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/cli/meta/applications/resolution/affected/test_main.py b/tests/unit/cli/meta/applications/resolution/affected/test_main.py new file mode 100644 index 0000000000..6a1d554480 --- /dev/null +++ b/tests/unit/cli/meta/applications/resolution/affected/test_main.py @@ -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()