mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
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:
1 parent
196c5bac28
commit
91fadbdcc5
12 files changed
+533
-12
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
Executable
+119
@@ -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()
|
||||
Reference in new issue
Block a user