diff --git a/.github/workflows/pipeline-tests.yml b/.github/workflows/pipeline-tests.yml index 0b9476f..fae8dbd 100644 --- a/.github/workflows/pipeline-tests.yml +++ b/.github/workflows/pipeline-tests.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v7 with: - python-version: 3.x + python-version: "3.14" - name: Set up uv uses: astral-sh/setup-uv@v7 diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 956195b..871c067 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -63,7 +63,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v7 with: - python-version: "3.x" + python-version: "3.14" - name: Set up Rust uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # 2026-09-03, no release tag - name: Set up uv diff --git a/.github/workflows/signature-update-alerts.yml b/.github/workflows/signature-update-alerts.yml new file mode 100644 index 0000000..2e7e47a --- /dev/null +++ b/.github/workflows/signature-update-alerts.yml @@ -0,0 +1,74 @@ +name: Signature Update Alerts + +# Opens a GitHub issue when the daily signature update fails, comments on it if it keeps failing, +# and closes it when the update next succeeds. +# The name below must match `name:` in daily-update.yml exactly, or alerting silently stops. +# workflow_run only fires from this file on the default branch (stable). +on: + workflow_run: + workflows: [Daily Signature Update] + types: [completed] + +permissions: + actions: read + issues: write + +concurrency: + group: signature-update-alerts + cancel-in-progress: false + +jobs: + alert: + runs-on: ubuntu-24.04 + if: contains(fromJSON('["failure", "timed_out", "startup_failure", "success"]'), github.event.workflow_run.conclusion) + steps: + - name: Open, update, or close the alert issue + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + CONCLUSION: ${{ github.event.workflow_run.conclusion }} + RUN_ID: ${{ github.event.workflow_run.id }} + RUN_URL: ${{ github.event.workflow_run.html_url }} + RUN_EVENT: ${{ github.event.workflow_run.event }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + TITLE="Daily signature update failing" + LABEL="signature-update-failure" + + ISSUE=$(gh issue list --repo "$REPO" --label "$LABEL" --state open --json number,title \ + | jq -r --arg t "$TITLE" '[.[] | select(.title == $t)][0].number // empty') + + if [ "$CONCLUSION" = "success" ]; then + if [ -n "$ISSUE" ]; then + gh issue comment "$ISSUE" --repo "$REPO" --body "Recovered in $RUN_URL" + gh issue close "$ISSUE" --repo "$REPO" + fi + exit 0 + fi + + FAILED=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs" --paginate \ + --jq '.jobs[] | select(.conclusion == "failure") | "- `\(.name)`: \([.steps[]? | select(.conclusion == "failure") | .name] | join(", "))"') + FAILED=${FAILED:-"- (no failed job details available)"} + + BODY=$(cat < threshold: print(f"Error count ({error_count}) exceeds threshold ({threshold})") return 1 @@ -17,5 +18,19 @@ def main(): return 0 +def write_step_summary(errors, threshold): + """Write the error list to the GitHub Actions job summary, if running in Actions.""" + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_path: + return + lines = [f"## Signature update: {len(errors)} errors (threshold {threshold})", ""] + for error in errors: + # errors can carry multi-line tracebacks; keep only the first line in the summary + first_line = (str(error).strip().splitlines() or [""])[0] + lines.append(f"- {first_line}") + with open(summary_path, "a") as f: + f.write("\n".join(lines) + "\n") + + if __name__ == "__main__": sys.exit(main()) diff --git a/cloudcheck_update/test_cloudcheck_update.py b/cloudcheck_update/test_cloudcheck_update.py index fbf5408..774aa76 100644 --- a/cloudcheck_update/test_cloudcheck_update.py +++ b/cloudcheck_update/test_cloudcheck_update.py @@ -281,3 +281,56 @@ def test_cidr_defragmentation_mixed_ipv4_ipv6(): ) print("Mixed IPv4/IPv6 CIDR defragmentation test passed!") + + +def test_provider_crash_counts_as_error(): + """A provider whose update() raises must be reported as an error, not dropped silently.""" + from cloudcheck_update import _update_provider + + class CrashingProvider(Amazon): + def update(self): + raise RuntimeError("source unreachable") + + name, provider, errors = _update_provider(CrashingProvider) + assert name is None + assert provider is None + assert len(errors) == 1 + assert "CrashingProvider crashed: source unreachable" in errors[0] + + +def test_step_summary(tmp_path, monkeypatch): + """Errors are written to the GitHub Actions job summary, one line each.""" + from cloudcheck_update.cli import write_step_summary + + summary = tmp_path / "summary.md" + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary)) + write_step_summary(["first error\nTraceback line", "second error"], threshold=10) + content = summary.read_text() + assert "2 errors (threshold 10)" in content + assert "- first error\n" in content + assert "Traceback line" not in content + assert "- second error\n" in content + + +def test_zscaler_skips_non_cloud_keys(monkeypatch): + """Zscaler's feed has a top-level "svpnIPs" list next to the cloud dicts; it must not break parsing.""" + from cloudcheck.providers.zscaler import Zscaler + + class FakeResponse: + status_code = 200 + + def json(self): + return { + "zscaler.net": { + "continent : EMEA": { + "city : Abu Dhabi II": [ + {"range": "159.254.250.0/23"}, + {"range": ""}, + ] + } + }, + "svpnIPs": ["165.225.240.56"], + } + + monkeypatch.setattr(Zscaler, "request", lambda self, *a, **kw: FakeResponse()) + assert Zscaler().fetch_cidrs() == ["159.254.250.0/23"]