Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .github/workflows/model-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,71 @@ jobs:
- run: pip install pyyaml jsonschema
- name: Validate OpenDEAM model (schema + integrity)
run: python3 scripts/validate_model.py

catalog-smoke-test:
# CR-CATALOG-STRUCT-07c: detect drift between model status and
# catalog content. Runs the vendored cross-repo consumer against
# the four conformant adopters (BP/BC/DBSF/SH) and verifies each
# model entity with catalog_repo pointing at a known adopter has
# backing content consistent with its status field.
#
# Hard failures (missing catalog, fetch error, schema mismatch)
# fail CI. Soft warnings (status drift) are surfaced in the
# output but do not block; the consumer operator triages them.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pyyaml
- name: Pre-populate cache from each conformant adopter's main
# Populate the cache from the upstream `main` branch of each
# known adopter so the smoke test runs offline. CI does NOT
# do live fetches; if the cache is missing, the smoke test
# surfaces that as a FAIL (the adoption has regressed).
#
# The raw.githubusercontent.com CDN can lag by 30-60s after
# a push; the loop retries up to 5 times with exponential
# backoff before giving up. This is robust against the
# known CDN-cache-miss window that bites immediately after
# a fresh merge.
run: |
mkdir -p .cache/cross_repo_consumer
for repo in \
dea-catalog-processes \
dea-catalog-business-capabilities \
dea-catalog-digital-business-service-factory \
dea-catalog-stakeholders; do
url="https://raw.githubusercontent.com/technehub-labs/${repo}/main/CATALOG.yaml"
out=".cache/cross_repo_consumer/${repo}@main.yaml"
echo "fetching ${url} -> ${out}"
ok=0
for attempt in 1 2 3 4 5; do
if curl --fail --silent --show-error --location --max-time 15 "${url}" -o "${out}"; then
lines=$(wc -l < "${out}")
echo " ok on attempt ${attempt}: ${lines} lines"
ok=1
break
else
echo " attempt ${attempt} failed (likely CDN cache miss); retrying in $((attempt * 10))s"
sleep $((attempt * 10))
fi
done
if [ "${ok}" -ne 1 ]; then
echo " WARN: fetch failed for ${repo} after 5 attempts (will surface as FAIL in smoke test)"
rm -f "${out}"
fi
done
- name: Run cross-repo smoke test (model status vs. catalog content)
# --skip-unreachable: in CI we may not have credentials for
# private repos (DBSF and stakeholders are private). The smoke
# test degrades gracefully: it emits INFO notes for unreachable
# repos rather than failing CI. Local developer with a PAT
# can run without --skip-unreachable to validate them all.
run: |
PYTHONPATH=scripts python3 scripts/check_catalog_index_matches_model.py \
--model model/opendeam-model.yaml \
--cache-dir .cache/cross_repo_consumer \
--offline \
--skip-unreachable
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
__pycache__/
*.pyc
.DS_Store
.cache/
51 changes: 49 additions & 2 deletions scripts/check_catalog_index_matches_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,16 @@ def run_smoke(
*,
offline: bool = False,
timeout_s: float = 15.0,
treat_fetch_failure_as_skip: bool = False,
) -> tuple[int, int, list[str]]:
"""Run the smoke test; return (fail_count, warn_count, finding_lines)."""
"""Run the smoke test; return (fail_count, warn_count, finding_lines).

Args:
treat_fetch_failure_as_skip: When True, a fetch failure for a
known adopter does NOT count as a fail. Instead it emits
an INFO note and is skipped. Useful in CI environments
that cannot reach private repos without a PAT.
"""
fail_count = 0
warn_count = 0
findings: list[str] = []
Expand Down Expand Up @@ -208,16 +216,44 @@ def run_smoke(
timeout_s=timeout_s,
offline=offline,
)
catalogs[repo] = parse_catalog_yaml(fetch.bytes)
except Exception as exc: # noqa: BLE001 (CLI surface; surfacing all)
# Fetch-stage errors are the only ones that can be skipped.
# A 404 (private repo no PAT), a cache miss in offline mode,
# or a CDN timeout all land here.
if treat_fetch_failure_as_skip:
findings.append(
f"INFO: {repo}: skipped (fetch error: "
f"{type(exc).__name__}: {exc})"
)
continue
findings.append(f"FAIL: {repo}: {type(exc).__name__}: {exc}")
fail_count += 1
continue
try:
catalogs[repo] = parse_catalog_yaml(fetch.bytes)
except Exception as exc: # noqa: BLE001 (CLI surface; surfacing all)
# Parse-stage errors are schema/content integrity issues.
# They are NOT skipped by --skip-unreachable; a broken
# catalog that was successfully fetched is still a real
# failure that the operator must fix.
findings.append(f"FAIL: {repo}: parse error: "
f"{type(exc).__name__}: {exc}")
fail_count += 1

# Per-entity checks.
for entity in entities:
if not entity_relevant_to_smoke(entity):
continue
entity_id, repo, entity_findings = check_one_entity(entity, catalogs)
# If the catalog was skipped (not in catalogs), all findings
# for this entity are SKIPPED at the entity level rather than
# FAIL (the consumer can't reach the repo, so it can't validate).
if repo not in catalogs:
findings.append(
f" {entity_id} ({repo}): SKIPPED (catalog not reachable; "
f"verify manually with a PAT)"
)
continue
for finding in entity_findings:
findings.append(f" {entity_id} ({repo}): {finding}")
if finding.startswith("FAIL"):
Expand Down Expand Up @@ -254,6 +290,16 @@ def _build_parser() -> argparse.ArgumentParser:
action="store_true",
help="Do not fetch; only read from --cache-dir.",
)
p.add_argument(
"--skip-unreachable",
action="store_true",
help=(
"Treat fetch failures as SKIP (do not fail CI). Useful when "
"some known adopters are private and the CI runner has no "
"PAT. The skipped repos are listed in the output so the "
"consumer operator can verify them manually with credentials."
),
)
p.add_argument(
"--timeout",
type=float,
Expand All @@ -273,6 +319,7 @@ def main(argv: list[str] | None = None) -> int:
cache_dir=args.cache_dir,
offline=args.offline,
timeout_s=args.timeout,
treat_fetch_failure_as_skip=args.skip_unreachable,
)
for line in findings:
print(line)
Expand Down
59 changes: 59 additions & 0 deletions tests/test_check_catalog_index_matches_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,65 @@ def test_model_with_no_known_adopters_returns_clean(
assert warn == 0
assert any("nothing to smoke" in f for f in findings)

def test_skip_unreachable_treats_fetch_failures_as_skip(
self, tmp_path: Path
) -> None:
"""With treat_fetch_failure_as_skip=True, a missing catalog
file is logged as INFO rather than FAIL."""
model = tmp_path / "model.yaml"
model.write_text(
"allocation:\n entities:\n"
" - entity_id: dea:entity-x\n"
" catalog_repo: dea-catalog-processes\n"
" status: existing\n"
)
# Cache is empty: processes the catalog will not be found.
cache = tmp_path / "empty"
cache.mkdir()
fail, warn, findings = smoke.run_smoke(
model,
cache_dir=cache,
offline=True,
treat_fetch_failure_as_skip=True,
)
assert fail == 0
assert warn == 0
assert any(
"INFO: dea-catalog-processes: skipped" in f for f in findings
)
assert any(
"dea:entity-x" in f and "SKIPPED" in f for f in findings
)

def test_skip_unreachable_does_not_mask_schema_errors(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A schema parse error (catalog exists but is broken YAML)
is still a FAIL even with treat_fetch_failure_as_skip=True.
Only fetch failures are skipped; parse errors propagate."""
model = tmp_path / "model.yaml"
model.write_text(
"allocation:\n entities:\n"
" - entity_id: dea:entity-x\n"
" catalog_repo: dea-catalog-processes\n"
" status: existing\n"
)
cache = tmp_path / "cache"
cache.mkdir()
# A valid YAML document, but with the WRONG shape: parse_catalog_yaml
# will reject this. This proves fetch-skip does not mask parse failures.
(cache / "dea-catalog-processes@main.yaml").write_text(
"not_a_catalog_root: true\n"
)
fail, _, findings = smoke.run_smoke(
model,
cache_dir=cache,
offline=True,
treat_fetch_failure_as_skip=True,
)
assert fail > 0
assert any("FAIL" in f for f in findings)


class TestStatusContentMismatch:
"""The status/content drift checks (the smoke test's primary value)."""
Expand Down
Loading