diff --git a/.github/scripts/update_debian_snapshot.py b/.github/scripts/update_debian_snapshot.py new file mode 100755 index 0000000000..ca6939e4cb --- /dev/null +++ b/.github/scripts/update_debian_snapshot.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Advance the agent-server Debian snapshot after a seven-day observation period.""" + +from __future__ import annotations + +import argparse +import re +import urllib.request +from datetime import UTC, datetime, timedelta +from pathlib import Path + + +MINIMUM_AGE = timedelta(days=7) +SNAPSHOT_RE = re.compile(r"(?m)^ARG DEBIAN_SNAPSHOT=(\d{8}T\d{6}Z)$") +ARCHIVES = ("debian", "debian-security") + + +def eligible_snapshot(now: datetime) -> datetime: + if now.tzinfo is None: + raise ValueError("now must be timezone-aware") + cutoff = now.astimezone(UTC) - MINIMUM_AGE + return cutoff.replace(hour=0, minute=0, second=0, microsecond=0) + + +def format_snapshot(value: datetime) -> str: + return value.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ") + + +def validate_snapshot_age(snapshot: datetime, now: datetime) -> None: + age = now.astimezone(UTC) - snapshot.astimezone(UTC) + if age < MINIMUM_AGE: + raise ValueError(f"snapshot is only {age} old; minimum age is {MINIMUM_AGE}") + + +def verify_snapshot(snapshot: str) -> None: + for archive in ARCHIVES: + url = f"https://snapshot.debian.org/archive/{archive}/{snapshot}/" + request = urllib.request.Request(url, method="HEAD") + try: + with urllib.request.urlopen(request, timeout=30) as response: + if response.status != 200: + raise ValueError(f"{url} returned HTTP {response.status}") + except OSError as exc: + raise ValueError(f"unable to verify {url}: {exc}") from exc + + +def update_dockerfile(path: Path, snapshot: str) -> bool: + text = path.read_text(encoding="utf-8") + matches = SNAPSHOT_RE.findall(text) + if len(matches) != 1: + raise ValueError(f"expected exactly one DEBIAN_SNAPSHOT in {path}") + updated = SNAPSHOT_RE.sub(f"ARG DEBIAN_SNAPSHOT={snapshot}", text) + if updated == text: + return False + path.write_text(updated, encoding="utf-8") + return True + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dockerfile", type=Path, required=True) + parser.add_argument( + "--now", + type=lambda value: datetime.fromisoformat(value.replace("Z", "+00:00")), + default=datetime.now(UTC), + help="UTC reference time for deterministic testing", + ) + parser.add_argument("--skip-network-check", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + snapshot_time = eligible_snapshot(args.now) + validate_snapshot_age(snapshot_time, args.now) + snapshot = format_snapshot(snapshot_time) + if not args.skip_network_check: + verify_snapshot(snapshot) + changed = update_dockerfile(args.dockerfile, snapshot) + print(f"Debian snapshot: {snapshot} ({'updated' if changed else 'unchanged'})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/update-debian-snapshot.yml b/.github/workflows/update-debian-snapshot.yml new file mode 100644 index 0000000000..078eac3ad6 --- /dev/null +++ b/.github/workflows/update-debian-snapshot.yml @@ -0,0 +1,88 @@ +--- +name: Update Canvas Debian Snapshot + +on: + schedule: + - cron: 17 6 * * 1 + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: update-canvas-debian-snapshot + cancel-in-progress: true + +jobs: + update-snapshot: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + GH_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }} + BRANCH: chore/update-canvas-debian-snapshot + DOCKERFILE: docker/Dockerfile + IMAGE: openhands/agent-canvas:snapshot-update + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }} + - name: Select newest snapshot older than seven days + run: python .github/scripts/update_debian_snapshot.py --dockerfile "$DOCKERFILE" + - name: Read image build defaults + id: defaults + run: | + echo "agent_server_image=$(node -p \"require('./config/defaults.json').images.agentServer + ':' + require('./config/defaults.json').versions.agentServer + '-python'\")" >> "$GITHUB_OUTPUT" + echo "agent_server_version=$(node -p \"require('./config/defaults.json').versions.agentServer\")" >> "$GITHUB_OUTPUT" + echo "automation_version=$(node -p \"require('./config/defaults.json').versions.automation\")" >> "$GITHUB_OUTPUT" + - name: Build image + run: | + docker build --tag "$IMAGE" --file "$DOCKERFILE" \ + --build-arg AGENT_SERVER_IMAGE="${{ steps.defaults.outputs.agent_server_image }}" \ + --build-arg AGENT_SERVER_VERSION="${{ steps.defaults.outputs.agent_server_version }}" \ + --build-arg AUTOMATION_VERSION="${{ steps.defaults.outputs.automation_version }}" . + - name: Scan image + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: ${{ env.IMAGE }} + format: json + output: trivy.json + scanners: vuln + - name: Open or refresh update PR + env: + REPO: ${{ github.repository }} + run: | + set -euo pipefail + if git diff --quiet; then + echo "Debian snapshot is already current." + exit 0 + fi + SNAPSHOT=$(sed -n 's/^ARG DEBIAN_SNAPSHOT=//p' "$DOCKERFILE") + TRIVY_COUNTS=$(jq -r '[.Results[]?.Vulnerabilities[]?] | group_by(.Severity) | map("\(.[0].Severity): \(length)") | join(", ")' trivy.json) + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git fetch origin "$BRANCH" || true + git checkout -B "$BRANCH" + git add "$DOCKERFILE" + git commit -m "chore(canvas): update Debian snapshot to $SNAPSHOT" \ + -m "Use the newest UTC snapshot that has completed the seven-day observation period." \ + -m "Co-authored-by: openhands " + git push --force-with-lease -u origin "$BRANCH" + BODY=$(cat </dev/null 2>&1; then \ + printf '%s\n' \ + 'Types: deb' \ + "URIs: http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}/" \ + 'Suites: trixie' \ + 'Components: main' \ + 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ + 'Check-Valid-Until: no' \ + '' \ + 'Types: deb' \ + "URIs: http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}/" \ + 'Suites: trixie-security' \ + 'Components: main' \ + 'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \ + 'Check-Valid-Until: no' \ + > /etc/apt/sources.list.d/debian.sources && \ apt-get update && \ apt-get install -y --no-install-recommends libpq-dev && \ + apt-get upgrade -y --no-install-recommends && \ rm -rf /var/lib/apt/lists/*; \ fi