mirror of
https://github.com/cisagov/ScubaGoggles.git
synced 2026-07-17 00:47:28 +02:00
Creates OPA update workflow (#966)
Run CI Pipeline for ScubaGoggles / Pylint (push) Has been cancelled
Run CI Pipeline for ScubaGoggles / Run OPA Tests (push) Has been cancelled
Run CI Pipeline for ScubaGoggles / Run Markdown Link Check (push) Has been cancelled
Run CI Pipeline for ScubaGoggles / Run Python Tests (push) Has been cancelled
Run Link Checker for Cron Job / markdown-link-check (push) Has been cancelled
Run Smoke Test / configuration (push) Has been cancelled
Update OPA Version Dependency / Check for OPA Update (push) Has been cancelled
Run Smoke Test / smoke-test (push) Has been cancelled
Run Smoke Test / lhci (push) Has been cancelled
Update OPA Version Dependency / Update OPA and Create PR (push) Has been cancelled
Run CI Pipeline for ScubaGoggles / Pylint (push) Has been cancelled
Run CI Pipeline for ScubaGoggles / Run OPA Tests (push) Has been cancelled
Run CI Pipeline for ScubaGoggles / Run Markdown Link Check (push) Has been cancelled
Run CI Pipeline for ScubaGoggles / Run Python Tests (push) Has been cancelled
Run Link Checker for Cron Job / markdown-link-check (push) Has been cancelled
Run Smoke Test / configuration (push) Has been cancelled
Update OPA Version Dependency / Check for OPA Update (push) Has been cancelled
Run Smoke Test / smoke-test (push) Has been cancelled
Run Smoke Test / lhci (push) Has been cancelled
Update OPA Version Dependency / Update OPA and Create PR (push) Has been cancelled
* Creates OPA update workflow * fix linter errors * temporarily added a push trigger to test * Fixing workflow error * updated workflow * updating PR template * Updated summary for pr * fixing regex * Removed push trigger * Updated setup so it doesn't use OPA latest * pyproject: exclude "utils" directory from package * Update .github/workflows/run_update_opa.yml Co-authored-by: Roy Lane <rlane@mitre.org> --------- Co-authored-by: Roy Lane <rlane@mitre.org>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
# Purpose: Automatically check for new OPA releases and update ScubaGoggles'
|
||||
# pinned OPA version dependency. Creates a PR with the version bump and
|
||||
# triggers regression testing.
|
||||
|
||||
name: Update OPA Version Dependency
|
||||
|
||||
on:
|
||||
|
||||
schedule:
|
||||
# Run Monday through Friday at 2:11 AM UTC (10:11 PM EDT)
|
||||
- cron: "11 2 * * 1-5"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
check-opa-update:
|
||||
name: Check for OPA Update
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
update_required: ${{ steps.check.outputs.update_required }}
|
||||
current_version: ${{ steps.check.outputs.current_version }}
|
||||
latest_version: ${{ steps.check.outputs.latest_version }}
|
||||
branch_name: ${{ steps.check.outputs.branch_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Check if OPA update is required
|
||||
id: check
|
||||
run: python utils/workflow/update_opa.py check
|
||||
|
||||
update-opa:
|
||||
name: Update OPA and Create PR
|
||||
needs: check-opa-update
|
||||
if: needs.check-opa-update.outputs.update_required == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
actions: write
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CURRENT_VERSION: ${{ needs.check-opa-update.outputs.current_version }}
|
||||
LATEST_VERSION: ${{ needs.check-opa-update.outputs.latest_version }}
|
||||
BRANCH_NAME: ${{ needs.check-opa-update.outputs.branch_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Setup OPA
|
||||
uses: open-policy-agent/setup-opa@v2
|
||||
with:
|
||||
version: v${{ env.LATEST_VERSION }}
|
||||
|
||||
- name: Update OPA version in scuba_constants.py
|
||||
run: |
|
||||
python utils/workflow/update_opa.py update \
|
||||
--current "$CURRENT_VERSION" \
|
||||
--latest "$LATEST_VERSION"
|
||||
|
||||
- name: Update OPA version in documentation
|
||||
run: |
|
||||
python utils/workflow/update_opa.py update-docs \
|
||||
--latest "$LATEST_VERSION"
|
||||
|
||||
- name: Run OPA Rego unit tests
|
||||
id: unit-tests
|
||||
run: python utils/workflow/update_opa.py test
|
||||
|
||||
- name: Create branch, commit, and open PR
|
||||
run: |
|
||||
git config user.email "action@github.com"
|
||||
git config user.name "GitHub Action"
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
git add -u
|
||||
git commit -m "Bump OPA version from v${CURRENT_VERSION} to v${LATEST_VERSION}"
|
||||
git push origin "$BRANCH_NAME"
|
||||
|
||||
TEST_SUMMARY="${{ steps.unit-tests.outputs.test_summary }}"
|
||||
|
||||
PR_BODY="## Description
|
||||
|
||||
This pull request was automatically created by a GitHub Action to bump
|
||||
ScubaGoggles' Open Policy Agent (OPA) version dependency.
|
||||
|
||||
- Updates \`OPA_VERSION\` in \`scubagoggles/scuba_constants.py\` from
|
||||
\`v${CURRENT_VERSION}\` to \`v${LATEST_VERSION}\`
|
||||
- Updates OPA version references in documentation
|
||||
|
||||
### Motivation and context
|
||||
|
||||
Keep ScubaGoggles aligned with the latest OPA release.
|
||||
See [OPA releases](https://github.com/open-policy-agent/opa/releases).
|
||||
|
||||
## Testing
|
||||
|
||||
Rego Unit Test results for OPA v${LATEST_VERSION}:
|
||||
|
||||
\`\`\`
|
||||
${TEST_SUMMARY}
|
||||
\`\`\`
|
||||
|
||||
- A smoke test is also being run against this branch. Check the
|
||||
[latest smoke test](https://github.com/${{ github.repository }}/actions/workflows/run_smoke_test.yml)
|
||||
targeting branch: \`${BRANCH_NAME}\`.
|
||||
|
||||
## Pre-approval checklist
|
||||
|
||||
- [x] This PR has an informative and human-readable title.
|
||||
- [x] Changes are limited to a single goal.
|
||||
- [x] All relevant repo and/or project documentation has been updated to reflect the changes in this PR.
|
||||
- [x] Tests have been added and/or modified to cover the changes in this PR.
|
||||
- [x] All new and existing tests pass."
|
||||
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head "$BRANCH_NAME" \
|
||||
--title "Bump OPA version from v${CURRENT_VERSION} to v${LATEST_VERSION}" \
|
||||
--body "$PR_BODY" \
|
||||
--label "version bump"
|
||||
|
||||
- name: Trigger smoke test workflow
|
||||
run: |
|
||||
gh workflow run run_smoke_test.yml \
|
||||
--ref "$BRANCH_NAME" \
|
||||
-f "opa-version=$LATEST_VERSION"
|
||||
@@ -37,3 +37,4 @@ version = {attr = "scubagoggles.__version__"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
exclude = ["utils*"]
|
||||
|
||||
@@ -13,6 +13,7 @@ from pathlib import Path
|
||||
|
||||
from scubagoggles.getopa import download_opa, opa_filespec
|
||||
from scubagoggles.run_rego import find_opa
|
||||
from scubagoggles.scuba_constants import OPA_VERSION
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -206,7 +207,7 @@ def create_dir_download_opa(opa_dir: Path, create_dir: bool, download: bool):
|
||||
opa = opa_filespec(opa_dir)
|
||||
if not opa.exists():
|
||||
print(f'OPA executable not present, downloading: {opa.name}')
|
||||
download_opa(opa_dir, verify = True)
|
||||
download_opa(opa_dir, version = OPA_VERSION, verify = True)
|
||||
|
||||
def validate_opa_dir(opa_dir: Path = None):
|
||||
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Utility functions for automating OPA version dependency management.
|
||||
|
||||
Used by the run_update_opa GitHub Actions workflow to check for new OPA
|
||||
releases, update the pinned version in scuba_constants.py, and create a
|
||||
pull request with the change.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
SCUBA_CONSTANTS_PATH = REPO_ROOT / "scubagoggles" / "scuba_constants.py"
|
||||
OPA_RELEASES_API = "https://api.github.com/repos/open-policy-agent/opa/releases/latest"
|
||||
OPA_VERSION_PATTERN = re.compile(r"^OPA_VERSION\s*=\s*['\"]v([\d.]+)['\"]", re.MULTILINE)
|
||||
|
||||
|
||||
def get_current_opa_version() -> str:
|
||||
"""Read the current OPA_VERSION from scuba_constants.py.
|
||||
|
||||
:return: current OPA version string without the 'v' prefix (e.g. '1.13.1')
|
||||
"""
|
||||
contents = SCUBA_CONSTANTS_PATH.read_text(encoding="utf-8")
|
||||
match = OPA_VERSION_PATTERN.search(contents)
|
||||
if not match:
|
||||
raise RuntimeError(
|
||||
f"Could not find OPA_VERSION in {SCUBA_CONSTANTS_PATH}"
|
||||
)
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def get_latest_opa_version() -> str:
|
||||
"""Query the OPA GitHub releases API for the latest version.
|
||||
|
||||
:return: latest OPA version string without the 'v' prefix (e.g. '1.14.0')
|
||||
"""
|
||||
request = Request(OPA_RELEASES_API)
|
||||
request.add_header("Accept", "application/vnd.github+json")
|
||||
with urlopen(request) as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
tag = data["tag_name"]
|
||||
return tag.lstrip("v")
|
||||
|
||||
|
||||
def check_branch_exists(branch_name: str) -> bool:
|
||||
"""Check whether a remote branch already exists.
|
||||
|
||||
:param branch_name: the branch name to check
|
||||
:return: True if the remote branch exists
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["git", "ls-remote", "--exit-code", "--heads", "origin", branch_name],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def confirm_update_required() -> dict:
|
||||
"""Determine whether an OPA version bump is needed.
|
||||
|
||||
:return: dict with keys 'update_required', 'current_version',
|
||||
'latest_version', and 'branch_name'
|
||||
"""
|
||||
current = get_current_opa_version()
|
||||
latest = get_latest_opa_version()
|
||||
branch_name = f"opa-version-bump-{latest}"
|
||||
|
||||
current_parts = tuple(int(x) for x in current.split("."))
|
||||
latest_parts = tuple(int(x) for x in latest.split("."))
|
||||
|
||||
update_required = False
|
||||
summary = ""
|
||||
|
||||
if latest_parts > current_parts:
|
||||
if not check_branch_exists(branch_name):
|
||||
update_required = True
|
||||
summary = "OPA version update required."
|
||||
else:
|
||||
summary = (
|
||||
f"Update branch ({branch_name}) already exists; "
|
||||
"no update required."
|
||||
)
|
||||
else:
|
||||
summary = "OPA version is already up to date; no update required."
|
||||
|
||||
print(summary)
|
||||
return {
|
||||
"update_required": update_required,
|
||||
"current_version": current,
|
||||
"latest_version": latest,
|
||||
"branch_name": branch_name,
|
||||
}
|
||||
|
||||
|
||||
def update_opa_version(current_version: str, latest_version: str) -> None:
|
||||
"""Update OPA_VERSION in scuba_constants.py.
|
||||
|
||||
:param current_version: the old version string (without 'v' prefix)
|
||||
:param latest_version: the new version string (without 'v' prefix)
|
||||
"""
|
||||
contents = SCUBA_CONSTANTS_PATH.read_text(encoding="utf-8")
|
||||
old_value = f"OPA_VERSION = 'v{current_version}'"
|
||||
new_value = f"OPA_VERSION = 'v{latest_version}'"
|
||||
|
||||
if old_value not in contents:
|
||||
raise RuntimeError(
|
||||
f"Could not find '{old_value}' in {SCUBA_CONSTANTS_PATH}"
|
||||
)
|
||||
|
||||
updated = contents.replace(old_value, new_value, 1)
|
||||
SCUBA_CONSTANTS_PATH.write_text(updated, encoding="utf-8")
|
||||
print(f"Updated OPA_VERSION from v{current_version} to v{latest_version}")
|
||||
|
||||
|
||||
def update_opa_docs(latest_version: str) -> None:
|
||||
"""Update OPA version references in documentation files.
|
||||
|
||||
:param latest_version: the new version string (without 'v' prefix)
|
||||
"""
|
||||
docs_path = REPO_ROOT / "docs" / "installation" / "OPA.md"
|
||||
if not docs_path.exists():
|
||||
print(f"Warning: {docs_path} not found, skipping docs update")
|
||||
return
|
||||
|
||||
contents = docs_path.read_text(encoding="utf-8")
|
||||
version_re = re.compile(r"v\d+\.\d+\.\d+")
|
||||
updated = version_re.sub(f"v{latest_version}", contents)
|
||||
docs_path.write_text(updated, encoding="utf-8")
|
||||
print(f"Updated OPA version references in {docs_path}")
|
||||
|
||||
|
||||
def _parse_test_summary(output: str) -> str:
|
||||
"""Parse OPA verbose test output into a per-baseline summary.
|
||||
|
||||
Expects lines like ``data.<package>.<test_name>: PASS (1.234ms)``.
|
||||
|
||||
:param output: raw stdout from ``opa test -v``
|
||||
:return: formatted summary string
|
||||
"""
|
||||
result_re = re.compile(r"data\.(\w+)\.[^:]+:\s+(PASS|FAIL)")
|
||||
counts: dict[str, dict[str, int]] = {}
|
||||
|
||||
for match in result_re.finditer(output):
|
||||
package = match.group(1).capitalize()
|
||||
status = match.group(2)
|
||||
if package not in counts:
|
||||
counts[package] = {"PASS": 0, "FAIL": 0}
|
||||
counts[package][status] += 1
|
||||
|
||||
if not counts:
|
||||
return "No test results parsed."
|
||||
|
||||
lines = []
|
||||
total_pass = 0
|
||||
total_fail = 0
|
||||
for package in sorted(counts):
|
||||
passed = counts[package]["PASS"]
|
||||
failed = counts[package]["FAIL"]
|
||||
total = passed + failed
|
||||
total_pass += passed
|
||||
total_fail += failed
|
||||
status = "PASS" if failed == 0 else "FAIL"
|
||||
lines.append(
|
||||
f"======== Testing {package} ======== {status}: {passed}/{total}"
|
||||
)
|
||||
|
||||
overall = f"Total: {total_pass}/{total_pass + total_fail} passed"
|
||||
if total_fail:
|
||||
overall += f", {total_fail} failed"
|
||||
lines.append(overall)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def run_rego_unit_tests() -> str:
|
||||
"""Run the OPA Rego unit tests, exiting non-zero on failure.
|
||||
|
||||
Uses `opa test` directly (requires OPA on PATH, e.g. via
|
||||
open-policy-agent/setup-opa).
|
||||
|
||||
:return: formatted test summary string
|
||||
"""
|
||||
rego_dir = REPO_ROOT / "scubagoggles" / "rego"
|
||||
test_dir = REPO_ROOT / "scubagoggles" / "Testing" / "Unit" / "Rego"
|
||||
|
||||
rego_files = list(rego_dir.glob("*.rego"))
|
||||
test_files = list(test_dir.rglob("*.rego"))
|
||||
|
||||
cmd = ["opa", "test", "-v"] + [str(f) for f in rego_files + test_files]
|
||||
print(f"Running: opa test -v ({len(rego_files)} rego + {len(test_files)} test files)")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr)
|
||||
print(f"OPA unit tests failed (exit code {result.returncode})")
|
||||
sys.exit(result.returncode)
|
||||
|
||||
summary = _parse_test_summary(result.stdout)
|
||||
print(summary)
|
||||
return summary
|
||||
|
||||
|
||||
def set_github_output(name: str, value: str) -> None:
|
||||
"""Append a key=value pair to $GITHUB_OUTPUT for use in subsequent steps.
|
||||
|
||||
Handles multiline values using the heredoc delimiter syntax.
|
||||
|
||||
:param name: output variable name
|
||||
:param value: output variable value
|
||||
"""
|
||||
github_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if github_output:
|
||||
with open(github_output, "a", encoding="utf-8") as f:
|
||||
if "\n" in value:
|
||||
f.write(f"{name}<<EOFMARKER\n{value}\nEOFMARKER\n")
|
||||
else:
|
||||
f.write(f"{name}={value}\n")
|
||||
else:
|
||||
print(f" (local) {name}={value}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Parse arguments and dispatch to the appropriate subcommand."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="OPA version dependency management helpers"
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
subparsers.add_parser(
|
||||
"check",
|
||||
help="Check if an OPA version update is required",
|
||||
)
|
||||
|
||||
update_parser = subparsers.add_parser(
|
||||
"update",
|
||||
help="Update OPA_VERSION in scuba_constants.py",
|
||||
)
|
||||
update_parser.add_argument("--current", required=True)
|
||||
update_parser.add_argument("--latest", required=True)
|
||||
|
||||
update_docs_parser = subparsers.add_parser(
|
||||
"update-docs",
|
||||
help="Update OPA version references in docs",
|
||||
)
|
||||
update_docs_parser.add_argument("--latest", required=True)
|
||||
|
||||
subparsers.add_parser(
|
||||
"test",
|
||||
help="Run OPA Rego unit tests",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "check":
|
||||
result = confirm_update_required()
|
||||
set_github_output("update_required", str(result["update_required"]).lower())
|
||||
set_github_output("current_version", result["current_version"])
|
||||
set_github_output("latest_version", result["latest_version"])
|
||||
set_github_output("branch_name", result["branch_name"])
|
||||
|
||||
elif args.command == "update":
|
||||
update_opa_version(args.current, args.latest)
|
||||
|
||||
elif args.command == "update-docs":
|
||||
update_opa_docs(args.latest)
|
||||
|
||||
elif args.command == "test":
|
||||
summary = run_rego_unit_tests()
|
||||
set_github_output("test_summary", summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user