.github/scripts/lib_dependency_scan.sh266 of 266 statements covered (100.00%).
coveredmissednever traced by Bash (not counted)A line ending in … continues the statement above it and shares its fate.
| 1 | #!/usr/bin/env bash | |
| 2 | # ============================================================================= | |
| 3 | # lib_dependency_scan.sh — sourceable functions for dependency-scan.sh | |
| 4 | # ============================================================================= | |
| 5 | # Extracted from dependency-scan.sh so BATS tests can exercise the real logic | |
| 6 | # without running the full scan (which needs pip, skopeo, helm, AWS creds). | |
| 7 | # | |
| 8 | # Usage: | |
| 9 | # source .github/scripts/lib_dependency_scan.sh | |
| 10 | # ============================================================================= | |
| 11 | ||
| 12 | # extract_direct_python_deps [pyproject_path] | |
| 13 | # | |
| 14 | # Reads the ``project.dependencies`` list and every list under | |
| 15 | # ``project.optional-dependencies`` from ``pyproject.toml`` and prints | |
| 16 | # one normalized package name per line (lowercased, ``_`` → ``-`` per | |
| 17 | # PEP 503). These are the packages we pin *directly* — everything else | |
| 18 | # is a transitive dependency whose version is controlled by something | |
| 19 | # we pin, and bumping it ourselves either does nothing (pip resolves | |
| 20 | # back to the same version) or breaks the resolver. | |
| 21 | # | |
| 22 | # Used by the python-drift path in ``dependency-scan.sh`` to filter | |
| 23 | # ``pip list --outdated`` down to names the operator can actually act | |
| 24 | # on, so the monthly report doesn't flag (for example) ``cattrs`` as | |
| 25 | # "outdated" when it's a jsii transitive we have no input on. | |
| 26 | # | |
| 27 | # Falls back silently to an empty list (prints nothing) if the file | |
| 28 | # isn't present or can't be parsed — the caller treats an empty list | |
| 29 | # as "no filter applied" rather than "no direct deps" so the scan | |
| 30 | # never silently hides genuine drift if the TOML parse breaks. | |
| 31 | # | |
| 32 | # Requires Python 3.11+ for ``tomllib`` (stdlib). The deps-scan | |
| 33 | # workflow already runs on 3.14. | |
| 34 | extract_direct_python_deps() { | |
| 35 | 32 | local pyproject="${1:-pyproject.toml}" |
| 36 | 34 | [ -f "$pyproject" ] || return 0 |
| 37 | 30 | python3 -c " |
| 38 | import re, sys, tomllib | |
| 39 | try: | |
| 40 | with open(sys.argv[1], 'rb') as f: | |
| 41 | data = tomllib.load(f) | |
| 42 | except Exception: | |
| 43 | sys.exit(0) | |
| 44 | ||
| 45 | project = data.get('project', {}) or {} | |
| 46 | deps = list(project.get('dependencies', []) or []) | |
| 47 | for group in (project.get('optional-dependencies', {}) or {}).values(): | |
| 48 | deps.extend(group or []) | |
| 49 | ||
| 50 | # Drop the project self-reference (``gco-cli[dev]`` etc.) before | |
| 51 | # normalising — pip doesn't report it in ``list --outdated`` anyway | |
| 52 | # but we also don't want to match on it. | |
| 53 | seen = set() | |
| 54 | for spec in deps: | |
| 55 | if not isinstance(spec, str): | |
| 56 | continue | |
| 57 | name = re.split(r'[\\[=!<>;~ ]', spec, 1)[0].strip() | |
| 58 | if not name or name.lower() == 'gco-cli': | |
| 59 | continue | |
| 60 | # PEP 503 normalisation: lowercase, ``_`` + ``.`` → ``-``. | |
| 61 | name = re.sub(r'[-_.]+', '-', name).lower() | |
| 62 | if name not in seen: | |
| 63 | seen.add(name) | |
| 64 | print(name) | |
| 65 | " "$pyproject" 2>/dev/null | |
| 66 | } | |
| 67 | ||
| 68 | # ============================================================================= | |
| 69 | # extract_python_extras [pyproject_path] | |
| 70 | # | |
| 71 | # Prints every ``[project.optional-dependencies]`` group name from | |
| 72 | # ``pyproject.toml``, one per line, in declaration order. The python-drift | |
| 73 | # path in ``dependency-scan.sh`` joins these into a single | |
| 74 | # ``pip install -e ".[group1,group2,...]"`` so that packages pinned *only* | |
| 75 | # inside an optional group (``aws-cdk-lib`` in ``cdk``, ``playwright`` in | |
| 76 | # ``diagrams``, ``mypy`` in ``typecheck``, ...) are actually present in the | |
| 77 | # scan venv. ``pip list --outdated`` can only report drift for installed | |
| 78 | # packages, so the previous base-only install silently dropped every | |
| 79 | # extras-only pin from the report even though | |
| 80 | # ``extract_direct_python_deps`` already includes those names in the | |
| 81 | # direct-pin filter. | |
| 82 | # | |
| 83 | # Falls back silently to an empty list (prints nothing) if the file is | |
| 84 | # missing or unparsable — the caller then falls back to a plain ``-e .`` | |
| 85 | # install, which is exactly the old behaviour, rather than dropping the | |
| 86 | # report section. | |
| 87 | extract_python_extras() { | |
| 88 | 31 | local pyproject="${1:-pyproject.toml}" |
| 89 | 33 | [ -f "$pyproject" ] || return 0 |
| 90 | 29 | python3 -c " |
| 91 | import sys, tomllib | |
| 92 | try: | |
| 93 | with open(sys.argv[1], 'rb') as f: | |
| 94 | data = tomllib.load(f) | |
| 95 | except Exception: | |
| 96 | sys.exit(0) | |
| 97 | groups = (data.get('project', {}) or {}).get('optional-dependencies', {}) or {} | |
| 98 | for group in groups: | |
| 99 | print(group) | |
| 100 | " "$pyproject" 2>/dev/null | |
| 101 | } | |
| 102 | ||
| 103 | # extract_build_system_pins [pyproject_path] | |
| 104 | # | |
| 105 | # Reads ``[build-system].requires`` and emits one ``name|version|raw`` | |
| 106 | # line per entry. ``version`` is filled only when the entry is a single | |
| 107 | # exact ``==X[.Y[.Z…]]`` pin with no extras, ranges, or markers; | |
| 108 | # otherwise it is left empty so the consistency check can flag the | |
| 109 | # entry while the PyPI drift check skips it. | |
| 110 | # | |
| 111 | # The build backend is a Python dependency too, but it is invisible to | |
| 112 | # ``pip list --outdated``: pip resolves it inside build isolation, not | |
| 113 | # in the scan venv (a Python 3.14 venv does not even ship setuptools). | |
| 114 | # Before this extractor existed the backend floated on a ``>=`` range | |
| 115 | # with nothing watching it — the exact failure mode the rest of this | |
| 116 | # library exists to catch. | |
| 117 | # | |
| 118 | # Prints nothing for a missing or unparseable file; the consistency | |
| 119 | # check treats that as a finding (fail-visible) rather than a pass. | |
| 120 | 17 | extract_build_system_pins() { |
| 121 | 65 | local pyproject="${1:-pyproject.toml}" |
| 122 | 70 | [ -f "$pyproject" ] || return 0 |
| 123 | 60 | python3 -c " |
| 124 | import re, sys, tomllib | |
| 125 | try: | |
| 126 | with open(sys.argv[1], 'rb') as f: | |
| 127 | data = tomllib.load(f) | |
| 128 | except Exception: | |
| 129 | sys.exit(0) | |
| 130 | requires = (data.get('build-system') or {}).get('requires') or [] | |
| 131 | for raw in requires: | |
| 132 | if not isinstance(raw, str) or not raw.strip(): | |
| 133 | continue | |
| 134 | entry = raw.strip() | |
| 135 | exact = re.fullmatch(r'([A-Za-z0-9][A-Za-z0-9._-]*)==([0-9]+(?:\.[0-9]+)*)', entry) | |
| 136 | name_match = re.match(r'[A-Za-z0-9][A-Za-z0-9._-]*', entry) | |
| 137 | # PEP 503 normalisation: lowercase, ``_`` + ``.`` → ``-``. | |
| 138 | name = re.sub(r'[-_.]+', '-', name_match.group(0)).lower() if name_match else '' | |
| 139 | version = exact.group(2) if exact else '' | |
| 140 | print(f'{name}|{version}|{entry}') | |
| 141 | " "$pyproject" 2>/dev/null | |
| 142 | } | |
| 143 | ||
| 144 | # parse_image_registry <image> | |
| 145 | # | |
| 146 | # Given a Docker image name (without tag), prints "registry|repo" where | |
| 147 | # registry is the domain and repo is the path within that registry. | |
| 148 | # | |
| 149 | # Examples: | |
| 150 | # parse_image_registry "nvcr.io/nvidia/cuda" → "nvcr.io|nvidia/cuda" | |
| 151 | # parse_image_registry "pytorch/pytorch" → "docker.io|pytorch/pytorch" | |
| 152 | # parse_image_registry "python" → "docker.io|library/python" | |
| 153 | # parse_image_registry "public.ecr.aws/eks/coredns" → "public.ecr.aws|eks/coredns" | |
| 154 | # parse_image_registry "docker.io/library/busybox" → "docker.io|library/busybox" | |
| 155 | # parse_image_registry "docker.io/python" → "docker.io|library/python" | |
| 156 | # | |
| 157 | # A first path component containing a dot, a port colon, or the literal | |
| 158 | # ``localhost`` is treated as a registry domain — the same heuristic container | |
| 159 | # runtimes apply — so a newly referenced registry needs no code change here. | |
| 160 | # The previous enumerated-registry list silently misparsed fully-qualified | |
| 161 | # Docker Hub references (``docker.io/library/busybox`` became repository | |
| 162 | # ``docker.io/library/busybox`` under a second ``docker.io``), which made | |
| 163 | # their tag lookups fail every month. | |
| 164 | 169 | parse_image_registry() { |
| 165 | 554 | local image="$1" |
| 166 | 554 | local registry="" repo="" first="" |
| 167 | 554 | case "$image" in |
| 168 | */*) | |
| 169 | 406 | first="${image%%/*}" |
| 170 | 406 | case "$first" in |
| 171 | *.*|*:*|localhost) | |
| 172 | 238 | registry="$first" |
| 173 | 238 | repo="${image#*/}" |
| 174 | # Docker Hub keeps official images under the implicit library/ | |
| 175 | # namespace; restore it for a fully-qualified single-segment repo. | |
| 176 | 252 | if [ "$registry" = "docker.io" ] && [ "${repo#*/}" = "$repo" ]; then |
| 177 | 2 | repo="library/$repo" |
| 178 | fi | |
| 179 | ;; | |
| 180 | *) | |
| 181 | 168 | registry="docker.io" |
| 182 | 168 | repo="$image" |
| 183 | ;; | |
| 184 | esac | |
| 185 | ;; | |
| 186 | *) | |
| 187 | 148 | registry="docker.io" |
| 188 | 148 | repo="library/$image" |
| 189 | ;; | |
| 190 | esac | |
| 191 | 554 | echo "${registry}|${repo}" |
| 192 | } | |
| 193 | ||
| 194 | # is_semver_tag <tag> | |
| 195 | # | |
| 196 | # Returns 0 (true) if the tag looks like a semver version (v1.2.3, 1.2, etc). | |
| 197 | # Returns 1 (false) otherwise. | |
| 198 | 8 | is_semver_tag() { |
| 199 | 556 | echo "$1" | grep -qE "^v?[0-9]+\.[0-9]+(\.[0-9]+)?" |
| 200 | } | |
| 201 | ||
| 202 | # is_project_image <image> | |
| 203 | # | |
| 204 | # Returns 0 (true) if the image is built by this project (gco/*). | |
| 205 | 5 | is_project_image() { |
| 206 | 498 | echo "$1" | grep -q "^gco/" |
| 207 | } | |
| 208 | ||
| 209 | # compare_semver <current> <candidate> | |
| 210 | # | |
| 211 | # Prints "newer" if candidate is strictly newer than current (by sort -V), | |
| 212 | # "same" if they're equal, "older" otherwise. | |
| 213 | 8 | compare_semver() { |
| 214 | 388 | local current="${1#v}" |
| 215 | 388 | local candidate="${2#v}" |
| 216 | 388 | if [ "$current" = "$candidate" ]; then |
| 217 | 231 | echo "same" |
| 218 | 231 | return |
| 219 | fi | |
| 220 | 157 | local newest |
| 221 | 628 | newest="$(printf '%s\n%s' "$current" "$candidate" | sort -V | tail -1)" |
| 222 | 157 | if [ "$newest" = "$candidate" ]; then |
| 223 | 131 | echo "newer" |
| 224 | else | |
| 225 | 26 | echo "older" |
| 226 | fi | |
| 227 | } | |
| 228 | ||
| 229 | # newer_same_variant_tag <current_tag> | |
| 230 | # | |
| 231 | # Reads a raw registry tag list on stdin (one tag per line) and prints the | |
| 232 | # newest tag in the *same variant family* as <current_tag> that is strictly | |
| 233 | # newer than it — or nothing when the pin is already the family's newest. | |
| 234 | # | |
| 235 | # A variant family shares the exact literal suffix after the leading numeric | |
| 236 | # version: ``24.01-py3`` compares only against ``NN[.NN…]-py3`` tags, | |
| 237 | # ``2.6.0-cuda12.6-cudnn9-runtime`` only against its identical variant | |
| 238 | # suffix, ``3.14.6-slim`` only against ``-slim`` tags, and a bare ``1.38.0`` | |
| 239 | # only against bare ``X.Y[.Z]`` tags. This mirrors the same-family scoping | |
| 240 | # the Bedrock model check uses: moving to a different variant (another CUDA | |
| 241 | # line, another base distro, dropping ``-slim``) is a human decision, not | |
| 242 | # drift, so it is never suggested. A leading ``v`` is accepted on either | |
| 243 | # side and ignored for comparison. | |
| 244 | # | |
| 245 | # The strict ``^v?X.Y.Z$`` filter this replaces matched nothing for every | |
| 246 | # suffix-tagged repository (NGC, GHCR Slurm, CUDA, PyTorch), which — under | |
| 247 | # ``pipefail`` — surfaced as a permanent "tag lookup failed" every month. | |
| 248 | # | |
| 249 | # Numeric components of five or more digits are treated as build/date | |
| 250 | # identifiers, not release numbers, and disqualify a candidate unless the | |
| 251 | # current pin itself carries one (a CalVer pin keeps comparing against | |
| 252 | # CalVer tags). Without this, ``alpine:3.21`` was "upgraded" to the | |
| 253 | # ``20260805`` date tag, ``kuberay/operator:v1.6.2`` to a ``9831375`` | |
| 254 | # commit-numbered tag, and ``ray:2.56.1`` to the ``2.57.0.397131`` nightly. | |
| 255 | 10 | newer_same_variant_tag() { |
| 256 | 222 | python3 -c " |
| 257 | import re, sys | |
| 258 | ||
| 259 | WIDE = 10000 # five digits: build number, date stamp, or commit counter | |
| 260 | ||
| 261 | current = sys.argv[1] | |
| 262 | match = re.match(r'^v?(\d+(?:\.\d+)*)(.*)\$', current) | |
| 263 | if not match: | |
| 264 | raise SystemExit(0) | |
| 265 | suffix = match.group(2) | |
| 266 | current_key = [int(part) for part in match.group(1).split('.')] | |
| 267 | current_is_calver = any(part >= WIDE for part in current_key) | |
| 268 | ||
| 269 | pattern = re.compile(r'^v?(\d+(?:\.\d+)*)' + re.escape(suffix) + r'\$') | |
| 270 | best_key = None | |
| 271 | best_tag = None | |
| 272 | for line in sys.stdin: | |
| 273 | tag = line.strip() | |
| 274 | if not tag: | |
| 275 | continue | |
| 276 | candidate = pattern.match(tag) | |
| 277 | if candidate is None: | |
| 278 | continue | |
| 279 | key = [int(part) for part in candidate.group(1).split('.')] | |
| 280 | if not current_is_calver and any(part >= WIDE for part in key): | |
| 281 | continue | |
| 282 | if key <= current_key: | |
| 283 | continue | |
| 284 | if best_key is None or key > best_key: | |
| 285 | best_key, best_tag = key, tag | |
| 286 | if best_tag: | |
| 287 | print(best_tag) | |
| 288 | " "$1" 2>/dev/null | |
| 289 | } | |
| 290 | ||
| 291 | # tag_listed <tag> <raw_tags> | |
| 292 | # | |
| 293 | # Whether <raw_tags> (a newline-separated registry tag list passed as one | |
| 294 | # argument, not on stdin) lists <tag> exactly, accepting a leading ``v`` on | |
| 295 | # either side. Exists because the obvious spelling — | |
| 296 | # ``printf '%s\n' "$raw_tags" | grep -qxF …`` — is wrong under ``pipefail`` | |
| 297 | # for large repositories: ``grep -q`` exits at the first match and closes | |
| 298 | # the pipe, ``printf`` takes SIGPIPE/EPIPE while still writing tag lists | |
| 299 | # bigger than the pipe buffer (python has ~3900 tags, tritonserver ~2600), | |
| 300 | # and the pipeline reports failure for a tag that is in fact listed. That | |
| 301 | # inverted into false "pinned tag is no longer listed" INCOMPLETE findings | |
| 302 | # in the 2026-09 scan. A herestring keeps grep's stdin writer-free, so | |
| 303 | # early exit has nothing to signal. | |
| 304 | 5 | tag_listed() { |
| 305 | 162 | local tag="$1" raw_tags="$2" |
| 306 | 162 | grep -qxF -e "$tag" -e "v${tag#v}" -e "${tag#v}" <<< "$raw_tags" |
| 307 | } | |
| 308 | ||
| 309 | # split_pinned_image_ref <repo:tag@sha256:digest> | |
| 310 | # | |
| 311 | # Validate and decompose a digest-pinned image reference, printing | |
| 312 | # ``repository|tag|digest`` on one line. Returns non-zero with no output for | |
| 313 | # anything that is not exactly the ``repo:tag@sha256:<64 hex>`` shape — | |
| 314 | # tag-only references, digest-only references, and malformed digests all | |
| 315 | # fail, so callers can gate the digest-freshness check on the return code. | |
| 316 | split_pinned_image_ref() { | |
| 317 | 59 | local ref="$1" |
| 318 | 59 | if ! [[ "$ref" =~ ^([^@:]+(:[0-9]+)?(/[^@:]+)*):([^@]+)@(sha256:[0-9a-f]{64})$ ]]; then |
| 319 | 4 | return 1 |
| 320 | fi | |
| 321 | 55 | printf '%s|%s|%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[4]}" "${BASH_REMATCH[5]}" |
| 322 | } | |
| 323 | ||
| 324 | # published_manifest_digest <repository:tag> | |
| 325 | # | |
| 326 | # Print the tag's current top-level manifest digest (``sha256:<64 hex>``) as | |
| 327 | # published by the registry. Hashes the raw manifest bytes rather than | |
| 328 | # selecting a platform child, because committed pins are multi-architecture | |
| 329 | # manifest-list digests. Returns non-zero with no output on transport | |
| 330 | # failure or an implausible hash, so callers distinguish "registry | |
| 331 | # unreachable" from "digest moved". | |
| 332 | published_manifest_digest() { | |
| 333 | 57 | local tagged_image="$1" manifest digest |
| 334 | 114 | manifest="$(mktemp)" |
| 335 | 57 | if ! skopeo inspect --raw "docker://${tagged_image}" > "$manifest" 2>/dev/null; then |
| 336 | 4 | rm -f "$manifest" |
| 337 | 4 | return 1 |
| 338 | fi | |
| 339 | 159 | digest="sha256:$(sha256sum "$manifest" | awk '{print $1}')" |
| 340 | 53 | rm -f "$manifest" |
| 341 | 53 | if ! [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then |
| 342 | 1 | return 1 |
| 343 | fi | |
| 344 | 52 | printf '%s\n' "$digest" |
| 345 | } | |
| 346 | ||
| 347 | # parse_accelerator_drift_count <json-summary-file> | |
| 348 | # | |
| 349 | # Validates the machine-readable output from | |
| 350 | # ``scripts/accelerator_catalog.py check-online --json-summary`` and prints | |
| 351 | # its exact non-negative ``drift_count``. The status/count relationship is | |
| 352 | # checked as well: ``current`` means zero and ``drift`` means one or more. | |
| 353 | # | |
| 354 | # Returns non-zero with no output for a missing file, malformed JSON, a | |
| 355 | # missing/invalid count, or an inconsistent status. The dependency-scan | |
| 356 | # driver turns any such parser failure into one operational finding so a | |
| 357 | # broken online scan can never be reported as clean. | |
| 358 | parse_accelerator_drift_count() { | |
| 359 | 25 | local summary_file="$1" |
| 360 | 25 | [ -f "$summary_file" ] || return 1 |
| 361 | 25 | python3 -c " |
| 362 | import json, sys | |
| 363 | try: | |
| 364 | with open(sys.argv[1]) as handle: | |
| 365 | summary = json.load(handle) | |
| 366 | except (OSError, json.JSONDecodeError): | |
| 367 | raise SystemExit(1) | |
| 368 | if not isinstance(summary, dict): | |
| 369 | raise SystemExit(1) | |
| 370 | count = summary.get('drift_count') | |
| 371 | status = summary.get('status') | |
| 372 | if isinstance(count, bool) or not isinstance(count, int) or count < 0: | |
| 373 | raise SystemExit(1) | |
| 374 | if status not in {'current', 'drift'}: | |
| 375 | raise SystemExit(1) | |
| 376 | if (status == 'current') != (count == 0): | |
| 377 | raise SystemExit(1) | |
| 378 | print(count) | |
| 379 | " "$summary_file" 2>/dev/null | |
| 380 | } | |
| 381 | ||
| 382 | # extract_aurora_versions <file> | |
| 383 | # | |
| 384 | # Prints the pinned Aurora PostgreSQL engine version (``major.minor``) from | |
| 385 | # the constants module, importing it when possible and falling back to a | |
| 386 | # direct regex over ``constants.py``. The pin is a plain version string | |
| 387 | # applied through ``rds.AuroraPostgresEngineVersion.of()`` — there is no CDK | |
| 388 | # enum member to scan for. Output is validated to ``X.Y[.Z]`` shape so a | |
| 389 | # refactor of the constant can never leak a non-version into the RDS query. | |
| 390 | 16 | extract_aurora_versions() { |
| 391 | 59 | local file="${1:-gco/stacks/regional_stack.py}" |
| 392 | 59 | python3 -c " |
| 393 | import re, sys | |
| 394 | value = None | |
| 395 | try: | |
| 396 | from gco.stacks.constants import AURORA_POSTGRES_VERSION | |
| 397 | value = AURORA_POSTGRES_VERSION | |
| 398 | except ImportError: | |
| 399 | import os | |
| 400 | constants_path = os.path.join(os.path.dirname(sys.argv[1]), 'constants.py') | |
| 401 | if os.path.exists(constants_path): | |
| 402 | with open(constants_path) as f: | |
| 403 | text = f.read() | |
| 404 | m = re.search(r'^AURORA_POSTGRES_VERSION\s*=\s*\"([^\"]+)\"', text, re.M) | |
| 405 | if m: | |
| 406 | value = m.group(1) | |
| 407 | if value and re.fullmatch(r'\d+\.\d+(?:\.\d+)?', value): | |
| 408 | print(value) | |
| 409 | " "$file" 2>/dev/null | sort -V | |
| 410 | } | |
| 411 | ||
| 412 | # extract_eks_addons <file> | |
| 413 | # | |
| 414 | # Extracts EKS addon name|version pairs from the constants module. | |
| 415 | # Falls back to reading constants.py directly if the module can't be imported. | |
| 416 | # Prints one "addon_name|addon_version" per line. | |
| 417 | 16 | extract_eks_addons() { |
| 418 | 57 | local file="${1:-gco/stacks/regional_stack.py}" |
| 419 | 59 | python3 -c " |
| 420 | import sys | |
| 421 | try: | |
| 422 | from gco.stacks.constants import ( | |
| 423 | EKS_ADDON_POD_IDENTITY_AGENT, | |
| 424 | EKS_ADDON_METRICS_SERVER, | |
| 425 | EKS_ADDON_EFS_CSI_DRIVER, | |
| 426 | EKS_ADDON_CLOUDWATCH_OBSERVABILITY, | |
| 427 | EKS_ADDON_FSX_CSI_DRIVER, | |
| 428 | ) | |
| 429 | addons = [ | |
| 430 | ('eks-pod-identity-agent', EKS_ADDON_POD_IDENTITY_AGENT), | |
| 431 | ('metrics-server', EKS_ADDON_METRICS_SERVER), | |
| 432 | ('aws-efs-csi-driver', EKS_ADDON_EFS_CSI_DRIVER), | |
| 433 | ('amazon-cloudwatch-observability', EKS_ADDON_CLOUDWATCH_OBSERVABILITY), | |
| 434 | ('aws-fsx-csi-driver', EKS_ADDON_FSX_CSI_DRIVER), | |
| 435 | ] | |
| 436 | for name, version in addons: | |
| 437 | print(f'{name}|{version}') | |
| 438 | except ImportError: | |
| 439 | # Fallback: read constants.py directly | |
| 440 | import re, os | |
| 441 | constants_path = os.path.join(os.path.dirname(sys.argv[1]), 'constants.py') | |
| 442 | if os.path.exists(constants_path): | |
| 443 | with open(constants_path) as f: | |
| 444 | text = f.read() | |
| 445 | # Map constant names to addon names | |
| 446 | mapping = { | |
| 447 | 'EKS_ADDON_POD_IDENTITY_AGENT': 'eks-pod-identity-agent', | |
| 448 | 'EKS_ADDON_METRICS_SERVER': 'metrics-server', | |
| 449 | 'EKS_ADDON_EFS_CSI_DRIVER': 'aws-efs-csi-driver', | |
| 450 | 'EKS_ADDON_CLOUDWATCH_OBSERVABILITY': 'amazon-cloudwatch-observability', | |
| 451 | 'EKS_ADDON_FSX_CSI_DRIVER': 'aws-fsx-csi-driver', | |
| 452 | } | |
| 453 | for const_name, addon_name in mapping.items(): | |
| 454 | m = re.search(const_name + r'\s*=\s*\"([^\"]+)\"', text) | |
| 455 | if m: | |
| 456 | print(f'{addon_name}|{m.group(1)}') | |
| 457 | else: | |
| 458 | # Last resort: scan the file for inline addon_name/addon_version pairs | |
| 459 | with open(sys.argv[1]) as f: | |
| 460 | text = f.read() | |
| 461 | for m in re.finditer(r'addon_name=\"([^\"]+)\".*?addon_version=\"([^\"]+)\"', text, re.DOTALL): | |
| 462 | print(f'{m.group(1)}|{m.group(2)}') | |
| 463 | " "$file" 2>/dev/null | |
| 464 | } | |
| 465 | ||
| 466 | # extract_helm_charts [charts_yaml_path] | |
| 467 | # | |
| 468 | # Reads ``lambda/helm-installer/charts.yaml`` and prints one JSON object per | |
| 469 | # chart entry: ``{name, repo_url, chart, version, use_oci}``. Extracted from | |
| 470 | # dependency-scan.sh so BATS can exercise the real charts.yaml parse — the | |
| 471 | # driver sources this helper and pipes its output into the version-drift loop | |
| 472 | # (``helm search repo`` / ``helm show chart`` per entry). Because it iterates | |
| 473 | # every entry, a newly-added chart (e.g. kube-prometheus-stack) is picked up | |
| 474 | # automatically with no change here. | |
| 475 | # | |
| 476 | # Prints nothing (exit 0) when the file is missing or unparseable, matching the | |
| 477 | # other extractors in this file so the caller treats empty as "skip". | |
| 478 | 16 | extract_helm_charts() { |
| 479 | 63 | local file="${1:-lambda/helm-installer/charts.yaml}" |
| 480 | 66 | [ -f "$file" ] || return 0 |
| 481 | 60 | python3 -c " |
| 482 | import json, sys, yaml | |
| 483 | try: | |
| 484 | with open(sys.argv[1]) as f: | |
| 485 | data = yaml.safe_load(f) | |
| 486 | except Exception: | |
| 487 | sys.exit(0) | |
| 488 | for name, cfg in (data or {}).get('charts', {}).items(): | |
| 489 | cfg = cfg or {} | |
| 490 | print(json.dumps({ | |
| 491 | 'name': name, | |
| 492 | 'repo_url': cfg.get('repo_url', ''), | |
| 493 | 'chart': cfg.get('chart', ''), | |
| 494 | 'version': cfg.get('version', ''), | |
| 495 | 'use_oci': cfg.get('use_oci', False), | |
| 496 | })) | |
| 497 | " "$file" 2>/dev/null | |
| 498 | } | |
| 499 | ||
| 500 | # extract_k8s_version [cdk_json_path] | |
| 501 | # | |
| 502 | # Reads the kubernetes_version from cdk.json. Falls back to "1.36". | |
| 503 | 16 | extract_k8s_version() { |
| 504 | 104 | local cdk="${1:-cdk.json}" |
| 505 | 109 | python3 -c "import json; print(json.load(open('$cdk'))['context']['kubernetes_version'])" 2>/dev/null || echo "1.36" |
| 506 | } | |
| 507 | ||
| 508 | # extract_dockerfile_pins <dockerfile> | |
| 509 | # | |
| 510 | # Parses ``ARG <NAME>=<VALUE>`` lines from the given Dockerfile and emits | |
| 511 | # ``NAME|VALUE`` for each pin we care about. The allowlist below is | |
| 512 | # intentional — random build-time ARGs (e.g. ``BUILD_DATE``) would add | |
| 513 | # noise to the drift report. | |
| 514 | # | |
| 515 | # The line-anchor (``^ARG``) and single-line Python regex avoid matching | |
| 516 | # ``ARG`` appearing inside a comment or a RUN heredoc. Leading whitespace | |
| 517 | # is permitted so a future ``RUN --mount=…`` or multi-stage FROM line | |
| 518 | # doesn't break the scan silently. | |
| 519 | # | |
| 520 | # Example output for Dockerfile.dev: | |
| 521 | # | |
| 522 | # NODE_VERSION|v24.18.0 | |
| 523 | # NPM_VERSION|11.14.1 | |
| 524 | # CDK_VERSION|2.1120.0 | |
| 525 | # KUBECTL_VERSION|v1.36.1 | |
| 526 | # AWSCLI_VERSION|2.34.42 | |
| 527 | # DOCKER_VERSION|29.4.2 | |
| 528 | # BUILDX_VERSION|v0.35.0 | |
| 529 | 17 | extract_dockerfile_pins() { |
| 530 | 96 | local file="${1:-Dockerfile.dev}" |
| 531 | 99 | [ -f "$file" ] || return 0 |
| 532 | 93 | python3 -c " |
| 533 | import re, sys | |
| 534 | allowlist = { | |
| 535 | 'NODE_VERSION', | |
| 536 | 'NPM_VERSION', | |
| 537 | 'CDK_VERSION', | |
| 538 | 'KUBECTL_VERSION', | |
| 539 | 'AWSCLI_VERSION', | |
| 540 | 'DOCKER_VERSION', | |
| 541 | 'BUILDX_VERSION', | |
| 542 | 'UV_VERSION', | |
| 543 | } | |
| 544 | with open(sys.argv[1]) as f: | |
| 545 | for line in f: | |
| 546 | # Strip trailing inline comments but keep the ARG value itself. | |
| 547 | stripped = line.split('#', 1)[0] | |
| 548 | m = re.match(r'^\s*ARG\s+([A-Z_][A-Z0-9_]*)=(\S+)\s*$', stripped) | |
| 549 | if not m: | |
| 550 | continue | |
| 551 | name, value = m.group(1), m.group(2) | |
| 552 | if name in allowlist: | |
| 553 | print(f'{name}|{value}') | |
| 554 | " "$file" 2>/dev/null | |
| 555 | } | |
| 556 | ||
| 557 | # extract_npm_direct_pins [package_json_path] | |
| 558 | # | |
| 559 | # Prints ``name|version`` for every exactly-pinned direct dependency in one | |
| 560 | # ``package.json`` — ``dependencies`` and ``devDependencies`` both count, since | |
| 561 | # every repository-owned graph pins tooling through devDependencies. Range | |
| 562 | # specifiers (``^``, ``~``, ``>=``, ``*``, tags) are skipped: the npm package | |
| 563 | # management check already fails graphs that carry them, and a range cannot | |
| 564 | # drift in the "pinned copy is behind latest" sense this scan reports. | |
| 565 | # Missing or malformed files print nothing, matching extract_dockerfile_pins. | |
| 566 | 17 | extract_npm_direct_pins() { |
| 567 | 69 | local file="${1:-package.json}" |
| 568 | 70 | [ -f "$file" ] || return 0 |
| 569 | 68 | python3 -c " |
| 570 | import json, re, sys | |
| 571 | ||
| 572 | try: | |
| 573 | with open(sys.argv[1]) as f: | |
| 574 | manifest = json.load(f) | |
| 575 | except (OSError, json.JSONDecodeError): | |
| 576 | sys.exit(0) | |
| 577 | ||
| 578 | exact = re.compile(r'^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$') | |
| 579 | for section in ('dependencies', 'devDependencies'): | |
| 580 | entries = manifest.get(section) | |
| 581 | if not isinstance(entries, dict): | |
| 582 | continue | |
| 583 | for name, version in sorted(entries.items()): | |
| 584 | if isinstance(version, str) and exact.match(version): | |
| 585 | print(f'{name}|{version}') | |
| 586 | " "$file" 2>/dev/null | |
| 587 | } | |
| 588 | ||
| 589 | # extract_precommit_hooks [config_path] | |
| 590 | # | |
| 591 | # Parses ``.pre-commit-config.yaml`` and emits one ``repo|rev`` pair per | |
| 592 | # hook ``repo:`` block. The repo URL is left intact (it's needed to | |
| 593 | # resolve the upstream releases endpoint), and ``rev`` is the literal | |
| 594 | # string committed to the config — usually a tag like ``v0.15.7`` or | |
| 595 | # ``v1.19.1`` but pre-commit also tolerates plain semver and full SHAs. | |
| 596 | # Local hook stanzas (``repo: local``) and the pre-commit hook | |
| 597 | # meta-stanza (``repo: meta``) are skipped: there's no upstream release | |
| 598 | # to compare against. | |
| 599 | # | |
| 600 | # Falls back silently to an empty list if the file is missing or the | |
| 601 | # YAML can't be parsed — the caller treats that as "skip" rather than | |
| 602 | # "no drift", same pattern as the other extractors in this file. | |
| 603 | 16 | extract_precommit_hooks() { |
| 604 | 63 | local file="${1:-.pre-commit-config.yaml}" |
| 605 | 66 | [ -f "$file" ] || return 0 |
| 606 | 60 | python3 -c " |
| 607 | import sys, yaml | |
| 608 | try: | |
| 609 | with open(sys.argv[1]) as f: | |
| 610 | data = yaml.safe_load(f) | |
| 611 | except Exception: | |
| 612 | sys.exit(0) | |
| 613 | for entry in (data or {}).get('repos', []) or []: | |
| 614 | repo = (entry or {}).get('repo', '') or '' | |
| 615 | rev = (entry or {}).get('rev', '') or '' | |
| 616 | # ``local`` and ``meta`` are pre-commit conventions for hooks | |
| 617 | # that aren't backed by an upstream repo; skip them. | |
| 618 | if not repo or repo in ('local', 'meta'): | |
| 619 | continue | |
| 620 | if not rev: | |
| 621 | continue | |
| 622 | print(f'{repo}|{rev}') | |
| 623 | " "$file" 2>/dev/null | |
| 624 | } | |
| 625 | ||
| 626 | # is_full_git_commit_sha <revision> | |
| 627 | # | |
| 628 | # Returns success only for a complete SHA-1 or SHA-256 object id. Pre-commit | |
| 629 | # accepts branch names and floating labels in ``rev`` too, so merely being | |
| 630 | # non-semver is not enough to call a ref immutable. | |
| 631 | 6 | is_full_git_commit_sha() { |
| 632 | 126 | [[ "$1" =~ ^[0-9a-fA-F]{40}$ || "$1" =~ ^[0-9a-fA-F]{64}$ ]] |
| 633 | } | |
| 634 | ||
| 635 | # extract_emr_versions <file> | |
| 636 | # | |
| 637 | # Extracts the pinned EMR Serverless release label from the constants module. | |
| 638 | # Prints the label (e.g. ``emr-7.13.0``) on a single line. Falls back to | |
| 639 | # reading constants.py directly if the module can't be imported. | |
| 640 | 16 | extract_emr_versions() { |
| 641 | 57 | local file="${1:-gco/stacks/constants.py}" |
| 642 | 57 | python3 -c " |
| 643 | import sys | |
| 644 | try: | |
| 645 | from gco.stacks.constants import EMR_SERVERLESS_RELEASE_LABEL | |
| 646 | print(EMR_SERVERLESS_RELEASE_LABEL) | |
| 647 | except ImportError: | |
| 648 | import re, os | |
| 649 | constants_path = os.path.join(os.path.dirname(sys.argv[1]), 'constants.py') if 'constants.py' not in sys.argv[1] else sys.argv[1] | |
| 650 | if os.path.exists(constants_path): | |
| 651 | with open(constants_path) as f: | |
| 652 | text = f.read() | |
| 653 | m = re.search(r'EMR_SERVERLESS_RELEASE_LABEL\s*=\s*\"([^\"]+)\"', text) | |
| 654 | if m: | |
| 655 | print(m.group(1)) | |
| 656 | " "$file" 2>/dev/null | |
| 657 | } | |
| 658 | ||
| 659 | # extract_constant_value <name> [constants_path] | |
| 660 | # | |
| 661 | # Reads a single string-valued top-level constant from the constants | |
| 662 | # module by regex (does *not* import ``gco.stacks``, which would pull | |
| 663 | # in the full CDK stack package). Used by the CDK-enum drift checks | |
| 664 | # below to look up ``LAMBDA_PYTHON_RUNTIME`` and ``AURORA_POSTGRES_VERSION`` | |
| 665 | # without assuming the rest of the project is installable. | |
| 666 | # | |
| 667 | # Example: | |
| 668 | # extract_constant_value LAMBDA_PYTHON_RUNTIME | |
| 669 | # # → PYTHON_3_14 | |
| 670 | 48 | extract_constant_value() { |
| 671 | 155 | local name="$1" |
| 672 | 155 | local file="${2:-gco/stacks/constants.py}" |
| 673 | 166 | [ -f "$file" ] || return 0 |
| 674 | 144 | python3 -c " |
| 675 | import re, sys | |
| 676 | name = sys.argv[1] | |
| 677 | with open(sys.argv[2]) as f: | |
| 678 | text = f.read() | |
| 679 | m = re.search(r'^' + re.escape(name) + r'\s*=\s*\"([^\"]+)\"', text, re.MULTILINE) | |
| 680 | if m: | |
| 681 | print(m.group(1)) | |
| 682 | " "$name" "$file" 2>/dev/null | |
| 683 | } | |
| 684 | ||
| 685 | # extract_python_string_constant <name> <python_path> | |
| 686 | # | |
| 687 | # Tokenizes the source without importing or executing it, then literal-evaluates | |
| 688 | # only the requested top-level assignment. Parsing only that expression keeps | |
| 689 | # extraction compatible when the rest of the module uses syntax newer than the | |
| 690 | # scanner's Python interpreter. Adjacent/parenthesized string literals remain | |
| 691 | # supported, while names, calls, and other executable expressions are rejected. | |
| 692 | 32 | extract_python_string_constant() { |
| 693 | 96 | local name="$1" |
| 694 | 96 | local file="$2" |
| 695 | 102 | [ -f "$file" ] || return 0 |
| 696 | 90 | python3 -c " |
| 697 | import ast, io, sys, tokenize | |
| 698 | try: | |
| 699 | with open(sys.argv[2], encoding='utf-8') as handle: | |
| 700 | source = handle.read() | |
| 701 | except OSError: | |
| 702 | raise SystemExit(0) | |
| 703 | tokens = tokenize.generate_tokens(io.StringIO(source).readline) | |
| 704 | try: | |
| 705 | for token in tokens: | |
| 706 | if not ( | |
| 707 | token.type == tokenize.NAME | |
| 708 | and token.string == sys.argv[1] | |
| 709 | and token.start[1] == 0 | |
| 710 | ): | |
| 711 | continue | |
| 712 | for token in tokens: | |
| 713 | if token.type not in (tokenize.NL, tokenize.COMMENT): | |
| 714 | break | |
| 715 | if token.type != tokenize.OP or token.string != '=': | |
| 716 | continue | |
| 717 | expression = [] | |
| 718 | depth = 0 | |
| 719 | for token in tokens: | |
| 720 | if token.type == tokenize.OP: | |
| 721 | if token.string in '([{': | |
| 722 | depth += 1 | |
| 723 | elif token.string in ')]}': | |
| 724 | depth -= 1 | |
| 725 | if token.type in (tokenize.NEWLINE, tokenize.ENDMARKER) and depth == 0: | |
| 726 | break | |
| 727 | expression.append((token.type, token.string)) | |
| 728 | try: | |
| 729 | value = ast.literal_eval(tokenize.untokenize(expression)) | |
| 730 | except (SyntaxError, ValueError): | |
| 731 | break | |
| 732 | if isinstance(value, str): | |
| 733 | print(value) | |
| 734 | break | |
| 735 | except tokenize.TokenError: | |
| 736 | pass | |
| 737 | " "$name" "$file" 2>/dev/null | |
| 738 | } | |
| 739 | ||
| 740 | # get_latest_lambda_python_runtime | |
| 741 | # | |
| 742 | # Imports ``aws_cdk.aws_lambda`` and prints the highest ``PYTHON_X_Y`` | |
| 743 | # enum member of ``Runtime`` (e.g. ``PYTHON_3_14``). Empty output when | |
| 744 | # aws-cdk-lib isn't importable — callers treat this as "skip". | |
| 745 | # | |
| 746 | # Used by the CDK-enum drift check in ``dependency-scan.sh`` to compare | |
| 747 | # the ``LAMBDA_PYTHON_RUNTIME`` constant against the newest Lambda | |
| 748 | # Python runtime the installed CDK can construct. The dep-scan workflow | |
| 749 | # installs the latest ``aws-cdk-lib`` for this; locally the helper just | |
| 750 | # reflects whatever is on the active interpreter. | |
| 751 | # | |
| 752 | # Suffixed members (``PYTHON_3_14_PROVIDED`` if it ever exists) are | |
| 753 | # ignored — the regex anchor on ``$`` keeps the result aligned with the | |
| 754 | # canonical "X.Y" runtime CDK exposes today. | |
| 755 | get_latest_lambda_python_runtime() { | |
| 756 | 25 | python3 -c " |
| 757 | import re | |
| 758 | try: | |
| 759 | from aws_cdk import aws_lambda | |
| 760 | except Exception: | |
| 761 | raise SystemExit(0) | |
| 762 | versions = [] | |
| 763 | for name in dir(aws_lambda.Runtime): | |
| 764 | m = re.match(r'^PYTHON_(\d+)_(\d+)$', name) | |
| 765 | if m: | |
| 766 | versions.append((int(m.group(1)), int(m.group(2)), name)) | |
| 767 | if versions: | |
| 768 | print(max(versions)[2]) | |
| 769 | " 2>/dev/null | |
| 770 | } | |
| 771 | ||
| 772 | # get_latest_lambda_nodejs_runtime | |
| 773 | # | |
| 774 | # Prints the highest canonical ``NODEJS_<major>_X`` member exposed by the | |
| 775 | # installed aws-cdk-lib. This is the Node equivalent of the Python helper | |
| 776 | # above and feeds the managed Lambda runtime drift check. | |
| 777 | get_latest_lambda_nodejs_runtime() { | |
| 778 | 25 | python3 -c " |
| 779 | import re | |
| 780 | try: | |
| 781 | from aws_cdk import aws_lambda | |
| 782 | except Exception: | |
| 783 | raise SystemExit(0) | |
| 784 | versions = [] | |
| 785 | for name in dir(aws_lambda.Runtime): | |
| 786 | m = re.match(r'^NODEJS_(\d+)_X$', name) | |
| 787 | if m: | |
| 788 | versions.append((int(m.group(1)), name)) | |
| 789 | if versions: | |
| 790 | print(max(versions)[1]) | |
| 791 | " 2>/dev/null | |
| 792 | } | |
| 793 | ||
| 794 | # get_latest_endoflife_cycle <product> | |
| 795 | # | |
| 796 | # Queries https://endoflife.date/api/<product>.json and prints the | |
| 797 | # highest ``cycle`` (e.g. ``3.14``) that's already shipped and still | |
| 798 | # under standard support. Empty output on network failure or schema | |
| 799 | # change — callers treat this as "skip" rather than as drift. | |
| 800 | # | |
| 801 | # We pick endoflife.date because it's a clean, unauthenticated JSON | |
| 802 | # endpoint that already filters out prerelease/EOL cycles via its | |
| 803 | # ``releaseDate`` and ``eol`` fields. Going through python.org, ruby-lang.org | |
| 804 | # or the upstream GitHub APIs would either rate-limit (no token) or | |
| 805 | # require us to hand-roll prerelease-tag filtering. | |
| 806 | # | |
| 807 | # Interpreters are pinned by *series* here (``3.14``, ``4.0``), not by patch, | |
| 808 | # because that's how .python-version and .ruby-version pin them: the patch is | |
| 809 | # whatever the runner or the prebuilt toolchain resolves to. | |
| 810 | get_latest_endoflife_cycle() { | |
| 811 | 57 | local product="$1" |
| 812 | 57 | curl -fsSL --max-time 15 "https://endoflife.date/api/${product}.json" 2>/dev/null \ |
| 813 | 57 | | python3 -c " |
| 814 | import datetime, json, sys | |
| 815 | try: | |
| 816 | data = json.load(sys.stdin) | |
| 817 | except Exception: | |
| 818 | sys.exit(0) | |
| 819 | today = datetime.date.today().isoformat() | |
| 820 | candidates = [] | |
| 821 | for entry in data: | |
| 822 | cycle = entry.get('cycle', '') | |
| 823 | release = entry.get('releaseDate', '') or '' | |
| 824 | eol = entry.get('eol', '') | |
| 825 | if not cycle or '.' not in cycle: | |
| 826 | continue | |
| 827 | # Skip prereleases (release date in the future). | |
| 828 | if isinstance(release, str) and release > today: | |
| 829 | continue | |
| 830 | # Skip end-of-life cycles. ``eol`` may be a string date or False | |
| 831 | # when EOL hasn't been announced yet — treat False/empty as still | |
| 832 | # supported. | |
| 833 | if isinstance(eol, str) and eol and eol < today: | |
| 834 | continue | |
| 835 | try: | |
| 836 | parts = tuple(int(p) for p in cycle.split('.')) | |
| 837 | except ValueError: | |
| 838 | continue | |
| 839 | candidates.append((parts, cycle)) | |
| 840 | if candidates: | |
| 841 | print(max(candidates)[1]) | |
| 842 | " 2>/dev/null | |
| 843 | } | |
| 844 | ||
| 845 | # get_latest_python_release | |
| 846 | # | |
| 847 | # The supported Python series, for the Lambda runtime comparison. | |
| 848 | get_latest_python_release() { | |
| 849 | 27 | get_latest_endoflife_cycle python |
| 850 | } | |
| 851 | ||
| 852 | # get_latest_ruby_release | |
| 853 | # | |
| 854 | # The supported Ruby series, for the .ruby-version comparison. Ruby is a | |
| 855 | # CI-only dependency (bashcov measures BATS shell coverage — see the Gemfile), | |
| 856 | # but it is pinned like every other toolchain, so it gets the same monthly | |
| 857 | # "is the pin still the current series?" check .python-version gets. | |
| 858 | get_latest_ruby_release() { | |
| 859 | 27 | get_latest_endoflife_cycle ruby |
| 860 | } | |
| 861 | ||
| 862 | # read_ruby_version_pin [version_file] | |
| 863 | # | |
| 864 | # Prints the committed Ruby series from .ruby-version (comments and blank | |
| 865 | # lines ignored), or nothing when the file is missing or holds no version. | |
| 866 | # Empty output makes the caller skip rather than report false drift. | |
| 867 | 16 | read_ruby_version_pin() { |
| 868 | 63 | local version_file="${1:-.ruby-version}" |
| 869 | 67 | [ -f "$version_file" ] || return 0 |
| 870 | 59 | grep -vE '^[[:space:]]*(#|$)' "$version_file" 2>/dev/null \ |
| 871 | 59 | | tr -d '[:space:]' \ |
| 872 | 59 | | grep -oE '^(ruby-)?[0-9]+\.[0-9]+(\.[0-9]+)?$' \ |
| 873 | 59 | | sed -E 's/^ruby-//' \ |
| 874 | 59 | | head -1 |
| 875 | } | |
| 876 | ||
| 877 | # get_latest_precommit_hook_release <repo_url> | |
| 878 | # | |
| 879 | # Given the ``repo:`` URL committed to ``.pre-commit-config.yaml``, | |
| 880 | # prints the latest semver-shaped tag from the upstream Git host so | |
| 881 | # the dep-scan can compare it against the pinned ``rev:``. Empty | |
| 882 | # output on network failure, an unsupported host, or when no tag | |
| 883 | # matches — callers treat that as "skip" rather than as drift. | |
| 884 | # | |
| 885 | # Today only GitHub repos are supported. Every hook in the project's | |
| 886 | # ``.pre-commit-config.yaml`` is hosted there, and the pre-commit | |
| 887 | # ecosystem is overwhelmingly GitHub-based. If a future hook lives | |
| 888 | # elsewhere (GitLab, Codeberg) the helper will return empty and the | |
| 889 | # scan logs a one-line skip note for that hook — no false drift. | |
| 890 | # | |
| 891 | # We use ``GET /repos/{owner}/{repo}/tags`` rather than | |
| 892 | # ``releases/latest`` because pre-commit pins ``rev:`` to a Git tag, | |
| 893 | # not a GitHub Release — and several hooks (yamllint, mirrors-mypy, | |
| 894 | # markdownlint-cli2) tag without ever cutting a Release. The tags | |
| 895 | # endpoint returns newest-first, so we filter to ``vX.Y.Z`` / | |
| 896 | # ``X.Y.Z`` / ``X.Y`` shapes, drop pre-release suffixes (``-rc1``, | |
| 897 | # ``-beta``), and take the highest by semver. | |
| 898 | # | |
| 899 | # Unauthenticated. The monthly scan calls this once per hook (four | |
| 900 | # times against today's config) — the unauthenticated GitHub API | |
| 901 | # limit is 60 req/h per IP, so a per-PAT/GITHUB_TOKEN bump to the | |
| 902 | # 5000 req/h authenticated bucket isn't worth the extra coupling. | |
| 903 | get_latest_precommit_hook_release() { | |
| 904 | 62 | local repo_url="$1" |
| 905 | 63 | [ -n "$repo_url" ] || return 0 |
| 906 | ||
| 907 | # Only GitHub is supported today. Strip any trailing ``.git`` or | |
| 908 | # ``/`` so the owner/repo extraction works for both forms commonly | |
| 909 | # seen in pre-commit configs. | |
| 910 | 61 | local cleaned="${repo_url%.git}" |
| 911 | 61 | cleaned="${cleaned%/}" |
| 912 | 61 | case "$cleaned" in |
| 913 | https://github.com/*) ;; | |
| 914 | 3 | *) return 0 ;; |
| 915 | esac | |
| 916 | ||
| 917 | 58 | local owner_repo="${cleaned#https://github.com/}" |
| 918 | # Reject anything that isn't ``owner/repo`` (no extra path segments). | |
| 919 | 170 | case "$owner_repo" in |
| 920 | 1 | */*/*) return 0 ;; |
| 921 | */*) ;; | |
| 922 | 1 | *) return 0 ;; |
| 923 | esac | |
| 924 | ||
| 925 | 56 | curl -fsSL --max-time 15 \ |
| 926 | -H "Accept: application/vnd.github+json" \ | |
| 927 | -H "X-GitHub-Api-Version: 2022-11-28" \ | |
| 928 | "https://api.github.com/repos/${owner_repo}/tags?per_page=100" 2>/dev/null \ | |
| 929 | 56 | | python3 -c " |
| 930 | import json, re, sys | |
| 931 | try: | |
| 932 | data = json.load(sys.stdin) | |
| 933 | except Exception: | |
| 934 | sys.exit(0) | |
| 935 | # pre-commit's ``rev:`` accepts ``vX.Y[.Z]``, ``X.Y[.Z]``, or full | |
| 936 | # SHAs. We compare on the semver-shaped ones; SHA-pinned hooks are | |
| 937 | # left alone (the helper returns empty and the caller skips them). | |
| 938 | pat = re.compile(r'^v?\d+\.\d+(?:\.\d+)?$') | |
| 939 | candidates = [] | |
| 940 | for entry in data or []: | |
| 941 | name = (entry or {}).get('name', '') | |
| 942 | if not pat.match(name): | |
| 943 | continue | |
| 944 | stripped = name.lstrip('v') | |
| 945 | parts = stripped.split('.') | |
| 946 | try: | |
| 947 | nums = tuple(int(p) for p in parts) | |
| 948 | except ValueError: | |
| 949 | continue | |
| 950 | candidates.append((nums, name)) | |
| 951 | if candidates: | |
| 952 | print(max(candidates)[1]) | |
| 953 | " 2>/dev/null | |
| 954 | } | |
| 955 | ||
| 956 | ||
| 957 | # extract_mooncake_default_image [images_py_path] | |
| 958 | # | |
| 959 | # Prints the default upstream Mooncake vLLM image reference (``repo:tag``) | |
| 960 | # pinned in ``cli/images.py`` as ``_DISAGGREGATED_DEFAULT_IMAGE`` — the image | |
| 961 | # GCO's disaggregated/store/both inference deploys pull when the operator | |
| 962 | # passes no ``--image``. | |
| 963 | # | |
| 964 | # This image lives in a Python constant, not a Dockerfile ``FROM`` or a K8s | |
| 965 | # manifest, so neither Dependabot (docker ecosystem) nor the manifest/workflow | |
| 966 | # image sweep in dependency-scan.sh sees it. This extractor feeds it into the | |
| 967 | # Docker-image drift check so a newer vLLM release is surfaced in the monthly | |
| 968 | # report — the cue to validate and bump the pin (the ``mooncake-image`` | |
| 969 | # workflow re-runs the image contract tests against the new tag). | |
| 970 | # | |
| 971 | # Prints nothing if the file or constant is absent — the caller treats an | |
| 972 | # empty result as "skip", same as the other extractors here. | |
| 973 | 16 | extract_mooncake_default_image() { |
| 974 | 62 | local file="${1:-cli/images.py}" |
| 975 | 66 | [ -f "$file" ] || return 0 |
| 976 | 58 | python3 -c " |
| 977 | import re, sys | |
| 978 | with open(sys.argv[1]) as f: | |
| 979 | text = f.read() | |
| 980 | m = re.search(r'^_DISAGGREGATED_DEFAULT_IMAGE\s*=\s*\"([^\"]+)\"', text, re.MULTILINE) | |
| 981 | if m: | |
| 982 | print(m.group(1)) | |
| 983 | " "$file" 2>/dev/null | |
| 984 | } | |
| 985 | ||
| 986 | # extract_chart_value_images [charts_yaml_path] | |
| 987 | # | |
| 988 | # Walks every ``values:`` block in charts.yaml and prints one fully-qualified | |
| 989 | # ``image:tag`` per line for every mapping that pins BOTH a repository and a | |
| 990 | # tag. Handles the two image-pin shapes the file uses: | |
| 991 | # | |
| 992 | # image: image: | |
| 993 | # repository: example/app registry: ghcr.io | |
| 994 | # tag: "1.2.3" repository: org/sub/app | |
| 995 | # tag: "v1.2.3" | |
| 996 | # | |
| 997 | # The ``registry`` sibling must join the emitted reference: without it a | |
| 998 | # ghcr-hosted repository is emitted bare and the tag sweep resolves it | |
| 999 | # against docker.io — for multi-segment repositories that cannot exist | |
| 1000 | # there, turning every monthly scan into a false INCOMPLETE (first hit by | |
| 1001 | # kubeflow-trainer's controller pin, the first registry-split image with an | |
| 1002 | # explicit tag; KEDA's registry-split blocks are tag-less and never emit). | |
| 1003 | # | |
| 1004 | # Tag-less pins stay un-emitted on purpose: their images follow the chart's | |
| 1005 | # appVersion, which the Helm chart version sweep already reports. Registry- | |
| 1006 | # less single-segment repositories are skipped as before (ambiguous | |
| 1007 | # namespace). Prints nothing on a missing or unparseable file — the caller | |
| 1008 | # treats empty output as a broken parse, matching the smoke-manifest | |
| 1009 | # precedent (charts.yaml always carries pinned values images). | |
| 1010 | 16 | extract_chart_value_images() { |
| 1011 | 61 | local file="${1:-lambda/helm-installer/charts.yaml}" |
| 1012 | 65 | [ -f "$file" ] || return 0 |
| 1013 | 57 | python3 -c " |
| 1014 | import sys | |
| 1015 | import yaml | |
| 1016 | try: | |
| 1017 | with open(sys.argv[1]) as handle: | |
| 1018 | data = yaml.safe_load(handle) | |
| 1019 | except Exception: | |
| 1020 | sys.exit(0) | |
| 1021 | ||
| 1022 | ||
| 1023 | def find_images(node): | |
| 1024 | if isinstance(node, dict): | |
| 1025 | registry = node.get('registry', '') | |
| 1026 | repository = node.get('repository', '') | |
| 1027 | tag = node.get('tag', '') | |
| 1028 | if repository and tag: | |
| 1029 | if registry: | |
| 1030 | print(f'{registry}/{repository}:{tag}') | |
| 1031 | elif '/' in repository: | |
| 1032 | print(f'{repository}:{tag}') | |
| 1033 | for value in node.values(): | |
| 1034 | find_images(value) | |
| 1035 | elif isinstance(node, list): | |
| 1036 | for item in node: | |
| 1037 | find_images(item) | |
| 1038 | ||
| 1039 | ||
| 1040 | for _name, cfg in ((data or {}).get('charts') or {}).items(): | |
| 1041 | if isinstance(cfg, dict): | |
| 1042 | find_images(cfg.get('values') or {}) | |
| 1043 | " "$file" 2>/dev/null | |
| 1044 | } | |
| 1045 | ||
| 1046 | # extract_default_bedrock_model [cdk_json_path] [leaf_key] | |
| 1047 | # | |
| 1048 | # Prints a configured Bedrock model id from ``cdk.json`` | |
| 1049 | # ``context.bedrock.<leaf_key>`` (default leaf: ``mission_default_model_id``). | |
| 1050 | # The managed generation leaves are ``mission_default_model_id`` — what | |
| 1051 | # Mission sampling resolves through ``gco.bedrock`` when no explicit override | |
| 1052 | # is supplied — ``capacity_advisor_default_model_id`` — the capacity | |
| 1053 | # advisor's equivalent — and ``claude_code_default_model_id``, the session | |
| 1054 | # model ``gco autopilot`` hands to Claude Code. The keys are deliberately | |
| 1055 | # independent knobs. | |
| 1056 | # | |
| 1057 | # These values feed the Bedrock-model drift check in dependency-scan.sh, which | |
| 1058 | # compares each against the newest profile in the same model family | |
| 1059 | # (get_latest_bedrock_model). A newer release is the cue to update cdk.json | |
| 1060 | # and, for the Mission key, re-capture the scaffold fixture under | |
| 1061 | # tests/fixtures/scaffold_responses/. | |
| 1062 | # | |
| 1063 | # Prints nothing if the file is absent, malformed, or does not contain a | |
| 1064 | # non-empty string at the expected path. The caller treats empty output as a | |
| 1065 | # skip, matching the other extractors in this library. | |
| 1066 | 96 | extract_default_bedrock_model() { |
| 1067 | 347 | local file="${1:-cdk.json}" |
| 1068 | 347 | local leaf="${2:-mission_default_model_id}" |
| 1069 | # Optional third argument selects the context block (default: bedrock). | |
| 1070 | # The vector-store feature keeps its own independent embedding model at | |
| 1071 | # ``context.vector_store.embedding_model_id``; passing ``vector_store`` | |
| 1072 | # here lets the same extractor and drift plumbing manage it. | |
| 1073 | 347 | local block="${3:-bedrock}" |
| 1074 | 366 | [ -f "$file" ] || return 0 |
| 1075 | 328 | python3 -c " |
| 1076 | import json, sys | |
| 1077 | try: | |
| 1078 | with open(sys.argv[1]) as handle: | |
| 1079 | data = json.load(handle) | |
| 1080 | value = data.get('context', {}).get(sys.argv[3], {}).get(sys.argv[2]) | |
| 1081 | except Exception: | |
| 1082 | value = None | |
| 1083 | if isinstance(value, str) and value.strip(): | |
| 1084 | print(value.strip()) | |
| 1085 | " "$file" "$leaf" "$block" 2>/dev/null | |
| 1086 | } | |
| 1087 | ||
| 1088 | # bedrock_model_family <inference_profile_id> | |
| 1089 | # | |
| 1090 | # Prints the "model family" key for a Bedrock system-defined inference | |
| 1091 | # profile id so two releases of the same model line compare equal on | |
| 1092 | # family and differ only on version. The family is the geography + | |
| 1093 | # provider + the non-version tokens of the model name, with numeric model | |
| 1094 | # version/generation/date tokens dropped. Numeric tokens may be integers or | |
| 1095 | # dotted versions embedded between hyphens: | |
| 1096 | # | |
| 1097 | # us.amazon.nova-pro-v1:0 -> us.amazon.nova-pro | |
| 1098 | # global.amazon.nova-3-lite-v1:0 -> global.amazon.nova-lite | |
| 1099 | # us.anthropic.claude-sonnet-4-5-20250929-v1:0 -> us.anthropic.claude-sonnet | |
| 1100 | # global.anthropic.claude-opus-4-6-v1 -> global.anthropic.claude-opus | |
| 1101 | # global.anthropic.claude-opus-9 -> global.anthropic.claude-opus | |
| 1102 | # global.openai.gpt-5.7-sol -> global.openai.gpt-sol | |
| 1103 | # | |
| 1104 | # The trailing revision appears in three shapes across live profiles: | |
| 1105 | # ``-vMAJOR:MINOR``, ``-vMAJOR`` alone (newer Anthropic profiles), and | |
| 1106 | # absent entirely. All three are stripped, so one model line stays one | |
| 1107 | # family; matching only the ``:MINOR`` form would file | |
| 1108 | # ``claude-opus-4-6-v1`` under a phantom ``claude-opus-v1`` family and | |
| 1109 | # silently stop reporting drift against ``claude-opus-5``. | |
| 1110 | # | |
| 1111 | # Folding the numeric generation token into the version key (rather | |
| 1112 | # than the family) is deliberate: it keeps "Nova 1 Pro" and a future | |
| 1113 | # "Nova 2 Pro" in the same family so a generation bump is reported as | |
| 1114 | # drift, while different tiers (nova-pro vs nova-lite) and providers | |
| 1115 | # stay in separate families and are never cross-suggested. | |
| 1116 | 15 | bedrock_model_family() { |
| 1117 | 30 | python3 -c " |
| 1118 | import re, sys | |
| 1119 | mid = sys.argv[1] | |
| 1120 | core = re.sub(r'-v\d+(?::\d+)?\Z', '', mid) | |
| 1121 | parts = core.split('.') | |
| 1122 | if len(parts) >= 3: | |
| 1123 | geo, provider, name = parts[0], parts[1], '.'.join(parts[2:]) | |
| 1124 | elif len(parts) == 2: | |
| 1125 | geo, provider, name = '', parts[0], parts[1] | |
| 1126 | else: | |
| 1127 | geo, provider, name = '', '', core | |
| 1128 | tokens = [ | |
| 1129 | t for t in name.split('-') if t and not re.fullmatch(r'\d+(?:\.\d+)*', t) | |
| 1130 | ] | |
| 1131 | prefix = '.'.join([p for p in (geo, provider) if p]) | |
| 1132 | print(prefix + ('.' + '-'.join(tokens) if tokens else '')) | |
| 1133 | " "$1" 2>/dev/null | |
| 1134 | } | |
| 1135 | ||
| 1136 | # compare_bedrock_model <current_id> <candidate_id> | |
| 1137 | # | |
| 1138 | # Prints "newer" when candidate is a newer release than current, | |
| 1139 | # "same" when equal, "older" otherwise — mirroring compare_semver's | |
| 1140 | # contract so the drift check reads the same way. The comparison key | |
| 1141 | # is the tuple of every integer in the id, left to right (model | |
| 1142 | # version, generation, date, and the trailing ``vMAJOR:MINOR``), so | |
| 1143 | # nova-pro-v1:0 (1,0) is older than a hypothetical nova-pro-v2:0 (2,0) | |
| 1144 | # and claude-sonnet-4-5-...-v1:0 (4,5,...) is older than a | |
| 1145 | # claude-sonnet-4-6-...-v1:0. Callers scope to one family first (see | |
| 1146 | # bedrock_model_family); this helper only looks at the integer key. | |
| 1147 | 9 | compare_bedrock_model() { |
| 1148 | 36 | python3 -c " |
| 1149 | import re, sys | |
| 1150 | def key(mid): | |
| 1151 | return [int(n) for n in re.findall(r'\d+', mid)] | |
| 1152 | a, b = key(sys.argv[1]), key(sys.argv[2]) | |
| 1153 | print('same' if a == b else ('newer' if b > a else 'older')) | |
| 1154 | " "$1" "$2" 2>/dev/null | |
| 1155 | } | |
| 1156 | ||
| 1157 | # get_latest_bedrock_model <current_id> [region] | |
| 1158 | # | |
| 1159 | # Prints the newest system-defined inference-profile id in the same | |
| 1160 | # model family as <current_id> (see bedrock_model_family), as reported | |
| 1161 | # by ``aws bedrock list-inference-profiles --type-equals SYSTEM_DEFINED``. | |
| 1162 | # Used by the Bedrock-model drift check to tell whether a pinned | |
| 1163 | # generation-model default has a newer release available. | |
| 1164 | # | |
| 1165 | # Family scoping keeps the comparison apples-to-apples: a newer Nova | |
| 1166 | # Pro is reported against a pinned Nova Pro, but a different tier (Nova | |
| 1167 | # Lite, a Claude model, ...) is never suggested as a "newer" default — | |
| 1168 | # switching tier/provider is a human decision, not drift. | |
| 1169 | # | |
| 1170 | # Region defaults to us-east-1 (matches the advisor + Mission sampling | |
| 1171 | # default region) regardless of the workflow's configured region, so both | |
| 1172 | # global.* and geography-scoped cross-Region profiles resolve consistently. | |
| 1173 | # Profiles without a numeric version key are ignored because they cannot be | |
| 1174 | # ordered safely. Empty output on any failure (no creds, throttling, schema | |
| 1175 | # change, or no comparable profile) tells the caller to mark the check skipped. | |
| 1176 | # | |
| 1177 | # IAM action: bedrock:ListInferenceProfiles. | |
| 1178 | get_latest_bedrock_model() { | |
| 1179 | 96 | local current="$1" |
| 1180 | 96 | local region="${2:-us-east-1}" |
| 1181 | 191 | if [ -z "$current" ] || ! [[ "$current" =~ [0-9] ]]; then |
| 1182 | 1 | return 0 |
| 1183 | fi | |
| 1184 | 95 | aws bedrock list-inference-profiles \ |
| 1185 | --type-equals SYSTEM_DEFINED \ | |
| 1186 | --region "$region" \ | |
| 1187 | --output json 2>/dev/null \ | |
| 1188 | 95 | | python3 -c " |
| 1189 | import json, re, sys | |
| 1190 | current = sys.argv[1] | |
| 1191 | def family(mid): | |
| 1192 | # Keep in lockstep with bedrock_model_family above: the revision | |
| 1193 | # suffix is optional and its ``:MINOR`` half is too. | |
| 1194 | core = re.sub(r'-v\d+(?::\d+)?\Z', '', mid) | |
| 1195 | parts = core.split('.') | |
| 1196 | if len(parts) >= 3: | |
| 1197 | geo, provider, name = parts[0], parts[1], '.'.join(parts[2:]) | |
| 1198 | elif len(parts) == 2: | |
| 1199 | geo, provider, name = '', parts[0], parts[1] | |
| 1200 | else: | |
| 1201 | geo, provider, name = '', '', core | |
| 1202 | tokens = [ | |
| 1203 | t for t in name.split('-') if t and not re.fullmatch(r'\d+(?:\.\d+)*', t) | |
| 1204 | ] | |
| 1205 | prefix = '.'.join([p for p in (geo, provider) if p]) | |
| 1206 | return prefix + ('.' + '-'.join(tokens) if tokens else '') | |
| 1207 | def key(mid): | |
| 1208 | return [int(n) for n in re.findall(r'\d+', mid)] | |
| 1209 | try: | |
| 1210 | data = json.load(sys.stdin) | |
| 1211 | except Exception: | |
| 1212 | sys.exit(0) | |
| 1213 | target = family(current) | |
| 1214 | cands = [] | |
| 1215 | for prof in data.get('inferenceProfileSummaries', []) or []: | |
| 1216 | pid = prof.get('inferenceProfileId', '') or '' | |
| 1217 | if (prof.get('status') or 'ACTIVE') != 'ACTIVE': | |
| 1218 | continue | |
| 1219 | if pid and key(pid) and family(pid) == target: | |
| 1220 | cands.append(pid) | |
| 1221 | if cands: | |
| 1222 | cands.sort(key=key) | |
| 1223 | print(cands[-1]) | |
| 1224 | " "$current" 2>/dev/null | |
| 1225 | } | |
| 1226 | ||
| 1227 | # get_latest_bedrock_embedding_model <current_id> [region] | |
| 1228 | # | |
| 1229 | # Prints the newest ACTIVE text-embedding foundation model in the same | |
| 1230 | # model family as <current_id> (see bedrock_model_family), as reported by | |
| 1231 | # ``aws bedrock list-foundation-models --by-output-modality EMBEDDING``. | |
| 1232 | # Used by the Bedrock-model drift check for | |
| 1233 | # ``context.bedrock.embedding_model_id`` — Mission memory's embedding | |
| 1234 | # default, resolved at runtime through | |
| 1235 | # ``gco.bedrock.get_default_embedding_model_id()``. | |
| 1236 | # | |
| 1237 | # Embedding models are plain foundation models, not system-defined | |
| 1238 | # inference profiles, so this is a separate lookup from | |
| 1239 | # get_latest_bedrock_model; the family scoping and integer-tuple version | |
| 1240 | # ranking are deliberately identical. A newer same-family release (e.g. a | |
| 1241 | # Titan Text Embeddings v3) is reported as drift; a different provider or | |
| 1242 | # model line never is. Embedding drift is advisory-with-a-caveat: stored | |
| 1243 | # vectors are only comparable to vectors from the same model, so adopting | |
| 1244 | # a newer embedding model means re-embedding or segregating existing | |
| 1245 | # data, not just bumping the pin (the drift report's remediation text | |
| 1246 | # says so). | |
| 1247 | # | |
| 1248 | # Region defaults to us-east-1 (the Mission memory default region). | |
| 1249 | # Models without a numeric version key are ignored because they cannot be | |
| 1250 | # ordered safely; non-ACTIVE lifecycles (e.g. LEGACY) are skipped. Empty | |
| 1251 | # output on any failure tells the caller to mark the check skipped. | |
| 1252 | # | |
| 1253 | # IAM action: bedrock:ListFoundationModels (already granted to the scan | |
| 1254 | # role alongside bedrock:ListInferenceProfiles). | |
| 1255 | get_latest_bedrock_embedding_model() { | |
| 1256 | 49 | local current="$1" |
| 1257 | 49 | local region="${2:-us-east-1}" |
| 1258 | 97 | if [ -z "$current" ] || ! [[ "$current" =~ [0-9] ]]; then |
| 1259 | 1 | return 0 |
| 1260 | fi | |
| 1261 | 48 | aws bedrock list-foundation-models \ |
| 1262 | --by-output-modality EMBEDDING \ | |
| 1263 | --region "$region" \ | |
| 1264 | --output json 2>/dev/null \ | |
| 1265 | 48 | | python3 -c " |
| 1266 | import json, re, sys | |
| 1267 | current = sys.argv[1] | |
| 1268 | def family(mid): | |
| 1269 | # Keep in lockstep with bedrock_model_family above: the revision | |
| 1270 | # suffix is optional and its ``:MINOR`` half is too. | |
| 1271 | core = re.sub(r'-v\d+(?::\d+)?\Z', '', mid) | |
| 1272 | parts = core.split('.') | |
| 1273 | if len(parts) >= 3: | |
| 1274 | geo, provider, name = parts[0], parts[1], '.'.join(parts[2:]) | |
| 1275 | elif len(parts) == 2: | |
| 1276 | geo, provider, name = '', parts[0], parts[1] | |
| 1277 | else: | |
| 1278 | geo, provider, name = '', '', core | |
| 1279 | tokens = [ | |
| 1280 | t for t in name.split('-') if t and not re.fullmatch(r'\d+(?:\.\d+)*', t) | |
| 1281 | ] | |
| 1282 | prefix = '.'.join([p for p in (geo, provider) if p]) | |
| 1283 | return prefix + ('.' + '-'.join(tokens) if tokens else '') | |
| 1284 | def key(mid): | |
| 1285 | return [int(n) for n in re.findall(r'\d+', mid)] | |
| 1286 | try: | |
| 1287 | data = json.load(sys.stdin) | |
| 1288 | except Exception: | |
| 1289 | sys.exit(0) | |
| 1290 | target = family(current) | |
| 1291 | cands = [] | |
| 1292 | for model in data.get('modelSummaries', []) or []: | |
| 1293 | mid = model.get('modelId', '') or '' | |
| 1294 | lifecycle = (model.get('modelLifecycle') or {}).get('status') or 'ACTIVE' | |
| 1295 | if lifecycle != 'ACTIVE': | |
| 1296 | continue | |
| 1297 | if mid and key(mid) and family(mid) == target: | |
| 1298 | cands.append(mid) | |
| 1299 | if cands: | |
| 1300 | cands.sort(key=key) | |
| 1301 | print(cands[-1]) | |
| 1302 | " "$current" 2>/dev/null | |
| 1303 | } | |
| 1304 | ||
| 1305 | # ============================================================================= | |
| 1306 | # Expanded coverage helpers | |
| 1307 | # ============================================================================= | |
| 1308 | # The functions below extend the scan to surfaces that live outside the | |
| 1309 | # original twelve: CI tooling pins the workflows install by hand, version | |
| 1310 | # pins that must move in lockstep across several files, and recurring | |
| 1311 | # hygiene checks (suppression expiry, lockfile freshness, base-image | |
| 1312 | # security epochs). | |
| 1313 | # | |
| 1314 | # Every one keeps the same contract as the extractors above: print one | |
| 1315 | # record per line to stdout, and print nothing (exit 0) on missing/malformed | |
| 1316 | # input so the caller treats an empty result as "skip", never as drift. | |
| 1317 | # ============================================================================= | |
| 1318 | ||
| 1319 | # get_latest_github_release_tag <owner/repo> | |
| 1320 | # | |
| 1321 | # Prints the ``tag_name`` of the latest non-prerelease GitHub Release for | |
| 1322 | # ``<owner/repo>`` (e.g. ``v0.70.0``). Generalises the inline release lookups | |
| 1323 | # the Dockerfile.dev section already does for moby/moby and docker/buildx so | |
| 1324 | # the CI-tooling drift check (Trivy, Helm, kind) can share one code path. | |
| 1325 | # | |
| 1326 | # Empty output on network failure, a non ``owner/repo`` argument, or a repo | |
| 1327 | # with no published Release — callers treat empty as "skip", same as the | |
| 1328 | # other lookups here. Unauthenticated: the monthly scan makes a handful of | |
| 1329 | # these calls, well under the 60 req/h anonymous GitHub limit. | |
| 1330 | get_latest_github_release_tag() { | |
| 1331 | 179 | local owner_repo="$1" |
| 1332 | 180 | [ -n "$owner_repo" ] || return 0 |
| 1333 | # Reject anything that isn't exactly ``owner/repo`` (mirrors the guard in | |
| 1334 | # get_latest_precommit_hook_release) so a stray URL or path can't 404. | |
| 1335 | 530 | case "$owner_repo" in |
| 1336 | 1 | */*/*) return 0 ;; |
| 1337 | */*) ;; | |
| 1338 | 1 | *) return 0 ;; |
| 1339 | esac | |
| 1340 | 176 | curl -fsSL --max-time 15 \ |
| 1341 | -H "Accept: application/vnd.github+json" \ | |
| 1342 | -H "X-GitHub-Api-Version: 2022-11-28" \ | |
| 1343 | "https://api.github.com/repos/${owner_repo}/releases/latest" 2>/dev/null \ | |
| 1344 | 176 | | jq -r '.tag_name // empty' 2>/dev/null |
| 1345 | } | |
| 1346 | ||
| 1347 | # extract_workflow_env_pin <VAR_NAME> [workflows_dir] | |
| 1348 | # | |
| 1349 | # Prints the unique value(s) of a ``<VAR_NAME>: "<value>"`` env assignment | |
| 1350 | # found across the workflow YAML under <workflows_dir> (default | |
| 1351 | # ``.github/workflows``). Used by the CI-tooling drift check to read the | |
| 1352 | # pinned ``HELM_VERSION`` / ``KUBECTL_VERSION`` / ``CALICO_VERSION`` the | |
| 1353 | # workflows install their own tooling from — pins Dependabot doesn't watch | |
| 1354 | # (they're plain env strings, not ``uses:`` refs or Dockerfile ``FROM`` | |
| 1355 | # lines). | |
| 1356 | # | |
| 1357 | # Prints one value per line, de-duplicated and sorted. More than one line | |
| 1358 | # means the same tool is pinned to *different* values across files — a | |
| 1359 | # lockstep-drift bug the Version Consistency section reports. Empty output | |
| 1360 | # when the dir is absent or the var is unset anywhere. | |
| 1361 | 64 | extract_workflow_env_pin() { |
| 1362 | 396 | local var="$1" |
| 1363 | 396 | local dir="${2:-.github/workflows}" |
| 1364 | 396 | [ -n "$var" ] || return 0 |
| 1365 | 413 | [ -d "$dir" ] || return 0 |
| 1366 | 379 | grep -rhoE "^[[:space:]]*${var}:[[:space:]]*\"?[A-Za-z0-9._+-]+\"?" "$dir" 2>/dev/null \ |
| 1367 | 379 | | sed -E "s/^[[:space:]]*${var}:[[:space:]]*//" \ |
| 1368 | 379 | | tr -d '"' \ |
| 1369 | 379 | | sort -u |
| 1370 | } | |
| 1371 | ||
| 1372 | # extract_install_trivy_pin [action_yml] | |
| 1373 | # | |
| 1374 | # Prints the Trivy version pinned as the ``version`` input default of the | |
| 1375 | # install-trivy composite action — the one place the Trivy pin lives, now | |
| 1376 | # that security.yml and cve-scan.yml carry no copies of their own. Empty | |
| 1377 | # output when the file, the input, or the default is absent. | |
| 1378 | 16 | extract_install_trivy_pin() { |
| 1379 | 61 | local file="${1:-.github/actions/install-trivy/action.yml}" |
| 1380 | 65 | [ -f "$file" ] || return 0 |
| 1381 | 57 | python3 -c " |
| 1382 | import sys, yaml | |
| 1383 | try: | |
| 1384 | with open(sys.argv[1]) as f: | |
| 1385 | data = yaml.safe_load(f) | |
| 1386 | except Exception: | |
| 1387 | sys.exit(0) | |
| 1388 | value = ((((data or {}).get('inputs') or {}).get('version') or {}).get('default') or '') | |
| 1389 | if value: | |
| 1390 | print(value) | |
| 1391 | " "$file" 2>/dev/null | |
| 1392 | } | |
| 1393 | ||
| 1394 | # extract_kind_pins [workflow_file] | |
| 1395 | # | |
| 1396 | # Prints the kind pins configured on the ``helm/kind-action`` step: | |
| 1397 | # kind|<version> e.g. kind|v0.32.0 (the kind binary) | |
| 1398 | # kind-node|<image:tag> e.g. kind-node|kindest/node:v1.36.1 | |
| 1399 | # | |
| 1400 | # These live in the action's ``with:`` block, not a top-level ``image:`` or | |
| 1401 | # a Dockerfile ``FROM``, so neither the workflow image sweep nor Dependabot's | |
| 1402 | # docker ecosystem sees them. The caller checks the kind binary against | |
| 1403 | # kubernetes-sigs/kind releases and the node image against its own registry | |
| 1404 | # tags within the pinned K8s minor. | |
| 1405 | # | |
| 1406 | # Empty output if the file or the kind-action step is absent. | |
| 1407 | 32 | extract_kind_pins() { |
| 1408 | 177 | local file="${1:-.github/workflows/integration-tests.yml}" |
| 1409 | 186 | [ -f "$file" ] || return 0 |
| 1410 | 168 | python3 -c " |
| 1411 | import re, sys, yaml | |
| 1412 | try: | |
| 1413 | with open(sys.argv[1]) as f: | |
| 1414 | data = yaml.safe_load(f) | |
| 1415 | except Exception: | |
| 1416 | sys.exit(0) | |
| 1417 | ||
| 1418 | # The kind-action steps reference the single workflow-level declarations as | |
| 1419 | # \${{ env.KIND_VERSION }} / \${{ env.KIND_NODE_IMAGE }}; resolve those here | |
| 1420 | # so callers keep seeing concrete values. Literal with: values (the | |
| 1421 | # pre-hoist shape) still pass through unchanged. An env reference that does | |
| 1422 | # not resolve prints nothing — the caller's presence checks report the pin | |
| 1423 | # as missing rather than passing a template string to a release lookup. | |
| 1424 | workflow_env = { | |
| 1425 | str(k): str(v) for k, v in ((data or {}).get('env') or {}).items() | |
| 1426 | } | |
| 1427 | _REF = re.compile(r'^\\\$\\{\\{\\s*env\\.([A-Za-z_][A-Za-z0-9_]*)\\s*\\}\\}\$') | |
| 1428 | ||
| 1429 | def resolve(value, job_env): | |
| 1430 | value = str(value or '') | |
| 1431 | match = _REF.match(value.strip()) | |
| 1432 | if not match: | |
| 1433 | return value | |
| 1434 | name = match.group(1) | |
| 1435 | if name in job_env: | |
| 1436 | return str(job_env[name]) | |
| 1437 | return workflow_env.get(name, '') | |
| 1438 | ||
| 1439 | seen = [] | |
| 1440 | for job in (data or {}).get('jobs', {}).values(): | |
| 1441 | job_env = {str(k): str(v) for k, v in ((job or {}).get('env') or {}).items()} | |
| 1442 | for step in (job or {}).get('steps', []) or []: | |
| 1443 | uses = (step or {}).get('uses', '') or '' | |
| 1444 | if uses.startswith('helm/kind-action'): | |
| 1445 | with_ = (step or {}).get('with', {}) or {} | |
| 1446 | ver = resolve(with_.get('version', ''), job_env) | |
| 1447 | node = resolve(with_.get('node_image', ''), job_env) | |
| 1448 | if ver and ('kind', ver) not in seen: | |
| 1449 | seen.append(('kind', ver)) | |
| 1450 | if node and ('kind-node', node) not in seen: | |
| 1451 | seen.append(('kind-node', node)) | |
| 1452 | # De-duplicated: multiple kind-action steps (cluster-e2e + examples-smoke) | |
| 1453 | # resolving to the SAME versions print once. A key appearing twice therefore | |
| 1454 | # always means the steps drifted apart — the caller's consistency check | |
| 1455 | # reports exactly that. | |
| 1456 | for key, value in seen: | |
| 1457 | print(f'{key}|{value}') | |
| 1458 | " "$file" 2>/dev/null | |
| 1459 | } | |
| 1460 | ||
| 1461 | # extract_ruff_pins [pyproject] [precommit] [lint_workflow] | |
| 1462 | # | |
| 1463 | # Prints the ruff version pinned in each place the project keeps it, one | |
| 1464 | # ``source|version`` per line (version normalised without a leading ``v``): | |
| 1465 | # Example output shape (the values differ when local pins drift): | |
| 1466 | # pyproject|1.2.3 ruff==X in [project.optional-dependencies] | |
| 1467 | # precommit|1.2.4 astral-sh/ruff-pre-commit rev in .pre-commit-config.yaml | |
| 1468 | # lint-action|1.2.3 astral-sh/ruff-action version input in lint.yml | |
| 1469 | # | |
| 1470 | # Ruff is pinned in three spots that must move together (developer install, | |
| 1471 | # the pre-commit hook, and the prebuilt-binary CI lint job). The base Python | |
| 1472 | # deps check already flags ruff drift vs PyPI, but nothing catches the three | |
| 1473 | # local pins silently disagreeing — which is exactly what the Version | |
| 1474 | # Consistency section reports. | |
| 1475 | extract_ruff_pins() { | |
| 1476 | 28 | local pyproject="${1:-pyproject.toml}" |
| 1477 | 28 | local precommit="${2:-.pre-commit-config.yaml}" |
| 1478 | 28 | local lintwf="${3:-.github/workflows/lint.yml}" |
| 1479 | 28 | python3 -c " |
| 1480 | import re, sys | |
| 1481 | pyproject, precommit, lintwf = sys.argv[1], sys.argv[2], sys.argv[3] | |
| 1482 | ||
| 1483 | def norm(v): | |
| 1484 | return v.lstrip('v').strip() | |
| 1485 | ||
| 1486 | # pyproject: first ruff==X.Y.Z anywhere (the lint + diagrams extras pin the | |
| 1487 | # same value; report it once). | |
| 1488 | try: | |
| 1489 | with open(pyproject) as f: | |
| 1490 | m = re.search(r'ruff==([0-9.]+)', f.read()) | |
| 1491 | if m: | |
| 1492 | print(f'pyproject|{norm(m.group(1))}') | |
| 1493 | except OSError: | |
| 1494 | pass | |
| 1495 | ||
| 1496 | # pre-commit: rev of the astral-sh/ruff-pre-commit repo block. | |
| 1497 | try: | |
| 1498 | import yaml | |
| 1499 | with open(precommit) as f: | |
| 1500 | data = yaml.safe_load(f) | |
| 1501 | for entry in (data or {}).get('repos', []) or []: | |
| 1502 | if 'astral-sh/ruff-pre-commit' in ((entry or {}).get('repo', '') or ''): | |
| 1503 | rev = (entry or {}).get('rev', '') | |
| 1504 | if rev: | |
| 1505 | print(f'precommit|{norm(rev)}') | |
| 1506 | break | |
| 1507 | except Exception: | |
| 1508 | pass | |
| 1509 | ||
| 1510 | # lint workflow: version input on the first astral-sh/ruff-action step (the | |
| 1511 | # two steps pin the same value). Parse the YAML rather than regex the raw | |
| 1512 | # text so a nearby ``python-version:`` can't be mistaken for the action's | |
| 1513 | # own ``version:`` input. | |
| 1514 | try: | |
| 1515 | import yaml | |
| 1516 | with open(lintwf) as f: | |
| 1517 | wf = yaml.safe_load(f) | |
| 1518 | found = '' | |
| 1519 | for job in (wf or {}).get('jobs', {}).values(): | |
| 1520 | for step in (job or {}).get('steps', []) or []: | |
| 1521 | uses = (step or {}).get('uses', '') or '' | |
| 1522 | if uses.startswith('astral-sh/ruff-action'): | |
| 1523 | found = str(((step or {}).get('with', {}) or {}).get('version', '') or '') | |
| 1524 | if found: | |
| 1525 | break | |
| 1526 | if found: | |
| 1527 | break | |
| 1528 | if found: | |
| 1529 | print(f'lint-action|{norm(found)}') | |
| 1530 | except Exception: | |
| 1531 | pass | |
| 1532 | " "$pyproject" "$precommit" "$lintwf" 2>/dev/null | |
| 1533 | } | |
| 1534 | ||
| 1535 | # extract_python_version_pins [workflows_dir] | |
| 1536 | # | |
| 1537 | # Prints one line per ``python-version: "X.Y"`` occurrence across the | |
| 1538 | # workflow YAML (value only, e.g. ``3.14``). The caller collapses these to | |
| 1539 | # unique values: more than one distinct value, or a value that disagrees | |
| 1540 | # with the project's canonical Python (derived from LAMBDA_PYTHON_RUNTIME), | |
| 1541 | # means the CI matrix drifted from the runtime the Lambdas actually ship on. | |
| 1542 | extract_python_version_pins() { | |
| 1543 | 30 | local dir="${1:-.github/workflows}" |
| 1544 | 30 | local version_file="${2:-.python-version}" |
| 1545 | 32 | [ -d "$dir" ] || return 0 |
| 1546 | # The CI Python lives once, in .python-version (every setup-python step | |
| 1547 | # uses python-version-file). Emit that single pin, PLUS any literal | |
| 1548 | # python-version: leftovers in the workflows — a stray literal is | |
| 1549 | # exactly the drift the consistency check should surface. | |
| 1550 | 28 | if [ -f "$version_file" ]; then |
| 1551 | 54 | grep -oE "^[0-9]+\.[0-9]+" "$version_file" | head -1 |
| 1552 | fi | |
| 1553 | 28 | grep -rhoE "python-version:[[:space:]]*\"?[0-9]+\.[0-9]+\"?" "$dir" 2>/dev/null \ |
| 1554 | 28 | | sed -E "s/python-version:[[:space:]]*//" \ |
| 1555 | 28 | | tr -d '"' |
| 1556 | } | |
| 1557 | ||
| 1558 | # list_npm_package_dirs [root] | |
| 1559 | # | |
| 1560 | # Prints every repository-owned npm package directory. Generated, vendored, | |
| 1561 | # virtual-environment, and CDK assembly trees are excluded so a copied | |
| 1562 | # ``package.json`` never becomes a false dependency surface. | |
| 1563 | 18 | list_npm_package_dirs() { |
| 1564 | 89 | local root="${1:-.}" |
| 1565 | 89 | python3 -c " |
| 1566 | from pathlib import Path | |
| 1567 | import sys | |
| 1568 | ||
| 1569 | root = Path(sys.argv[1]).resolve() | |
| 1570 | excluded = { | |
| 1571 | '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache', | |
| 1572 | 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__', | |
| 1573 | } | |
| 1574 | ||
| 1575 | def owned(path): | |
| 1576 | parts = path.relative_to(root).parts[:-1] | |
| 1577 | return not any( | |
| 1578 | part in excluded or part.startswith('.venv') or part.endswith('-build') | |
| 1579 | for part in parts | |
| 1580 | ) | |
| 1581 | ||
| 1582 | for manifest in sorted(root.rglob('package.json')): | |
| 1583 | if not owned(manifest): | |
| 1584 | continue | |
| 1585 | relative = manifest.parent.relative_to(root) | |
| 1586 | print('.' if relative == Path('.') else relative.as_posix()) | |
| 1587 | " "$root" 2>/dev/null | |
| 1588 | } | |
| 1589 | ||
| 1590 | # check_npm_package_management [root] [dependabot_config] | |
| 1591 | # | |
| 1592 | # Emits ``package.json|problem`` for every repository-owned npm graph that is | |
| 1593 | # not fully reproducible and managed. A graph must have a package-lock.json, | |
| 1594 | # an exact npm packageManager pin, exact direct dependency pins, and a matching | |
| 1595 | # Dependabot npm directory entry. The all-package npm-audit CI job treats any | |
| 1596 | # output as a hard failure; the monthly scan also reports it as consistency | |
| 1597 | # drift. | |
| 1598 | check_npm_package_management() { | |
| 1599 | 29 | local root="${1:-.}" |
| 1600 | 29 | local dependabot="${2:-.github/dependabot.yml}" |
| 1601 | 29 | python3 -c " |
| 1602 | import json, re, sys | |
| 1603 | from pathlib import Path | |
| 1604 | ||
| 1605 | root = Path(sys.argv[1]).resolve() | |
| 1606 | dependabot_path = Path(sys.argv[2]) | |
| 1607 | if not dependabot_path.is_absolute(): | |
| 1608 | dependabot_path = root / dependabot_path | |
| 1609 | excluded = { | |
| 1610 | '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache', | |
| 1611 | 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__', | |
| 1612 | } | |
| 1613 | exact_version = re.compile(r'^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$') | |
| 1614 | ||
| 1615 | def owned(path): | |
| 1616 | parts = path.relative_to(root).parts[:-1] | |
| 1617 | return not any( | |
| 1618 | part in excluded or part.startswith('.venv') or part.endswith('-build') | |
| 1619 | for part in parts | |
| 1620 | ) | |
| 1621 | ||
| 1622 | dependabot_dirs = set() | |
| 1623 | try: | |
| 1624 | text = dependabot_path.read_text(encoding='utf-8') | |
| 1625 | except OSError: | |
| 1626 | text = '' | |
| 1627 | for block in re.split(r'(?m)(?=^\s*-\s+package-ecosystem:)', text): | |
| 1628 | if not re.search(r'(?m)^\s*-\s+package-ecosystem:\s*[\"\']?npm[\"\']?\s*$', block): | |
| 1629 | continue | |
| 1630 | match = re.search(r'(?m)^\s+directory:\s*[\"\']?([^\"\'\s]+)', block) | |
| 1631 | if match: | |
| 1632 | dependabot_dirs.add('/' + match.group(1).strip('/')) | |
| 1633 | ||
| 1634 | for manifest in sorted(root.rglob('package.json')): | |
| 1635 | if not owned(manifest): | |
| 1636 | continue | |
| 1637 | rel_manifest = manifest.relative_to(root).as_posix() | |
| 1638 | rel_dir = manifest.parent.relative_to(root) | |
| 1639 | dependabot_dir = '/' if rel_dir == Path('.') else '/' + rel_dir.as_posix() | |
| 1640 | try: | |
| 1641 | package = json.loads(manifest.read_text(encoding='utf-8')) | |
| 1642 | except (OSError, json.JSONDecodeError) as exc: | |
| 1643 | print(f'{rel_manifest}|invalid JSON: {exc}') | |
| 1644 | continue | |
| 1645 | if not manifest.with_name('package-lock.json').is_file(): | |
| 1646 | print(f'{rel_manifest}|missing package-lock.json') | |
| 1647 | manager = package.get('packageManager', '') | |
| 1648 | if not re.fullmatch(r'npm@\d+\.\d+\.\d+', manager): | |
| 1649 | print(f'{rel_manifest}|packageManager must be an exact npm@X.Y.Z pin') | |
| 1650 | for section in ('dependencies', 'devDependencies', 'optionalDependencies'): | |
| 1651 | for name, version in (package.get(section) or {}).items(): | |
| 1652 | if not isinstance(version, str) or not exact_version.fullmatch(version): | |
| 1653 | print(f'{rel_manifest}|{section}.{name} must use an exact version pin') | |
| 1654 | if dependabot_dir not in dependabot_dirs: | |
| 1655 | print(f'{rel_manifest}|missing Dependabot npm entry for {dependabot_dir}') | |
| 1656 | " "$root" "$dependabot" 2>/dev/null | |
| 1657 | } | |
| 1658 | ||
| 1659 | # extract_node_major_pins [root] [constants] [nvmrc] [dockerfile] | |
| 1660 | # | |
| 1661 | # Emits ``source|major`` for every place that intentionally mirrors the | |
| 1662 | # repository Node major: the Lambda runtime constant, .nvmrc, every owned npm | |
| 1663 | # graph's engine, and Dockerfile.dev. The driver reports missing sources and | |
| 1664 | # disagreement; the CDK enum check separately detects a newer Lambda runtime. | |
| 1665 | extract_node_major_pins() { | |
| 1666 | 29 | local root="${1:-.}" |
| 1667 | 29 | local constants="${2:-gco/stacks/constants.py}" |
| 1668 | 29 | local nvmrc="${3:-.nvmrc}" |
| 1669 | 29 | local dockerfile="${4:-Dockerfile.dev}" |
| 1670 | 29 | python3 -c " |
| 1671 | import json, re, sys | |
| 1672 | from pathlib import Path | |
| 1673 | ||
| 1674 | root = Path(sys.argv[1]).resolve() | |
| 1675 | excluded = { | |
| 1676 | '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache', | |
| 1677 | 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__', | |
| 1678 | } | |
| 1679 | ||
| 1680 | def resolve(path): | |
| 1681 | candidate = Path(path) | |
| 1682 | return candidate if candidate.is_absolute() else root / candidate | |
| 1683 | ||
| 1684 | def emit(source, value): | |
| 1685 | match = re.search(r'\d+', str(value)) | |
| 1686 | if match: | |
| 1687 | print(f'{source}|{int(match.group())}') | |
| 1688 | ||
| 1689 | def owned(path): | |
| 1690 | parts = path.relative_to(root).parts[:-1] | |
| 1691 | return not any( | |
| 1692 | part in excluded or part.startswith('.venv') or part.endswith('-build') | |
| 1693 | for part in parts | |
| 1694 | ) | |
| 1695 | ||
| 1696 | try: | |
| 1697 | text = resolve(sys.argv[2]).read_text(encoding='utf-8') | |
| 1698 | match = re.search(r'^LAMBDA_NODEJS_RUNTIME\s*=\s*\"NODEJS_(\d+)_X\"', text, re.MULTILINE) | |
| 1699 | if match: | |
| 1700 | emit('gco/stacks/constants.py', match.group(1)) | |
| 1701 | except OSError: | |
| 1702 | pass | |
| 1703 | try: | |
| 1704 | emit('.nvmrc', resolve(sys.argv[3]).read_text(encoding='utf-8').strip()) | |
| 1705 | except OSError: | |
| 1706 | pass | |
| 1707 | try: | |
| 1708 | text = resolve(sys.argv[4]).read_text(encoding='utf-8') | |
| 1709 | # NODE_VERSION pins the exact release (e.g. ``v24.18.0``); emit() | |
| 1710 | # reduces it to the leading major for the cross-source comparison. | |
| 1711 | match = re.search(r'^\s*ARG\s+NODE_VERSION=(v?\d+(?:\.\d+)*)\s*$', text, re.MULTILINE) | |
| 1712 | if match: | |
| 1713 | emit('Dockerfile.dev', match.group(1)) | |
| 1714 | except OSError: | |
| 1715 | pass | |
| 1716 | for manifest in sorted(root.rglob('package.json')): | |
| 1717 | if not owned(manifest): | |
| 1718 | continue | |
| 1719 | try: | |
| 1720 | package = json.loads(manifest.read_text(encoding='utf-8')) | |
| 1721 | except (OSError, json.JSONDecodeError): | |
| 1722 | continue | |
| 1723 | emit(manifest.relative_to(root).as_posix(), (package.get('engines') or {}).get('node', '')) | |
| 1724 | " "$root" "$constants" "$nvmrc" "$dockerfile" 2>/dev/null | |
| 1725 | } | |
| 1726 | ||
| 1727 | # extract_npm_version_pins [root] [dockerfile] | |
| 1728 | # | |
| 1729 | # Emits every package.json packageManager npm version plus Dockerfile.dev's | |
| 1730 | # NPM_VERSION so Dependabot/tooling updates cannot leave contributor and CI | |
| 1731 | # npm versions disagreeing. | |
| 1732 | extract_npm_version_pins() { | |
| 1733 | 28 | local root="${1:-.}" |
| 1734 | 28 | local dockerfile="${2:-Dockerfile.dev}" |
| 1735 | 28 | python3 -c " |
| 1736 | import json, re, sys | |
| 1737 | from pathlib import Path | |
| 1738 | ||
| 1739 | root = Path(sys.argv[1]).resolve() | |
| 1740 | excluded = { | |
| 1741 | '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache', | |
| 1742 | 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__', | |
| 1743 | } | |
| 1744 | ||
| 1745 | def owned(path): | |
| 1746 | parts = path.relative_to(root).parts[:-1] | |
| 1747 | return not any( | |
| 1748 | part in excluded or part.startswith('.venv') or part.endswith('-build') | |
| 1749 | for part in parts | |
| 1750 | ) | |
| 1751 | ||
| 1752 | for manifest in sorted(root.rglob('package.json')): | |
| 1753 | if not owned(manifest): | |
| 1754 | continue | |
| 1755 | try: | |
| 1756 | package = json.loads(manifest.read_text(encoding='utf-8')) | |
| 1757 | except (OSError, json.JSONDecodeError): | |
| 1758 | continue | |
| 1759 | match = re.fullmatch(r'npm@(\d+\.\d+\.\d+)', package.get('packageManager', '')) | |
| 1760 | if match: | |
| 1761 | print(f'{manifest.relative_to(root).as_posix()}|{match.group(1)}') | |
| 1762 | docker = Path(sys.argv[2]) | |
| 1763 | if not docker.is_absolute(): | |
| 1764 | docker = root / docker | |
| 1765 | try: | |
| 1766 | text = docker.read_text(encoding='utf-8') | |
| 1767 | match = re.search(r'^\s*ARG\s+NPM_VERSION=(\d+\.\d+\.\d+)\s*$', text, re.MULTILINE) | |
| 1768 | if match: | |
| 1769 | print(f'Dockerfile.dev|{match.group(1)}') | |
| 1770 | except OSError: | |
| 1771 | pass | |
| 1772 | " "$root" "$dockerfile" 2>/dev/null | |
| 1773 | } | |
| 1774 | ||
| 1775 | # extract_cdk_cli_pins [root] [dockerfile] | |
| 1776 | # | |
| 1777 | # Emits the root tooling graph's aws-cdk version and Dockerfile.dev's global | |
| 1778 | # CDK CLI pin. Both execution paths must synthesize with the same CLI release. | |
| 1779 | extract_cdk_cli_pins() { | |
| 1780 | 28 | local root="${1:-.}" |
| 1781 | 28 | local dockerfile="${2:-Dockerfile.dev}" |
| 1782 | 28 | python3 -c " |
| 1783 | import json, re, sys | |
| 1784 | from pathlib import Path | |
| 1785 | ||
| 1786 | root = Path(sys.argv[1]).resolve() | |
| 1787 | try: | |
| 1788 | package = json.loads((root / 'package.json').read_text(encoding='utf-8')) | |
| 1789 | version = (package.get('devDependencies') or {}).get('aws-cdk', '') | |
| 1790 | if re.fullmatch(r'\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?', version): | |
| 1791 | print(f'package.json|{version}') | |
| 1792 | except (OSError, json.JSONDecodeError): | |
| 1793 | pass | |
| 1794 | docker = Path(sys.argv[2]) | |
| 1795 | if not docker.is_absolute(): | |
| 1796 | docker = root / docker | |
| 1797 | try: | |
| 1798 | text = docker.read_text(encoding='utf-8') | |
| 1799 | match = re.search(r'^\s*ARG\s+CDK_VERSION=(\S+)\s*$', text, re.MULTILINE) | |
| 1800 | if match: | |
| 1801 | print(f'Dockerfile.dev|{match.group(1)}') | |
| 1802 | except OSError: | |
| 1803 | pass | |
| 1804 | " "$root" "$dockerfile" 2>/dev/null | |
| 1805 | } | |
| 1806 | ||
| 1807 | # parse_suppression_expiries <file> | |
| 1808 | # | |
| 1809 | # Prints ``ID|YYYY-MM-DD`` for every dated suppression entry in a | |
| 1810 | # ``.trivyignore`` / ``.pip-audit-ignore`` / ``.npm-audit-ignore`` file (any | |
| 1811 | # non-comment line carrying an ``exp:YYYY-MM-DD`` marker). For the | |
| 1812 | # whitespace-delimited trivy/pip format the ID is the first token; for the | |
| 1813 | # npm-audit pipe format (``package-dir|package|advisory|node-path|exp:…``) | |
| 1814 | # the ID is the advisory field, so the report names the GHSA rather than the | |
| 1815 | # whole entry line. The caller computes days-to-expiry and surfaces entries | |
| 1816 | # expiring soon so they get renewed *before* the CI expiry validator hard- | |
| 1817 | # fails a PR — the report is the early warning, the validator is the gate. | |
| 1818 | # | |
| 1819 | # Empty output when the file is absent or has no dated entries. | |
| 1820 | parse_suppression_expiries() { | |
| 1821 | 78 | local file="$1" |
| 1822 | 79 | [ -f "$file" ] || return 0 |
| 1823 | 77 | python3 -c " |
| 1824 | import re, sys | |
| 1825 | with open(sys.argv[1]) as f: | |
| 1826 | for line in f: | |
| 1827 | s = line.strip() | |
| 1828 | if not s or s.startswith('#'): | |
| 1829 | continue | |
| 1830 | m = re.search(r'exp:(\d{4}-\d{2}-\d{2})', s) | |
| 1831 | if not m: | |
| 1832 | continue | |
| 1833 | fields = s.split('|') | |
| 1834 | if len(fields) == 5: | |
| 1835 | # .npm-audit-ignore pipe format — the advisory is field 3. | |
| 1836 | ident = fields[2] | |
| 1837 | else: | |
| 1838 | ident = s.split()[0] | |
| 1839 | print(f'{ident}|{m.group(1)}') | |
| 1840 | " "$file" 2>/dev/null | |
| 1841 | } | |
| 1842 | ||
| 1843 | # extract_helm_installer_pins [dockerfile] | |
| 1844 | # | |
| 1845 | # Prints the tool versions hardcoded in the helm-installer Lambda's | |
| 1846 | # Dockerfile RUN lines: | |
| 1847 | # HELM_VERSION|vX.Y.Z from the get.helm.sh download URL | |
| 1848 | # KUBECTL_VERSION|vX.Y.Z from the dl.k8s.io download URL | |
| 1849 | # | |
| 1850 | # These pins are RUN-line URL literals — not ARGs, ``FROM`` lines, or | |
| 1851 | # workflow env — so Dependabot, the Dockerfile.dev ARG sweep, and the | |
| 1852 | # workflow-env consistency check were all blind to them. The consistency | |
| 1853 | # section compares them against the HELM_VERSION / KUBECTL_VERSION workflow | |
| 1854 | # env pins (and Dockerfile.dev's KUBECTL_VERSION ARG) so the Lambda image, | |
| 1855 | # the CI installs, and the dev container can't silently disagree about | |
| 1856 | # which helm/kubectl they ship. | |
| 1857 | # | |
| 1858 | # Empty output when the file is absent or the URLs aren't found — callers | |
| 1859 | # treat that as "skip", matching every other extractor here. | |
| 1860 | 32 | extract_helm_installer_pins() { |
| 1861 | 146 | local file="${1:-lambda/helm-installer/Dockerfile}" |
| 1862 | 154 | [ -f "$file" ] || return 0 |
| 1863 | 138 | python3 -c " |
| 1864 | import re, sys | |
| 1865 | with open(sys.argv[1]) as f: | |
| 1866 | text = f.read() | |
| 1867 | # Versions from the download URLs; checksums from the sha256sum trust | |
| 1868 | # anchor bound to each download's output path. These four lines are the | |
| 1869 | # single Helm/kubectl source the CI jobs load into GITHUB_ENV — the | |
| 1870 | # workflows deliberately carry no literal copies of their own. | |
| 1871 | m = re.search(r'get\.helm\.sh/helm-(v\d+\.\d+\.\d+)-', text) | |
| 1872 | if m: | |
| 1873 | print(f'HELM_VERSION|{m.group(1)}') | |
| 1874 | m = re.search(r'([0-9a-f]{64})\s+/tmp/helm\.tar\.gz', text) | |
| 1875 | if m: | |
| 1876 | print(f'HELM_SHA256|{m.group(1)}') | |
| 1877 | m = re.search(r'dl\.k8s\.io/release/(v\d+\.\d+\.\d+)/', text) | |
| 1878 | if m: | |
| 1879 | print(f'KUBECTL_VERSION|{m.group(1)}') | |
| 1880 | m = re.search(r'([0-9a-f]{64})\s+/tmp/kubectl', text) | |
| 1881 | if m: | |
| 1882 | print(f'KUBECTL_SHA256|{m.group(1)}') | |
| 1883 | " "$file" 2>/dev/null | |
| 1884 | } | |
| 1885 | ||
| 1886 | # dependency_scan_is_complete <incomplete-reasons-file> [skip-reason ...] | |
| 1887 | # | |
| 1888 | # Returns success only when the durable incomplete-reason channel exists and | |
| 1889 | # is empty and every section-specific skip reason is empty. The driver uses | |
| 1890 | # this single predicate for its ``scan_complete`` output, so a failed lookup, | |
| 1891 | # parser, or explicit skip can never close the rolling dependency issue. | |
| 1892 | dependency_scan_is_complete() { | |
| 1893 | 30 | local incomplete_reasons_file="$1" |
| 1894 | 30 | shift |
| 1895 | ||
| 1896 | 31 | [ -f "$incomplete_reasons_file" ] || return 1 |
| 1897 | 41 | [ ! -s "$incomplete_reasons_file" ] || return 1 |
| 1898 | ||
| 1899 | 17 | local skip_reason |
| 1900 | 135 | for skip_reason in "$@"; do |
| 1901 | 140 | [ -z "$skip_reason" ] || return 1 |
| 1902 | done | |
| 1903 | } | |
| 1904 | ||
| 1905 | # check_lockfile_freshness [pyproject] [lockfile] | |
| 1906 | # | |
| 1907 | # Prints ``normalised-name[;marker]|expected-version|locked-version`` for each | |
| 1908 | # exact direct dependency whose compiled lock entry is missing or has a | |
| 1909 | # different version. ``locked-version`` is ``<missing>`` when no matching | |
| 1910 | # name-and-marker entry exists. Both base and optional dependency groups are | |
| 1911 | # checked; self-referential ``gco-cli`` extras are intentionally excluded. | |
| 1912 | # | |
| 1913 | # Whitespace around ``==`` and marker operators is insignificant. Marker text | |
| 1914 | # remains part of the comparison identity so mutually exclusive platform pins | |
| 1915 | # can carry different exact versions without being treated as conflicts. | |
| 1916 | # Returns nonzero for missing/malformed input, non-exact direct dependencies, | |
| 1917 | # conflicting pins with the same marker, or unparseable lock records. | |
| 1918 | check_lockfile_freshness() { | |
| 1919 | 43 | local pyproject="${1:-pyproject.toml}" |
| 1920 | 43 | local lockfile="${2:-requirements-lock.txt}" |
| 1921 | 43 | python3 -c " |
| 1922 | import re, sys, tomllib | |
| 1923 | ||
| 1924 | pyproject, lockfile = sys.argv[1], sys.argv[2] | |
| 1925 | ||
| 1926 | def fail(message): | |
| 1927 | print(f'lockfile freshness error: {message}', file=sys.stderr) | |
| 1928 | raise SystemExit(2) | |
| 1929 | ||
| 1930 | try: | |
| 1931 | with open(pyproject, 'rb') as handle: | |
| 1932 | data = tomllib.load(handle) | |
| 1933 | except (OSError, tomllib.TOMLDecodeError) as exc: | |
| 1934 | fail(f'cannot read {pyproject}: {exc}') | |
| 1935 | ||
| 1936 | project = data.get('project') | |
| 1937 | if not isinstance(project, dict): | |
| 1938 | fail(f'{pyproject} has no valid [project] table') | |
| 1939 | base_dependencies = project.get('dependencies', []) | |
| 1940 | optional_dependencies = project.get('optional-dependencies', {}) | |
| 1941 | if not isinstance(base_dependencies, list) or not isinstance(optional_dependencies, dict): | |
| 1942 | fail(f'{pyproject} dependency tables have invalid types') | |
| 1943 | ||
| 1944 | def norm(name): | |
| 1945 | return re.sub(r'[-_.]+', '-', name).lower() | |
| 1946 | ||
| 1947 | def canonical_marker(marker, source): | |
| 1948 | marker = (marker or '').strip() | |
| 1949 | if not marker: | |
| 1950 | return '' | |
| 1951 | if '|' in marker: | |
| 1952 | fail(f'{source} marker contains an unsupported pipe character') | |
| 1953 | result = [] | |
| 1954 | quote = None | |
| 1955 | escaped = False | |
| 1956 | for character in marker: | |
| 1957 | if quote is not None: | |
| 1958 | result.append(character) | |
| 1959 | if escaped: | |
| 1960 | escaped = False | |
| 1961 | elif character == '\\\\': | |
| 1962 | escaped = True | |
| 1963 | elif character == quote: | |
| 1964 | # PEP 508 treats single- and double-quoted marker values as | |
| 1965 | # equivalent. Emit one quote style for identity comparison. | |
| 1966 | result[-1] = '\"' | |
| 1967 | quote = None | |
| 1968 | elif character in ('\"', \"'\"): | |
| 1969 | quote = character | |
| 1970 | result.append('\"') | |
| 1971 | elif not character.isspace(): | |
| 1972 | result.append(character) | |
| 1973 | if quote is not None: | |
| 1974 | fail(f'{source} marker has an unterminated quoted value') | |
| 1975 | return ''.join(result) | |
| 1976 | ||
| 1977 | exact_requirement = re.compile( | |
| 1978 | r'\\s*(?P<name>[A-Za-z0-9][A-Za-z0-9._-]*)\\s*' | |
| 1979 | r'(?:\\[\\s*[A-Za-z0-9._-]+(?:\\s*,\\s*[A-Za-z0-9._-]+)*\\s*\\])?\\s*' | |
| 1980 | r'==\\s*(?P<version>[^\\s;]+)\\s*' | |
| 1981 | r'(?:;\\s*(?P<marker>.+?))?\\s*' | |
| 1982 | ) | |
| 1983 | # A concrete PEP-440-shaped literal may carry an epoch, pre/post/dev suffix, | |
| 1984 | # or local version, but never a wildcard, another comparator, or a comma- | |
| 1985 | # separated clause. This deliberately rejects ``===`` arbitrary equality. | |
| 1986 | concrete_version = re.compile(r'v?(?:[0-9]+!)?[0-9]+[A-Za-z0-9._+-]*') | |
| 1987 | ||
| 1988 | def parse_exact(spec, source): | |
| 1989 | if not isinstance(spec, str): | |
| 1990 | fail(f'{source} contains a non-string dependency') | |
| 1991 | name_match = re.match(r'^\\s*([A-Za-z0-9][A-Za-z0-9._-]*)', spec) | |
| 1992 | if not name_match: | |
| 1993 | fail(f'{source} contains an invalid dependency: {spec!r}') | |
| 1994 | name = norm(name_match.group(1)) | |
| 1995 | if name == 'gco-cli': | |
| 1996 | return None | |
| 1997 | match = exact_requirement.fullmatch(spec) | |
| 1998 | if not match: | |
| 1999 | fail(f'{source} dependency is not an exact == pin: {spec!r}') | |
| 2000 | version = match.group('version') | |
| 2001 | if not concrete_version.fullmatch(version): | |
| 2002 | fail(f'{source} dependency is not a concrete exact == pin: {spec!r}') | |
| 2003 | marker = canonical_marker(match.group('marker'), source) | |
| 2004 | return (name, marker), version | |
| 2005 | ||
| 2006 | def add_pin(pins, parsed, source): | |
| 2007 | if parsed is None: | |
| 2008 | return | |
| 2009 | identity, version = parsed | |
| 2010 | previous = pins.get(identity) | |
| 2011 | if previous is not None and previous != version: | |
| 2012 | display = identity[0] + (f';{identity[1]}' if identity[1] else '') | |
| 2013 | fail(f'{display} has conflicting pins in {source}: {previous} and {version}') | |
| 2014 | pins[identity] = version | |
| 2015 | ||
| 2016 | expected = {} | |
| 2017 | for dependency in base_dependencies: | |
| 2018 | add_pin( | |
| 2019 | expected, | |
| 2020 | parse_exact(dependency, '[project].dependencies'), | |
| 2021 | '[project].dependencies', | |
| 2022 | ) | |
| 2023 | for group, dependencies in optional_dependencies.items(): | |
| 2024 | if not isinstance(dependencies, list): | |
| 2025 | fail(f'[project.optional-dependencies].{group} is not an array') | |
| 2026 | source = f'[project.optional-dependencies].{group}' | |
| 2027 | for dependency in dependencies: | |
| 2028 | add_pin(expected, parse_exact(dependency, source), source) | |
| 2029 | if not expected: | |
| 2030 | fail(f'{pyproject} contains no exact direct dependency pins') | |
| 2031 | ||
| 2032 | locked = {} | |
| 2033 | try: | |
| 2034 | with open(lockfile, encoding='utf-8') as handle: | |
| 2035 | for line_number, raw_line in enumerate(handle, 1): | |
| 2036 | candidate = raw_line.strip() | |
| 2037 | if not candidate or candidate.startswith(('#', '-')): | |
| 2038 | continue | |
| 2039 | candidate = candidate.split(' #', 1)[0].rstrip() | |
| 2040 | if candidate.endswith('\\\\'): | |
| 2041 | candidate = candidate[:-1].rstrip() | |
| 2042 | source = f'{lockfile}:{line_number}' | |
| 2043 | add_pin(locked, parse_exact(candidate, source), source) | |
| 2044 | except OSError as exc: | |
| 2045 | fail(f'cannot read {lockfile}: {exc}') | |
| 2046 | if not locked: | |
| 2047 | fail(f'{lockfile} contains no pinned requirements') | |
| 2048 | ||
| 2049 | for identity, expected_version in sorted(expected.items()): | |
| 2050 | locked_version = locked.get(identity) | |
| 2051 | if locked_version != expected_version: | |
| 2052 | name, marker = identity | |
| 2053 | display = name + (f';{marker}' if marker else '') | |
| 2054 | print(f'{display}|{expected_version}|{locked_version or \"<missing>\"}') | |
| 2055 | " "$pyproject" "$lockfile" | |
| 2056 | } | |
| 2057 | ||
| 2058 | # extract_security_epochs <dockerfile> | |
| 2059 | # | |
| 2060 | # Prints ``ARGNAME|YYYY-MM-DD`` for each build-time security-refresh epoch | |
| 2061 | # ARG in the given Dockerfile (``APT_SECURITY_EPOCH`` for the Debian service | |
| 2062 | # images / dev image, ``DNF_SECURITY_EPOCH`` for the AL2023 helm-installer | |
| 2063 | # Lambda). These dates are bumped by hand to bust the CI layer cache and pull | |
| 2064 | # freshly-published OS security patches; nothing else reminds anyone to move | |
| 2065 | # them, so the report flags an epoch older than the freshness window. Trivy's | |
| 2066 | # container scan is the backstop; this is the proactive nudge. | |
| 2067 | # | |
| 2068 | # Empty output when the file is absent or pins no epoch ARG. | |
| 2069 | extract_security_epochs() { | |
| 2070 | 89 | local file="$1" |
| 2071 | 90 | [ -f "$file" ] || return 0 |
| 2072 | 88 | python3 -c " |
| 2073 | import re, sys | |
| 2074 | with open(sys.argv[1]) as f: | |
| 2075 | for line in f: | |
| 2076 | stripped = line.split('#', 1)[0] | |
| 2077 | m = re.match(r'^\s*ARG\s+((?:APT|DNF)_SECURITY_EPOCH)=(\d{4}-\d{2}-\d{2})\s*$', stripped) | |
| 2078 | if m: | |
| 2079 | print(f'{m.group(1)}|{m.group(2)}') | |
| 2080 | " "$file" 2>/dev/null | |
| 2081 | } | |
| 2082 | ||
| 2083 | # extract_claude_code_pin [autopilot_py] | |
| 2084 | # | |
| 2085 | # Prints the exact Claude Code release ``gco autopilot`` installs, read from | |
| 2086 | # the ``CLAUDE_CODE_VERSION`` constant in ``cli/autopilot.py``. | |
| 2087 | # | |
| 2088 | # Like the Mooncake image above, this pin lives in a Python constant, so | |
| 2089 | # neither Dependabot nor the npm-graph sweep sees it — autopilot installs | |
| 2090 | # Claude Code lazily via ``npm install -g`` rather than declaring it in any | |
| 2091 | # package.json. This extractor feeds the autopilot-pins drift check so a | |
| 2092 | # newer npm release is surfaced in the monthly report. | |
| 2093 | # | |
| 2094 | # The textual contract (a single-line, double-quoted assignment) is locked | |
| 2095 | # by tests/test_cli_autopilot.py, so this regex and the constant cannot | |
| 2096 | # silently drift apart. Prints nothing if the file or constant is absent — | |
| 2097 | # the caller treats an empty result as "skip". | |
| 2098 | 17 | extract_claude_code_pin() { |
| 2099 | 62 | local file="${1:-cli/autopilot.py}" |
| 2100 | 67 | [ -f "$file" ] || return 0 |
| 2101 | 57 | python3 -c " |
| 2102 | import re, sys | |
| 2103 | with open(sys.argv[1]) as f: | |
| 2104 | text = f.read() | |
| 2105 | m = re.search(r'^CLAUDE_CODE_VERSION = \"([^\"]+)\"', text, re.MULTILINE) | |
| 2106 | if m: | |
| 2107 | print(m.group(1)) | |
| 2108 | " "$file" 2>/dev/null | |
| 2109 | } | |
| 2110 | ||
| 2111 | # extract_codex_pin [autopilot_py] | |
| 2112 | # | |
| 2113 | # Prints the exact OpenAI Codex CLI release the Codex Autopilot engine installs, | |
| 2114 | # read from the scanner-stable ``CODEX_VERSION`` literal in cli/autopilot.py. | |
| 2115 | # Codex is another lazy global npm dependency, so it must flow through the same | |
| 2116 | # monthly registry-drift system as Claude Code rather than package.json. | |
| 2117 | 17 | extract_codex_pin() { |
| 2118 | 62 | local file="${1:-cli/autopilot.py}" |
| 2119 | 67 | [ -f "$file" ] || return 0 |
| 2120 | 57 | python3 -c " |
| 2121 | import re, sys | |
| 2122 | with open(sys.argv[1]) as f: | |
| 2123 | text = f.read() | |
| 2124 | m = re.search(r'^CODEX_VERSION = \"([^\"]+)\"', text, re.MULTILINE) | |
| 2125 | if m: | |
| 2126 | print(m.group(1)) | |
| 2127 | " "$file" 2>/dev/null | |
| 2128 | } | |
| 2129 | ||
| 2130 | # extract_companion_mcp_packages [autopilot_py] | |
| 2131 | # | |
| 2132 | # Prints one ``name|registry|package`` line per companion MCP server in the | |
| 2133 | # ``COMPANION_MCP_SERVERS`` registry of ``cli/autopilot.py`` — the servers | |
| 2134 | # ``gco autopilot`` wires into every session. ``registry`` is ``npm`` or | |
| 2135 | # ``pypi``. | |
| 2136 | # | |
| 2137 | # These packages are launch-time dependencies fetched by npx/uvx, so they | |
| 2138 | # appear in no lockfile and no manifest Dependabot watches. The autopilot | |
| 2139 | # liveness check uses this list to verify each package is still published | |
| 2140 | # (and not deprecated/yanked) on its registry — exactly the failure mode | |
| 2141 | # that got mcp-server-fetch and mcp-server-calculator pruned in 2026-08. | |
| 2142 | # | |
| 2143 | # Parses the ``name=`` / ``registry=`` / ``package=`` keywords inside each | |
| 2144 | # ``CompanionServer(`` block textually (no imports), so BATS can exercise it | |
| 2145 | # against fixtures; tests/test_cli_autopilot.py locks the source formatting. | |
| 2146 | # Prints nothing for a missing or unparseable file. | |
| 2147 | extract_companion_mcp_packages() { | |
| 2148 | 28 | local file="${1:-cli/autopilot.py}" |
| 2149 | 29 | [ -f "$file" ] || return 0 |
| 2150 | 27 | python3 -c " |
| 2151 | import re, sys | |
| 2152 | with open(sys.argv[1]) as f: | |
| 2153 | text = f.read() | |
| 2154 | pattern = re.compile( | |
| 2155 | r'CompanionServer\(\s*name=\"([^\"]+)\",\s*registry=\"([^\"]+)\",\s*package=\"([^\"]+)\",' | |
| 2156 | ) | |
| 2157 | for name, registry, package in pattern.findall(text): | |
| 2158 | print(f'{name}|{registry}|{package}') | |
| 2159 | " "$file" 2>/dev/null | |
| 2160 | } | |
| 2161 | ||
| 2162 | # get_registry_package_status <registry> <package> | |
| 2163 | # | |
| 2164 | # Resolves a package's publication status on its public registry and prints | |
| 2165 | # exactly one of: | |
| 2166 | # | |
| 2167 | # ok|<latest-version> published and healthy | |
| 2168 | # missing| registry answers 404 — unpublished/renamed | |
| 2169 | # deprecated|<message> npm ``deprecated`` flag on the latest release | |
| 2170 | # yanked|<latest-version> PyPI ``yanked`` flag on the latest release | |
| 2171 | # | |
| 2172 | # Prints nothing on a network/transport failure or an unknown registry so | |
| 2173 | # the caller marks the check skipped instead of reporting phantom drift — | |
| 2174 | # the same fail-quiet contract as the other ``get_latest_*`` helpers here. | |
| 2175 | # Both endpoints are public; no credentials needed. | |
| 2176 | get_registry_package_status() { | |
| 2177 | 123 | local registry="$1" package="$2" |
| 2178 | 123 | local url="" body="" code="" |
| 2179 | 123 | case "$registry" in |
| 2180 | npm) | |
| 2181 | # Scoped packages need the slash percent-encoded on the | |
| 2182 | # per-version route (@scope%2Fname/latest). | |
| 2183 | 88 | url="https://registry.npmjs.org/${package//\//%2F}/latest" |
| 2184 | ;; | |
| 2185 | pypi) | |
| 2186 | 34 | url="https://pypi.org/pypi/${package}/json" |
| 2187 | ;; | |
| 2188 | *) | |
| 2189 | 1 | return 0 |
| 2190 | ;; | |
| 2191 | esac | |
| 2192 | ||
| 2193 | 244 | body="$(mktemp)" |
| 2194 | 250 | code="$(curl -sSL --max-time 15 -o "$body" -w '%{http_code}' "$url" 2>/dev/null)" || code="" |
| 2195 | 122 | if [ "$code" = "404" ]; then |
| 2196 | 3 | rm -f "$body" |
| 2197 | 3 | echo "missing|" |
| 2198 | 3 | return 0 |
| 2199 | fi | |
| 2200 | 119 | if [ "$code" != "200" ]; then |
| 2201 | 7 | rm -f "$body" |
| 2202 | 7 | return 0 |
| 2203 | fi | |
| 2204 | ||
| 2205 | 112 | python3 -c " |
| 2206 | import json, sys | |
| 2207 | registry, path = sys.argv[1], sys.argv[2] | |
| 2208 | try: | |
| 2209 | with open(path) as handle: | |
| 2210 | data = json.load(handle) | |
| 2211 | except Exception: | |
| 2212 | sys.exit(0) | |
| 2213 | if registry == 'npm': | |
| 2214 | deprecated = data.get('deprecated') | |
| 2215 | if deprecated: | |
| 2216 | print('deprecated|' + ' '.join(str(deprecated).split())[:120]) | |
| 2217 | else: | |
| 2218 | version = str(data.get('version', '') or '') | |
| 2219 | if version: | |
| 2220 | print('ok|' + version) | |
| 2221 | else: | |
| 2222 | info = data.get('info') or {} | |
| 2223 | urls = data.get('urls') or [] | |
| 2224 | yanked = bool(urls and urls[0].get('yanked')) | |
| 2225 | version = str(info.get('version', '') or '') | |
| 2226 | if version: | |
| 2227 | print(('yanked|' if yanked else 'ok|') + version) | |
| 2228 | " "$registry" "$body" 2>/dev/null | |
| 2229 | 112 | rm -f "$body" |
| 2230 | } | |
| 2231 | ||
| 2232 | # check_lambda_requirements_pins [root] [pyproject] [lockfile] | |
| 2233 | # | |
| 2234 | # Emits ``requirements-path|problem`` for every pin in a per-Lambda | |
| 2235 | # ``requirements.txt`` that disagrees with the version this repository | |
| 2236 | # actually resolves for that package. | |
| 2237 | # | |
| 2238 | # Each Lambda carries its own ``requirements.txt`` because it is packaged and | |
| 2239 | # deployed independently, so the same library is pinned in up to seven places: | |
| 2240 | # ``pyproject.toml`` (what the test suite and the CLI resolve), | |
| 2241 | # ``requirements-lock.txt`` (what pip-compile pins the whole graph to), and one | |
| 2242 | # copy per Lambda that declares it. Nothing watched the Lambda copies. A bump | |
| 2243 | # applied centrally therefore left them behind silently, and the handlers | |
| 2244 | # shipped a different boto3 than anything CI ever exercised — the exact drift | |
| 2245 | # class the rest of this library exists to catch, on the one surface that | |
| 2246 | # reaches production directly. | |
| 2247 | # | |
| 2248 | # Resolution order for the authoritative version, most specific first: | |
| 2249 | # 1. ``[project].dependencies`` — the packages we pin deliberately | |
| 2250 | # 2. ``[project.optional-dependencies]`` — group-only pins | |
| 2251 | # 3. ``requirements-lock.txt`` — transitives (cryptography, …) that | |
| 2252 | # no pyproject entry names but the repository still resolves exactly | |
| 2253 | # A package found in none of the three is a Lambda-only dependency with no | |
| 2254 | # central copy to disagree with, so it is skipped rather than reported. | |
| 2255 | # | |
| 2256 | # Only ``lambda/<name>/requirements.txt`` is read. The generated ``*-build`` | |
| 2257 | # staging bundles copy their requirements from the source directory, so | |
| 2258 | # including them would double-report every finding when they happen to exist | |
| 2259 | # locally and report nothing in CI, where they do not. | |
| 2260 | # | |
| 2261 | # A requirements file that pins nothing (three of them only document that the | |
| 2262 | # Lambda runtime supplies boto3) is a skip, not a finding. A missing or | |
| 2263 | # unparseable ``pyproject.toml`` is reported, because it always exists here and | |
| 2264 | # a parse break must not silently downgrade this to a pass. | |
| 2265 | # | |
| 2266 | # Example output: | |
| 2267 | # | |
| 2268 | # lambda/secret-rotation/requirements.txt|boto3==1.43.74 must match pyproject.toml 1.43.85 | |
| 2269 | # | |
| 2270 | # The PR-time half of this contract lives in | |
| 2271 | # tests/test_integration.py::TestDependencyVersionConsistency::test_lambda_requirements_match_pyproject. | |
| 2272 | check_lambda_requirements_pins() { | |
| 2273 | 35 | local root="${1:-.}" |
| 2274 | 35 | local pyproject="${2:-pyproject.toml}" |
| 2275 | 35 | local lockfile="${3:-requirements-lock.txt}" |
| 2276 | 35 | python3 -c " |
| 2277 | import re, sys, tomllib | |
| 2278 | from pathlib import Path | |
| 2279 | ||
| 2280 | root = Path(sys.argv[1]).resolve() | |
| 2281 | ||
| 2282 | def resolve(path): | |
| 2283 | candidate = Path(path) | |
| 2284 | return candidate if candidate.is_absolute() else root / candidate | |
| 2285 | ||
| 2286 | def normalize(name): | |
| 2287 | # PEP 503 normalisation: lowercase, ``_`` + ``.`` -> ``-``. | |
| 2288 | return re.sub(r'[-_.]+', '-', name).lower() | |
| 2289 | ||
| 2290 | pin = re.compile(r'([A-Za-z0-9][A-Za-z0-9._-]*)\s*==\s*([^\s;]+)') | |
| 2291 | ||
| 2292 | try: | |
| 2293 | with open(resolve(sys.argv[2]), 'rb') as handle: | |
| 2294 | data = tomllib.load(handle) | |
| 2295 | except Exception: | |
| 2296 | print('pyproject.toml|missing or unparseable, cannot verify Lambda pins') | |
| 2297 | sys.exit(0) | |
| 2298 | ||
| 2299 | project = data.get('project', {}) or {} | |
| 2300 | ||
| 2301 | def collect(specs): | |
| 2302 | found = {} | |
| 2303 | for spec in specs or []: | |
| 2304 | if not isinstance(spec, str): | |
| 2305 | continue | |
| 2306 | match = pin.fullmatch(spec.strip()) | |
| 2307 | if match: | |
| 2308 | found.setdefault(normalize(match.group(1)), set()).add(match.group(2)) | |
| 2309 | return found | |
| 2310 | ||
| 2311 | direct = collect(project.get('dependencies')) | |
| 2312 | optional = {} | |
| 2313 | for group in (project.get('optional-dependencies', {}) or {}).values(): | |
| 2314 | for name, versions in collect(group).items(): | |
| 2315 | optional.setdefault(name, set()).update(versions) | |
| 2316 | ||
| 2317 | locked = {} | |
| 2318 | try: | |
| 2319 | for line in resolve(sys.argv[3]).read_text(encoding='utf-8').splitlines(): | |
| 2320 | # Lock annotations are indented ('' # via boto3''); pins are not. | |
| 2321 | if not line or line[:1].isspace() or line.lstrip().startswith('#'): | |
| 2322 | continue | |
| 2323 | match = pin.match(line.split('#', 1)[0].strip()) | |
| 2324 | if match: | |
| 2325 | locked.setdefault(normalize(match.group(1)), set()).add(match.group(2)) | |
| 2326 | except OSError: | |
| 2327 | pass | |
| 2328 | ||
| 2329 | def authority(name): | |
| 2330 | for label, table in ( | |
| 2331 | ('pyproject.toml', direct), | |
| 2332 | ('pyproject.toml optional groups', optional), | |
| 2333 | ('requirements-lock.txt', locked), | |
| 2334 | ): | |
| 2335 | if name in table: | |
| 2336 | return label, table[name] | |
| 2337 | return None, None | |
| 2338 | ||
| 2339 | def owned(path): | |
| 2340 | return not any(part.endswith('-build') for part in path.relative_to(root).parts[:-1]) | |
| 2341 | ||
| 2342 | for requirements in sorted((root / 'lambda').glob('*/requirements.txt')): | |
| 2343 | if not owned(requirements): | |
| 2344 | continue | |
| 2345 | relative = requirements.relative_to(root).as_posix() | |
| 2346 | try: | |
| 2347 | lines = requirements.read_text(encoding='utf-8').splitlines() | |
| 2348 | except OSError: | |
| 2349 | print(relative + '|unreadable, cannot verify Lambda pins') | |
| 2350 | continue | |
| 2351 | for line in lines: | |
| 2352 | entry = line.split('#', 1)[0].strip() | |
| 2353 | if not entry: | |
| 2354 | continue | |
| 2355 | match = pin.fullmatch(entry) | |
| 2356 | if not match: | |
| 2357 | continue | |
| 2358 | name, version = normalize(match.group(1)), match.group(2) | |
| 2359 | label, expected = authority(name) | |
| 2360 | if not expected or version in expected: | |
| 2361 | continue | |
| 2362 | want = ','.join(sorted(expected)) | |
| 2363 | print(f'{relative}|{name}=={version} must match {label} {want}') | |
| 2364 | " "$root" "$pyproject" "$lockfile" 2>/dev/null | |
| 2365 | } | |
| 2366 | ||
| 2367 | # check_image_digest_consistency [root] | |
| 2368 | # | |
| 2369 | # Emits ``image|problem`` for every ``repo:tag`` this repository pins to two | |
| 2370 | # different ``@sha256:`` digests — the tag was re-pushed upstream and only some | |
| 2371 | # of the copies were refreshed. | |
| 2372 | # | |
| 2373 | # This exists because the drift sections answer "is this pin behind upstream?" | |
| 2374 | # and the version-consistency checks answer "do the copies of a *named* pin | |
| 2375 | # agree?", but neither could see a pin restated as a bare literal elsewhere in | |
| 2376 | # the tree. That is the class that keeps escaping: the #317 sweep refreshed the | |
| 2377 | # python-slim digest in a smoke manifest while a test kept the previous one, and | |
| 2378 | # it surfaced only when the CI shard holding that test happened to run. | |
| 2379 | # | |
| 2380 | # Deliberately narrow. Two broader variants were written and measured against | |
| 2381 | # this tree before being cut, because both were pure noise here: | |
| 2382 | # | |
| 2383 | # * "same repo, different tags" reported python at 3.14, 3.14.6-slim and | |
| 2384 | # 3.14.7-slim for three legitimate purposes, busybox pinned in examples | |
| 2385 | # versus ``:latest`` in property-test fixtures, and several synthetic | |
| 2386 | # Volcano tags — about fifteen rows, none of them drift. | |
| 2387 | # * "digest-pinned here but named at a bare tag there" reported only fixture | |
| 2388 | # image strings and the quoted registry-timeout error messages in the | |
| 2389 | # ``build-image-with-retry`` docs. | |
| 2390 | # | |
| 2391 | # A section that is mostly noise trains readers to skip it. Two digests under | |
| 2392 | # one tag, by contrast, cannot be anything but a copy someone missed: a fixture | |
| 2393 | # does not invent a real 64-hex digest. Images that need tag-level coverage get | |
| 2394 | # a dedicated guard instead — see ``tests/test_pinned_floci_version.py``. | |
| 2395 | # | |
| 2396 | # Excludes generated trees, sibling git worktrees, recorded terminal casts, and | |
| 2397 | # the generated flowchart HTML: all of them hold point-in-time copies of source | |
| 2398 | # that are not pins. | |
| 2399 | # | |
| 2400 | # Example output: | |
| 2401 | # | |
| 2402 | # docker.io/library/python|tag 3.14.7-slim has 2 digests: 656d12e7… in a.yaml, ce407646… in b.py | |
| 2403 | # | |
| 2404 | # Prints nothing when every digest-pinned image agrees with itself. | |
| 2405 | check_image_digest_consistency() { | |
| 2406 | 32 | local root="${1:-.}" |
| 2407 | 32 | python3 -c " |
| 2408 | import re, sys | |
| 2409 | from pathlib import Path | |
| 2410 | from collections import defaultdict | |
| 2411 | ||
| 2412 | root = Path(sys.argv[1]).resolve() | |
| 2413 | excluded = { | |
| 2414 | '.git', '.kiro', '.mypy_cache', '.pytest_cache', '.ruff_cache', '.worktrees', | |
| 2415 | 'build', 'cdk.out', 'dist', 'node_modules', '__pycache__', 'site', | |
| 2416 | '.example-job-validation', | |
| 2417 | } | |
| 2418 | suffixes = {'.py', '.yaml', '.yml', '.md', '.sh', '.txt', '.toml'} | |
| 2419 | ||
| 2420 | def owned(path): | |
| 2421 | parts = path.relative_to(root).parts | |
| 2422 | if any(p in excluded or p.startswith('.venv') or p.endswith('-build') for p in parts[:-1]): | |
| 2423 | return False | |
| 2424 | if parts and parts[0] in excluded: | |
| 2425 | return False | |
| 2426 | # Generated flowchart HTML embeds a copy of the source it charts. | |
| 2427 | if parts and parts[0] == 'diagrams' and path.suffix != '.py': | |
| 2428 | return False | |
| 2429 | return path.suffix in suffixes or 'ockerfile' in path.name | |
| 2430 | ||
| 2431 | # repo:tag[@sha256:digest]. The tag must look like a version so ordinary | |
| 2432 | # ''key: value'' text and ''host:port'' pairs are never read as images. | |
| 2433 | reference = re.compile( | |
| 2434 | r'(?<![\w./:-])' | |
| 2435 | r'([a-z0-9][a-z0-9._-]*(?:/[a-z0-9][a-z0-9._-]*)+)' | |
| 2436 | r':(v?[0-9][A-Za-z0-9._-]*)' | |
| 2437 | r'(?:@sha256:([0-9a-f]{64}))?' | |
| 2438 | ) | |
| 2439 | ||
| 2440 | pinned = defaultdict(lambda: defaultdict(set)) # repo -> tag -> {(digest, file)} | |
| 2441 | ||
| 2442 | for path in sorted(root.rglob('*')): | |
| 2443 | if not path.is_file() or not owned(path): | |
| 2444 | continue | |
| 2445 | try: | |
| 2446 | text = path.read_text(encoding='utf-8') | |
| 2447 | except (OSError, UnicodeDecodeError): | |
| 2448 | continue | |
| 2449 | relative = path.relative_to(root).as_posix() | |
| 2450 | # Rejoin references split across adjacent string literals. The stale copy | |
| 2451 | # that caused the incident was written exactly this way — | |
| 2452 | # ''docker.io/library/python:3.14.7-slim@'' on one line and | |
| 2453 | # ''sha256:656d…'' on the next — so a line-at-a-time matcher saw no pin at | |
| 2454 | # all and reported nothing. Only the seam around the digest is rejoined, so | |
| 2455 | # unrelated adjacent literals cannot be welded into a false reference. | |
| 2456 | text = re.sub(r'@[\"\x27][\s,]*[\"\x27]sha256:', '@sha256:', text) | |
| 2457 | text = re.sub(r'[\"\x27][\s,]*[\"\x27]@sha256:', '@sha256:', text) | |
| 2458 | for line in text.splitlines(): | |
| 2459 | for match in reference.finditer(line): | |
| 2460 | repo, tag, digest = match.group(1), match.group(2), match.group(3) | |
| 2461 | prefix = line[: match.start()] | |
| 2462 | # ''https://host/path:1.2'' is a URL, not an image reference. | |
| 2463 | if '://' in prefix[-12:] or prefix.endswith('//'): | |
| 2464 | continue | |
| 2465 | if digest: | |
| 2466 | pinned[repo][tag].add((digest, relative)) | |
| 2467 | ||
| 2468 | def listing(items): | |
| 2469 | return ', '.join(sorted(items)) | |
| 2470 | ||
| 2471 | for repo in sorted(pinned): | |
| 2472 | for tag in sorted(pinned[repo]): | |
| 2473 | seen = pinned[repo][tag] | |
| 2474 | distinct = sorted({digest for digest, _ in seen}) | |
| 2475 | if len(distinct) > 1: | |
| 2476 | detail = ', '.join( | |
| 2477 | d[:8] + '… in ' + listing(f for g, f in seen if g == d) for d in distinct | |
| 2478 | ) | |
| 2479 | print(f'{repo}|tag {tag} has {len(distinct)} digests: {detail}') | |
| 2480 | ||
| 2481 | " "$root" 2>/dev/null | |
| 2482 | } |