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
2 changes: 1 addition & 1 deletion .github/workflows/pipeline-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions .github/workflows/signature-update-alerts.yml
Original file line number Diff line number Diff line change
@@ -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 <<EOF
The daily signature update did not succeed.

- Conclusion: \`$CONCLUSION\`
- Run: $RUN_URL
- Trigger: \`$RUN_EVENT\` on \`$HEAD_BRANCH\` @ \`${HEAD_SHA:0:7}\`

Failed jobs and steps:
$FAILED

The run's job summary lists the provider errors.
EOF
)

if [ -n "$ISSUE" ]; then
gh issue comment "$ISSUE" --repo "$REPO" --body "$BODY"
else
gh label create "$LABEL" --repo "$REPO" --color B60205 \
--description "Daily signature update is failing" --force
gh issue create --repo "$REPO" --title "$TITLE" --label "$LABEL" --body "$BODY"
fi
3 changes: 3 additions & 0 deletions cloudcheck/providers/zscaler.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ def fetch_cidrs(self):
response_json = response.json()
if isinstance(response_json, dict):
for domain, data in response_json.items():
# skip non-cloud keys like "svpnIPs" (a flat list of IPs already covered by the ranges)
if not isinstance(data, dict):
continue
for continent, cities in data.items():
for city, ranges_list in cities.items():
for range_data in ranges_list:
Expand Down
7 changes: 4 additions & 3 deletions cloudcheck_update/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ def _update_provider(provider_class):
return (provider.name, provider, provider_errors or [])
except Exception as e:
print(
f"Failed to update provider {provider_class.name}: {e}\n{traceback.format_exc()}"
f"Failed to update provider {provider_class.__name__}: {e}\n{traceback.format_exc()}"
)
return (None, None, [])
# count the crash as an error so it can't pass silently; the provider's existing data is kept
return (None, None, [f"Provider {provider_class.__name__} crashed: {e}"])


def update():
Expand All @@ -47,9 +48,9 @@ def update():
for error in provider_errors:
print(f" - Error: {error}")
print(f" Pending ({len(pending)}): {', '.join(sorted(pending))}")
errors.extend(provider_errors)
if name and provider:
providers[name] = provider
errors.extend(provider_errors)
print("Exiting for loop...")
print("Exited executor context")
print(f"Finished updating {len(providers):,} providers")
Expand Down
15 changes: 15 additions & 0 deletions cloudcheck_update/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ def main():
for error in errors:
print(error)
error_count = len(errors)
write_step_summary(errors, threshold)
if error_count > threshold:
print(f"Error count ({error_count}) exceeds threshold ({threshold})")
return 1
Expand All @@ -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())
53 changes: 53 additions & 0 deletions cloudcheck_update/test_cloudcheck_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading