diff --git a/.github/workflows/action-pin-sweep.yml b/.github/workflows/action-pin-sweep.yml index df5daf2..b9cf8fe 100644 --- a/.github/workflows/action-pin-sweep.yml +++ b/.github/workflows/action-pin-sweep.yml @@ -39,18 +39,35 @@ on: - '.github/workflows/action-pin-sweep.yml' concurrency: - group: action-pin-sweep-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false permissions: contents: read jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + EVENT_NAME: ${{ github.event_name }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + if [ "$EVENT_NAME" = 'pull_request' ]; then + exit 0 + fi + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + self-test: # A detector nobody has seen fire is a detector nobody should trust. These # tests prove it fires on the exact reference that stranded openadapt-evals # 0.91.0 on PyPI, and stays quiet on an accepted backlog entry. name: Prove the detector fires and stays quiet + needs: reject-lifecycle-app if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: @@ -66,7 +83,11 @@ jobs: sweep: name: Sweep every repository we own for an unpinned action - if: github.event_name != 'pull_request' + needs: reject-lifecycle-app + if: >- + github.event_name != 'pull_request' && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/azure-cost-guard.yml b/.github/workflows/azure-cost-guard.yml index dbcdd51..6cbb260 100644 --- a/.github/workflows/azure-cost-guard.yml +++ b/.github/workflows/azure-cost-guard.yml @@ -9,11 +9,27 @@ permissions: contents: read concurrency: - group: azure-cost-guard + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: false jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + report: + needs: reject-lifecycle-app + if: >- + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/db-backup-freshness.yml b/.github/workflows/db-backup-freshness.yml index d676a65..05e9976 100644 --- a/.github/workflows/db-backup-freshness.yml +++ b/.github/workflows/db-backup-freshness.yml @@ -13,11 +13,27 @@ permissions: contents: read concurrency: - group: production-db-backup-freshness + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: false jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + verify: + needs: reject-lifecycle-app + if: >- + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest timeout-minutes: 10 environment: production-backup-monitor @@ -110,8 +126,11 @@ jobs: record-alert: name: Keep one durable backup freshness alert - needs: verify - if: ${{ always() }} + needs: [reject-lifecycle-app, verify] + if: >- + always() && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/db-backup.yml b/.github/workflows/db-backup.yml index 3643168..30f65b1 100644 --- a/.github/workflows/db-backup.yml +++ b/.github/workflows/db-backup.yml @@ -17,11 +17,27 @@ permissions: contents: read concurrency: - group: production-db-backup + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: false jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + dump: + needs: reject-lifecycle-app + if: >- + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' # The complete production database is plaintext until the local age step. # Route this job only to the workflow-restricted production backup group. # GitHub-hosted runners and the default self-hosted group are not permitted. @@ -244,8 +260,11 @@ jobs: record-alert: name: Keep one durable backup alert - needs: dump - if: ${{ always() }} + needs: [reject-lifecycle-app, dump] + if: >- + always() && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/default-branch-sweep.yml b/.github/workflows/default-branch-sweep.yml index ff244e4..92aabac 100644 --- a/.github/workflows/default-branch-sweep.yml +++ b/.github/workflows/default-branch-sweep.yml @@ -33,18 +33,35 @@ on: - '.github/workflows/default-branch-sweep.yml' concurrency: - group: default-branch-sweep-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false permissions: contents: read jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + EVENT_NAME: ${{ github.event_name }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + if [ "$EVENT_NAME" = 'pull_request' ]; then + exit 0 + fi + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + self-test: # A detector nobody has seen fire is a detector nobody should trust. The # offline classification tests also run in Docs CI; running them here keeps # a change to the detector self-contained. name: Prove the classifier fires and stays quiet + needs: reject-lifecycle-app if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: @@ -60,7 +77,11 @@ jobs: sweep: name: Sweep every default branch we own - if: github.event_name != 'pull_request' + needs: reject-lifecycle-app + if: >- + github.event_name != 'pull_request' && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/prod-health-alert.yml b/.github/workflows/prod-health-alert.yml index 54b645a..23efb51 100644 --- a/.github/workflows/prod-health-alert.yml +++ b/.github/workflows/prod-health-alert.yml @@ -31,11 +31,27 @@ permissions: contents: read concurrency: - group: prod-health + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: false jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + probe: + needs: reject-lifecycle-app + if: >- + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -88,8 +104,11 @@ jobs: record-alert: name: Keep one durable production health alert - needs: probe - if: ${{ always() }} + needs: [reject-lifecycle-app, probe] + if: >- + always() && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/production-lifecycle-policy.yml b/.github/workflows/production-lifecycle-policy.yml index c22e3a9..7e726bf 100644 --- a/.github/workflows/production-lifecycle-policy.yml +++ b/.github/workflows/production-lifecycle-policy.yml @@ -5,36 +5,36 @@ on: - cron: "37 8 * * *" workflow_dispatch: pull_request: - paths: - - ".github/workflows/production-lifecycle-policy.yml" - - "docs/production-lifecycle.json" - - "docs/reference/production-lifecycle.md" - - "docs/schemas/production-lifecycle-public.schema.json" - - "mkdocs.yml" - - "production-lifecycle-source.json" - - "scripts/render_production_lifecycle.py" - - "tests/test_production_lifecycle_policy.py" push: branches: [main] - paths: - - ".github/workflows/production-lifecycle-policy.yml" - - "docs/production-lifecycle.json" - - "docs/reference/production-lifecycle.md" - - "docs/schemas/production-lifecycle-public.schema.json" - - "mkdocs.yml" - - "production-lifecycle-source.json" - - "scripts/render_production_lifecycle.py" - - "tests/test_production_lifecycle_policy.py" permissions: contents: read concurrency: - group: production-lifecycle-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + EVENT_NAME: ${{ github.event_name }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + if [ "$EVENT_NAME" = 'pull_request' ] || [ "$EVENT_NAME" = 'push' ]; then + exit 0 + fi + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + validate: + name: Validate Production lifecycle + needs: reject-lifecycle-app runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -51,10 +51,12 @@ jobs: run: python3 -m unittest tests.test_production_lifecycle_policy report-failure: - needs: validate + needs: [reject-lifecycle-app, validate] if: >- ${{ always() && needs.validate.result == 'failure' && - (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }} + (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' }} runs-on: ubuntu-latest permissions: issues: write diff --git a/.github/workflows/production-lifecycle-projection.yml b/.github/workflows/production-lifecycle-projection.yml new file mode 100644 index 0000000..aa3b513 --- /dev/null +++ b/.github/workflows/production-lifecycle-projection.yml @@ -0,0 +1,157 @@ +name: Production lifecycle feed source + +on: + workflow_dispatch: + inputs: + expected_old_commit: + description: Exact current feed commit; empty only for first creation + required: false + type: string + new_commit: + description: Exact .github main commit that contains the feed + required: true + type: string + feed_sha256: + description: Exact sha256 digest of production-lifecycle-feed.json + required: true + type: string + checkpoint_sha256: + description: Exact checkpoint object digest named by the feed + required: true + type: string + registry_head_sha256: + description: Exact content-addressed registry head digest + required: true + type: string + expires_at: + description: Exact feed expiry + required: true + type: string + idempotency_key: + description: Domain-bound lifecycle-feed-update idempotency key + required: true + type: string + +permissions: {} + +concurrency: + group: production-lifecycle-feed-source + cancel-in-progress: false + +jobs: + authorize-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Refuse any identity or source other than the exact lifecycle App on main + env: + ACTOR: ${{ github.actor }} + ACTOR_ID: ${{ github.actor_id }} + EXPECTED_ACTOR_ID: ${{ vars.OPENADAPT_LIFECYCLE_ACTOR_ID }} + EVENT_NAME: ${{ github.event_name }} + REF: ${{ github.ref }} + REPOSITORY: ${{ github.repository }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + set -euo pipefail + test "$REPOSITORY" = 'OpenAdaptAI/openadapt-ops' + test "$REF" = 'refs/heads/main' + test "$EVENT_NAME" = 'workflow_dispatch' + test "$ACTOR" = 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" = 'openadapt-lifecycle[bot]' + test -n "$EXPECTED_ACTOR_ID" + test "$ACTOR_ID" = "$EXPECTED_ACTOR_ID" + + dispatch-feed-update: + name: Dispatch one exact Production lifecycle feed update + needs: authorize-lifecycle-app + if: >- + github.repository == 'OpenAdaptAI/openadapt-ops' && + github.ref == 'refs/heads/main' && + github.event_name == 'workflow_dispatch' && + github.actor == 'openadapt-lifecycle[bot]' && + github.triggering_actor == 'openadapt-lifecycle[bot]' && + github.actor_id == vars.OPENADAPT_LIFECYCLE_ACTOR_ID + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: production-lifecycle-projection + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + + - name: Verify exact current protected Ops main and .github candidate ancestry + env: + GH_TOKEN: ${{ github.token }} + NEW_COMMIT: ${{ inputs.new_commit }} + run: | + set -euo pipefail + test "${GITHUB_REF}" = 'refs/heads/main' + test "${GITHUB_REF_PROTECTED}" = 'true' + test "${GITHUB_SHA}" = "$(gh api repos/OpenAdaptAI/openadapt-ops/commits/main --jq .sha)" + test "${NEW_COMMIT}" = "$(gh api repos/OpenAdaptAI/.github/commits/main --jq .sha)" + + - name: Build the closed canonical feed update + env: + CHECKPOINT_SHA256: ${{ inputs.checkpoint_sha256 }} + EXPECTED_OLD_COMMIT: ${{ inputs.expected_old_commit }} + EXPIRES_AT: ${{ inputs.expires_at }} + FEED_SHA256: ${{ inputs.feed_sha256 }} + IDEMPOTENCY_KEY: ${{ inputs.idempotency_key }} + NEW_COMMIT: ${{ inputs.new_commit }} + REGISTRY_HEAD_SHA256: ${{ inputs.registry_head_sha256 }} + run: | + set -euo pipefail + python scripts/prepare_production_lifecycle_projection.py \ + --source-commit "$GITHUB_SHA" \ + --expected-old-commit "$EXPECTED_OLD_COMMIT" \ + --new-commit "$NEW_COMMIT" \ + --feed-sha256 "$FEED_SHA256" \ + --checkpoint-sha256 "$CHECKPOINT_SHA256" \ + --registry-head-sha256 "$REGISTRY_HEAD_SHA256" \ + --expires-at "$EXPIRES_AT" \ + --idempotency-key "$IDEMPOTENCY_KEY" \ + --output /tmp/production-lifecycle-feed-update.json + + - name: Create the exact lifecycle App installation token + id: lifecycle-app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ vars.OPENADAPT_LIFECYCLE_APP_ID }} + private-key: ${{ secrets.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY }} + owner: OpenAdaptAI + repositories: | + .github + openadapt-evals + openadapt-ops + permission-actions: write + permission-contents: write + permission-metadata: read + permission-pull-requests: write + + - name: Verify the exact lifecycle App installation + env: + EXPECTED_APP_ID: ${{ vars.OPENADAPT_LIFECYCLE_APP_ID }} + EXPECTED_INSTALLATION_ID: ${{ vars.OPENADAPT_LIFECYCLE_INSTALLATION_ID }} + GH_TOKEN: ${{ steps.lifecycle-app.outputs.token }} + run: | + set -euo pipefail + test -n "$EXPECTED_APP_ID" + test -n "$EXPECTED_INSTALLATION_ID" + test "$(gh api /installation --jq '.app_id | tostring')" = "$EXPECTED_APP_ID" + test "$(gh api /installation --jq '.id | tostring')" = "$EXPECTED_INSTALLATION_ID" + + - name: Dispatch the protected feed ref producer once + env: + GH_TOKEN: ${{ steps.lifecycle-app.outputs.token }} + run: | + set -euo pipefail + feed_update_json=$(- ${{ always() && needs.check.result == 'failure' && (github.event_name == 'schedule' || - github.event_name == 'workflow_dispatch') }} + github.event_name == 'workflow_dispatch') && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' }} runs-on: ubuntu-latest permissions: issues: write diff --git a/.github/workflows/workspace-staleness-sweep.yml b/.github/workflows/workspace-staleness-sweep.yml index 038f7b0..3b4f062 100644 --- a/.github/workflows/workspace-staleness-sweep.yml +++ b/.github/workflows/workspace-staleness-sweep.yml @@ -63,18 +63,35 @@ on: - '.github/workflows/workspace-staleness-sweep.yml' concurrency: - group: workspace-staleness-sweep-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: false permissions: contents: read jobs: + reject-lifecycle-app: + permissions: {} + runs-on: ubuntu-latest + steps: + - name: Reject the lifecycle App + env: + ACTOR: ${{ github.actor }} + EVENT_NAME: ${{ github.event_name }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + run: | + if [ "$EVENT_NAME" = 'pull_request' ]; then + exit 0 + fi + test "$ACTOR" != 'openadapt-lifecycle[bot]' + test "$TRIGGERING_ACTOR" != 'openadapt-lifecycle[bot]' + self-test: # A detector nobody has seen fire is a detector nobody should trust. The # offline classification tests also run in Docs CI; running them here keeps # a change to the detector self-contained. name: Prove the classifiers fire and stay quiet + needs: reject-lifecycle-app if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: @@ -90,7 +107,11 @@ jobs: sweep: name: Sweep for stranded work - if: github.event_name != 'pull_request' + needs: reject-lifecycle-app + if: >- + github.event_name != 'pull_request' && + github.actor != 'openadapt-lifecycle[bot]' && + github.triggering_actor != 'openadapt-lifecycle[bot]' runs-on: ubuntu-latest permissions: contents: read diff --git a/scripts/database_backup_dispatch_reconciliation.py b/scripts/database_backup_dispatch_reconciliation.py new file mode 100755 index 0000000..e1d27f5 --- /dev/null +++ b/scripts/database_backup_dispatch_reconciliation.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Retain Cloud dispatch identity and sign exact NOT_RECEIVED resolutions.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import re +from collections.abc import Callable, Mapping, Sequence +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +INITIAL_EVENT = "verified_first_payment" +INITIAL_SCHEMA = "openadapt.database-backup-activation-dispatch/v1" +RENEWAL_EVENT = "database_backup_renewal" +RENEWAL_SCHEMA = "openadapt.database-backup-renewal-dispatch/v1" +RESOLUTION_SCHEMA = "openadapt.database-backup-dispatch-resolution/v1" +RESOLUTION_AUDIENCE = "openadapt-cloud:database-backup-dispatch-resolution" +OFFER_CONTRACT = "openadapt-cloud-managed-browser-v1" +SIGNATURE_ALGORITHM = "AWS-KMS-ECDSA-SHA256" +SIGNATURE_KEY_ID = "ops-backup-dispatch-resolution-kms-p256-2026-01" +KMS_ACCOUNT_ID = "992382684924" +KMS_REGION = "us-east-1" +KMS_ALIAS = "alias/openadapt-production-backup-dispatch-resolution" +KMS_KEY_SPEC = "ECC_NIST_P256" +KMS_KEY_USAGE = "SIGN_VERIFY" +KMS_SIGNING_ALGORITHM = "ECDSA_SHA_256" +HEX64 = re.compile(r"^[0-9a-f]{64}$") +ACTIVATION_ID = re.compile(r"^act_[0-9a-f]{64}$") +LEASE_EVENT_ID = re.compile(r"^lease_[0-9a-f]{64}$") +RESOLUTION_ID = re.compile(r"^resolution_[0-9a-f]{64}$") +CANDIDATE_FIELDS = { + "resolution_id", + "dispatch_kind", + "activation_id", + "organization_id_sha256", + "dispatch_attempt_id_sha256", + "dispatch_envelope_sha256", + "lease_event_id", + "prior_lease_sha256", + "requested_lease_sequence", + "last_error_code", + "reconciliation_required_at", +} + + +class DispatchContractError(ValueError): + """A dispatch identity or absence proof is incomplete or inconsistent.""" + + +def canonical_json(value: Mapping[str, Any]) -> bytes: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + + +def _sha256(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _decode_envelope(value: str) -> Mapping[str, Any]: + try: + raw = base64.b64decode(value, validate=True) + envelope = json.loads(raw) + except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise DispatchContractError( + "the signed envelope is not valid base64 JSON" + ) from exc + if not isinstance(envelope, Mapping): + raise DispatchContractError("the signed envelope is not an object") + return envelope + + +def retain_ingress( + event_name: str, + payload: Mapping[str, Any], + *, + github_repository: str, + github_run_id: str, + github_run_attempt: int, +) -> dict[str, Any]: + if github_repository != "OpenAdaptAI/openadapt-ops": + raise DispatchContractError("the GitHub repository identity is not exact") + if not github_run_id.isdigit() or github_run_attempt < 1: + raise DispatchContractError("the GitHub run identity is malformed") + if event_name == INITIAL_EVENT: + expected_fields = { + "schema", + "dispatch_attempt_id_sha256", + "dispatch_envelope_sha256", + "activation_request_b64", + } + if set(payload) != expected_fields or payload.get("schema") != INITIAL_SCHEMA: + raise DispatchContractError("the initial dispatch payload is not exact") + envelope_b64 = payload["activation_request_b64"] + kind = "INITIAL_ACTIVATION" + identity_parent = "request" + identity_name = "activation_id" + lease_event_id = None + elif event_name == RENEWAL_EVENT: + expected_fields = { + "schema", + "lease_event_id", + "dispatch_attempt_id_sha256", + "dispatch_envelope_sha256", + "continuation_assertion_b64", + } + if set(payload) != expected_fields or payload.get("schema") != RENEWAL_SCHEMA: + raise DispatchContractError("the renewal dispatch payload is not exact") + envelope_b64 = payload["continuation_assertion_b64"] + kind = "LEASE_RENEWAL" + identity_parent = "assertion" + identity_name = "lease_event_id" + lease_event_id = payload["lease_event_id"] + if ( + not isinstance(lease_event_id, str) + or LEASE_EVENT_ID.fullmatch(lease_event_id) is None + ): + raise DispatchContractError("the renewal lease event identity is malformed") + else: + raise DispatchContractError("the dispatch event type is not authorized") + attempt_digest = payload.get("dispatch_attempt_id_sha256") + envelope_digest = payload.get("dispatch_envelope_sha256") + if not isinstance(attempt_digest, str) or HEX64.fullmatch(attempt_digest) is None: + raise DispatchContractError("the dispatch attempt digest is malformed") + if not isinstance(envelope_digest, str) or HEX64.fullmatch(envelope_digest) is None: + raise DispatchContractError("the dispatch envelope digest is malformed") + if not isinstance(envelope_b64, str): + raise DispatchContractError("the signed envelope is missing") + envelope = _decode_envelope(envelope_b64) + if _sha256(canonical_json(envelope)) != envelope_digest: + raise DispatchContractError("the dispatch envelope digest does not match") + identity = envelope.get(identity_parent) + if not isinstance(identity, Mapping): + raise DispatchContractError("the signed envelope identity is missing") + embedded_identity = identity.get(identity_name) + if kind == "INITIAL_ACTIVATION": + if ( + not isinstance(embedded_identity, str) + or ACTIVATION_ID.fullmatch(embedded_identity) is None + ): + raise DispatchContractError("the activation identity is malformed") + activation_id = embedded_identity + else: + if embedded_identity != lease_event_id: + raise DispatchContractError("the lease event identity does not match") + activation_id = identity.get("activation_id") + if ( + not isinstance(activation_id, str) + or ACTIVATION_ID.fullmatch(activation_id) is None + ): + raise DispatchContractError("the renewal activation identity is malformed") + return { + "schema": "openadapt.ops-backup-dispatch-ingress/v1", + "event_name": event_name, + "dispatch_kind": kind, + "activation_id": activation_id, + "lease_event_id": lease_event_id, + "dispatch_attempt_id_sha256": attempt_digest, + "dispatch_envelope_sha256": envelope_digest, + "signed_envelope_b64": envelope_b64, + "github_repository": github_repository, + "github_run_id": github_run_id, + "github_run_attempt": github_run_attempt, + } + + +def ingress_ledger_key(ingress: Mapping[str, Any]) -> str: + digest = ingress.get("dispatch_attempt_id_sha256") + if not isinstance(digest, str) or HEX64.fullmatch(digest) is None: + raise DispatchContractError("the retained attempt digest is malformed") + return f"dispatch-ingress/sha256/{digest[:2]}/{digest}.json" + + +def classify_ingress_write( + ingress: Mapping[str, Any], existing_bytes: bytes | None +) -> str: + """Return CREATE or IDEMPOTENT; reject an occupied key with other bytes.""" + + expected = canonical_json(ingress) + if existing_bytes is None: + return "CREATE" + if existing_bytes == expected: + return "IDEMPOTENT" + raise DispatchContractError("the dispatch ingress ledger key has conflicting bytes") + + +def resolution_id(candidate: Mapping[str, Any]) -> str: + identity = { + "activation_id": candidate.get("activation_id"), + "dispatch_attempt_id_sha256": candidate.get("dispatch_attempt_id_sha256"), + "dispatch_envelope_sha256": candidate.get("dispatch_envelope_sha256"), + "dispatch_kind": candidate.get("dispatch_kind"), + "lease_event_id": candidate.get("lease_event_id"), + "requested_lease_sequence": candidate.get("requested_lease_sequence"), + } + return "resolution_" + _sha256(canonical_json(identity)) + + +def _timestamp(value: str) -> datetime: + try: + result = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise DispatchContractError("issued_at is not an ISO-8601 time") from exc + if result.tzinfo is None: + raise DispatchContractError("issued_at has no timezone") + return result.astimezone(timezone.utc) + + +def _assert_github_run_absence( + runs: Sequence[Mapping[str, Any]], *, reconciliation_required_at: str +) -> None: + _timestamp(reconciliation_required_at) + for run in runs: + event = run.get("event") + created_at = run.get("created_at") + run_id = run.get("id") + if ( + not isinstance(event, str) + or not isinstance(created_at, str) + or not isinstance(run_id, int) + ): + raise DispatchContractError("the GitHub run inventory is incomplete") + _timestamp(created_at) + if event == "repository_dispatch": + raise DispatchContractError( + "a repository_dispatch run can match the missing delivery" + ) + + +def prepare_not_received_resolution( + candidate: Mapping[str, Any], + *, + expected_attempt_sha256: str, + expected_envelope_sha256: str, + issued_at: str, + ingress_ledger_object: bytes | None, + github_runs: Sequence[Mapping[str, Any]], +) -> dict[str, Any]: + if set(candidate) != CANDIDATE_FIELDS: + raise DispatchContractError("the reconciliation candidate is not closed") + for field in ( + "organization_id_sha256", + "dispatch_attempt_id_sha256", + "dispatch_envelope_sha256", + ): + value = candidate.get(field) + if not isinstance(value, str) or HEX64.fullmatch(value) is None: + raise DispatchContractError(f"the candidate {field} is malformed") + if candidate["dispatch_attempt_id_sha256"] != expected_attempt_sha256: + raise DispatchContractError("the candidate dispatch attempt does not match") + if candidate["dispatch_envelope_sha256"] != expected_envelope_sha256: + raise DispatchContractError("the candidate dispatch envelope does not match") + if ingress_ledger_object is not None: + raise DispatchContractError("the dispatch attempt exists in the ingress ledger") + reconciliation_required_at = candidate.get("reconciliation_required_at") + if not isinstance(reconciliation_required_at, str): + raise DispatchContractError("the reconciliation time is missing") + _assert_github_run_absence( + github_runs, reconciliation_required_at=reconciliation_required_at + ) + activation_id = candidate.get("activation_id") + if ( + not isinstance(activation_id, str) + or ACTIVATION_ID.fullmatch(activation_id) is None + ): + raise DispatchContractError("the candidate activation identity is malformed") + kind = candidate.get("dispatch_kind") + lease_event_id = candidate.get("lease_event_id") + prior_lease = candidate.get("prior_lease_sha256") + sequence = candidate.get("requested_lease_sequence") + if kind == "INITIAL_ACTIVATION": + if any(value is not None for value in (lease_event_id, prior_lease, sequence)): + raise DispatchContractError("the initial candidate has renewal identity") + elif kind == "LEASE_RENEWAL": + if ( + not isinstance(lease_event_id, str) + or LEASE_EVENT_ID.fullmatch(lease_event_id) is None + or not isinstance(prior_lease, str) + or HEX64.fullmatch(prior_lease) is None + or not isinstance(sequence, int) + or isinstance(sequence, bool) + or sequence < 1 + ): + raise DispatchContractError("the renewal candidate identity is incomplete") + else: + raise DispatchContractError("the candidate dispatch kind is not authorized") + expected_resolution_id = resolution_id(candidate) + if candidate.get("resolution_id") != expected_resolution_id: + raise DispatchContractError("the candidate resolution identity does not match") + issued = _timestamp(issued_at) + expires = issued + timedelta(minutes=5) + resolution = { + "activation_id": activation_id, + "audience": RESOLUTION_AUDIENCE, + "dispatch_attempt_id_sha256": candidate["dispatch_attempt_id_sha256"], + "dispatch_envelope_sha256": candidate["dispatch_envelope_sha256"], + "dispatch_kind": kind, + "expires_at": expires.isoformat().replace("+00:00", "Z"), + "issued_at": issued.isoformat().replace("+00:00", "Z"), + "issuer": "openadapt-ops", + "lease_event_id": lease_event_id, + "offer_contract": OFFER_CONTRACT, + "organization_id_sha256": candidate["organization_id_sha256"], + "prior_lease_sha256": prior_lease, + "requested_lease_sequence": sequence, + "resolution_id": expected_resolution_id, + "resolution_state": "NOT_RECEIVED", + } + encoded = canonical_json(resolution) + return { + "schema": "openadapt.ops-backup-dispatch-resolution-signing-request/v1", + "resolution": resolution, + "resolution_sha256": _sha256(encoded), + "canonical_resolution_b64": base64.b64encode(encoded).decode("ascii"), + } + + +def sign_prepared_resolution( + prepared: Mapping[str, Any], + signer: Callable[[bytes], Mapping[str, Any]], +) -> dict[str, Any]: + if set(prepared) != { + "schema", + "resolution", + "resolution_sha256", + "canonical_resolution_b64", + } or prepared.get("schema") != ( + "openadapt.ops-backup-dispatch-resolution-signing-request/v1" + ): + raise DispatchContractError("the resolution signing request is not exact") + resolution = prepared.get("resolution") + if not isinstance(resolution, Mapping): + raise DispatchContractError("the prepared resolution is missing") + encoded = canonical_json(resolution) + if _sha256(encoded) != prepared.get("resolution_sha256"): + raise DispatchContractError("the prepared resolution digest does not match") + if base64.b64encode(encoded).decode("ascii") != prepared.get( + "canonical_resolution_b64" + ): + raise DispatchContractError("the prepared canonical bytes do not match") + signature = signer(encoded) + if not isinstance(signature, Mapping) or set(signature) != { + "algorithm", + "key_id", + "value", + }: + raise DispatchContractError("the asymmetric signer result is not exact") + if signature.get("algorithm") != SIGNATURE_ALGORITHM: + raise DispatchContractError("the resolution signature algorithm is not exact") + if signature.get("key_id") != SIGNATURE_KEY_ID: + raise DispatchContractError( + "the resolution signature key identity is not exact" + ) + value = signature.get("value") + if not isinstance(value, str): + raise DispatchContractError("the resolution signature value is missing") + try: + der = base64.b64decode(value, validate=True) + except ValueError as exc: + raise DispatchContractError( + "the resolution signature is not base64 DER" + ) from exc + if not der or der[0] != 0x30: + raise DispatchContractError("the resolution signature is not a DER sequence") + return { + "schema": RESOLUTION_SCHEMA, + "resolution": dict(resolution), + "resolution_sha256": prepared["resolution_sha256"], + "signature": dict(signature), + } + + +def main() -> int: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + ingress = subparsers.add_parser("retain-ingress") + ingress.add_argument("--event-name", required=True) + ingress.add_argument("--payload", type=Path, required=True) + ingress.add_argument("--output", type=Path, required=True) + ingress.add_argument("--github-repository", required=True) + ingress.add_argument("--github-run-id", required=True) + ingress.add_argument("--github-run-attempt", type=int, required=True) + resolve = subparsers.add_parser("prepare-not-received") + resolve.add_argument("--candidate", type=Path, required=True) + resolve.add_argument("--expected-attempt-sha256", required=True) + resolve.add_argument("--expected-envelope-sha256", required=True) + resolve.add_argument("--issued-at", required=True) + resolve.add_argument("--github-runs", type=Path, required=True) + resolve.add_argument("--ingress-ledger-object", type=Path) + resolve.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + value = ( + json.loads(args.payload.read_text()) + if args.command == "retain-ingress" + else json.loads(args.candidate.read_text()) + ) + if not isinstance(value, Mapping): + raise DispatchContractError("the input file is not an object") + if args.command == "retain-ingress": + output = retain_ingress( + args.event_name, + value, + github_repository=args.github_repository, + github_run_id=args.github_run_id, + github_run_attempt=args.github_run_attempt, + ) + else: + runs = json.loads(args.github_runs.read_text()) + if not isinstance(runs, list) or not all( + isinstance(run, Mapping) for run in runs + ): + raise DispatchContractError("the GitHub run inventory is not an array") + existing = ( + args.ingress_ledger_object.read_bytes() + if args.ingress_ledger_object is not None + else None + ) + output = prepare_not_received_resolution( + value, + expected_attempt_sha256=args.expected_attempt_sha256, + expected_envelope_sha256=args.expected_envelope_sha256, + issued_at=args.issued_at, + ingress_ledger_object=existing, + github_runs=runs, + ) + if args.command == "retain-ingress": + args.output.write_bytes(canonical_json(output)) + else: + args.output.write_text(json.dumps(output, indent=2, sort_keys=True) + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prepare_production_lifecycle_projection.py b/scripts/prepare_production_lifecycle_projection.py new file mode 100755 index 0000000..f99234b --- /dev/null +++ b/scripts/prepare_production_lifecycle_projection.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Validate and build one canonical Production lifecycle feed update.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import urllib.request +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any + +UPDATE_SCHEMA = "openadapt.production-lifecycle-feed-update/v1" +FEED_SCHEMA = "openadapt.production-lifecycle-feed/v1" +EVENT_TYPE = "production_lifecycle_feed_updated" +SOURCE_REPOSITORY = "OpenAdaptAI/openadapt-ops" +SOURCE_REPOSITORY_ID = "1172011294" +SOURCE_REF = "refs/heads/main" +TARGET_REPOSITORY = "OpenAdaptAI/.github" +TARGET_REPOSITORY_ID = "858454062" +TARGET_OWNER_ID = "132681217" +TARGET_REF = "refs/heads/production-lifecycle-feed" +FEED_PATH = "production-lifecycle-feed.json" +IDEMPOTENCY_DOMAIN = b"OpenAdapt production lifecycle feed update idempotency v1\0" +HEX40 = re.compile(r"^[0-9a-f]{40}$") +SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$") +IDEMPOTENCY = re.compile(r"^lifecycle-feed-update:[0-9a-f]{64}$") +MAX_FEED_BYTES = 2 * 1024 * 1024 +UPDATE_FIELDS = { + "schema_version", + "event_type", + "source_repository", + "source_repository_id", + "source_ref", + "source_commit", + "target_repository", + "target_repository_id", + "target_ref", + "expected_old_commit", + "new_commit", + "feed_path", + "feed_sha256", + "checkpoint_sha256", + "registry_head_sha256", + "expires_at", + "idempotency_key", +} +FEED_FIELDS = { + "schema_version", + "repository", + "repository_id", + "repository_owner_id", + "ref", + "feed_revision", + "generated_at", + "expires_at", + "registry_source_commit", + "registry_revision", + "registry_head_sha256", + "signer_registry", + "checkpoints", +} + + +class ProjectionInputError(ValueError): + """The feed update is malformed or does not match the exact feed bytes.""" + + +def canonical_json(value: Mapping[str, Any]) -> bytes: + return json.dumps( + value, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + + +def sha256(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() + + +def idempotency_key(value_without_key: Mapping[str, Any]) -> str: + return ( + "lifecycle-feed-update:" + + hashlib.sha256( + IDEMPOTENCY_DOMAIN + canonical_json(value_without_key) + ).hexdigest() + ) + + +def fetch(url: str) -> bytes: + request = urllib.request.Request( + url, headers={"User-Agent": "openadapt-lifecycle-feed-source/1"} + ) + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read(MAX_FEED_BYTES + 1) + if len(body) > MAX_FEED_BYTES: + raise ProjectionInputError("the lifecycle feed exceeds the size limit") + return body + + +def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ProjectionInputError(f"the lifecycle feed repeats field {key!r}") + result[key] = value + return result + + +def _parse_feed(body: bytes) -> Mapping[str, Any]: + try: + value = json.loads(body, object_pairs_hook=_unique_object) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ProjectionInputError( + "the lifecycle feed is not valid UTF-8 JSON" + ) from exc + if not isinstance(value, Mapping) or set(value) != FEED_FIELDS: + raise ProjectionInputError( + "the lifecycle feed does not have the closed field set" + ) + return value + + +def _validate_feed( + feed: Mapping[str, Any], + *, + new_commit: str, + feed_sha256: str, + checkpoint_sha256: str, + registry_head_sha256: str, + expires_at: str, + body: bytes, +) -> None: + if feed.get("schema_version") != FEED_SCHEMA: + raise ProjectionInputError("the lifecycle feed schema is not exact") + if ( + feed.get("repository") != TARGET_REPOSITORY + or feed.get("repository_id") != TARGET_REPOSITORY_ID + or feed.get("repository_owner_id") != TARGET_OWNER_ID + or feed.get("ref") != TARGET_REF + ): + raise ProjectionInputError( + "the lifecycle feed repository identity is not exact" + ) + if feed.get("registry_source_commit") != new_commit: + raise ProjectionInputError( + "the feed registry source commit is not the new commit" + ) + if sha256(body) != feed_sha256: + raise ProjectionInputError("the feed digest does not match the exact bytes") + if feed.get("registry_head_sha256") != registry_head_sha256: + raise ProjectionInputError("the feed registry head does not match") + if feed.get("expires_at") != expires_at: + raise ProjectionInputError("the feed expiry does not match") + checkpoints = feed.get("checkpoints") + if not isinstance(checkpoints, list) or len(checkpoints) not in {1, 2}: + raise ProjectionInputError("the feed must contain one or two checkpoints") + checkpoint_digests: list[str] = [] + for pair in checkpoints: + if not isinstance(pair, Mapping) or set(pair) != { + "checkpoint_reference", + "checkpoint_bundle_reference", + }: + raise ProjectionInputError("a feed checkpoint pair is not closed") + reference = pair["checkpoint_reference"] + if not isinstance(reference, Mapping): + raise ProjectionInputError("a checkpoint reference is not an object") + digest = reference.get("object_sha256") + if not isinstance(digest, str) or SHA256.fullmatch(digest) is None: + raise ProjectionInputError("a checkpoint reference digest is malformed") + checkpoint_digests.append(digest) + if checkpoint_sha256 not in checkpoint_digests: + raise ProjectionInputError("the named checkpoint is not in the feed") + + +def prepare( + *, + source_commit: str, + expected_old_commit: str | None, + new_commit: str, + feed_sha256: str, + checkpoint_sha256: str, + registry_head_sha256: str, + expires_at: str, + supplied_idempotency_key: str, + fetch_bytes: Callable[[str], bytes] = fetch, +) -> dict[str, Any]: + if HEX40.fullmatch(source_commit) is None or HEX40.fullmatch(new_commit) is None: + raise ProjectionInputError("source_commit and new_commit must be exact SHAs") + if expected_old_commit == "": + expected_old_commit = None + if expected_old_commit is not None and HEX40.fullmatch(expected_old_commit) is None: + raise ProjectionInputError("expected_old_commit must be null or an exact SHA") + for label, value in ( + ("feed", feed_sha256), + ("checkpoint", checkpoint_sha256), + ("registry head", registry_head_sha256), + ): + if SHA256.fullmatch(value) is None: + raise ProjectionInputError(f"the {label} digest is malformed") + if not isinstance(expires_at, str) or not expires_at: + raise ProjectionInputError("expires_at is required") + if IDEMPOTENCY.fullmatch(supplied_idempotency_key) is None: + raise ProjectionInputError("the idempotency key is malformed") + url = ( + "https://raw.githubusercontent.com/" + f"{TARGET_REPOSITORY}/{new_commit}/{FEED_PATH}" + ) + body = fetch_bytes(url) + if len(body) > MAX_FEED_BYTES: + raise ProjectionInputError("the lifecycle feed exceeds the size limit") + _validate_feed( + _parse_feed(body), + new_commit=new_commit, + feed_sha256=feed_sha256, + checkpoint_sha256=checkpoint_sha256, + registry_head_sha256=registry_head_sha256, + expires_at=expires_at, + body=body, + ) + update: dict[str, Any] = { + "schema_version": UPDATE_SCHEMA, + "event_type": EVENT_TYPE, + "source_repository": SOURCE_REPOSITORY, + "source_repository_id": SOURCE_REPOSITORY_ID, + "source_ref": SOURCE_REF, + "source_commit": source_commit, + "target_repository": TARGET_REPOSITORY, + "target_repository_id": TARGET_REPOSITORY_ID, + "target_ref": TARGET_REF, + "expected_old_commit": expected_old_commit, + "new_commit": new_commit, + "feed_path": FEED_PATH, + "feed_sha256": feed_sha256, + "checkpoint_sha256": checkpoint_sha256, + "registry_head_sha256": registry_head_sha256, + "expires_at": expires_at, + } + expected_key = idempotency_key(update) + if supplied_idempotency_key != expected_key: + raise ProjectionInputError("the idempotency key does not bind the update") + update["idempotency_key"] = supplied_idempotency_key + if set(update) != UPDATE_FIELDS: + raise AssertionError("the update builder emitted the wrong field set") + return update + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--source-commit", required=True) + parser.add_argument("--expected-old-commit", default="") + parser.add_argument("--new-commit", required=True) + parser.add_argument("--feed-sha256", required=True) + parser.add_argument("--checkpoint-sha256", required=True) + parser.add_argument("--registry-head-sha256", required=True) + parser.add_argument("--expires-at", required=True) + parser.add_argument("--idempotency-key", required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + value = prepare( + source_commit=args.source_commit, + expected_old_commit=args.expected_old_commit, + new_commit=args.new_commit, + feed_sha256=args.feed_sha256, + checkpoint_sha256=args.checkpoint_sha256, + registry_head_sha256=args.registry_head_sha256, + expires_at=args.expires_at, + supplied_idempotency_key=args.idempotency_key, + ) + args.output.write_bytes(canonical_json(value)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_azure_cost_guard.py b/tests/test_azure_cost_guard.py index b666c68..10a34d9 100644 --- a/tests/test_azure_cost_guard.py +++ b/tests/test_azure_cost_guard.py @@ -105,8 +105,10 @@ def test_scheduled_guard_uses_step_level_secret_gate(): workflow = yaml.safe_load( (REPO_ROOT / ".github/workflows/azure-cost-guard.yml").read_text(encoding="utf-8") ) - steps = workflow["jobs"]["report"]["steps"] - assert "if" not in workflow["jobs"]["report"] + report = workflow["jobs"]["report"] + steps = report["steps"] + assert "github.actor != 'openadapt-lifecycle[bot]'" in report["if"] + assert "github.triggering_actor != 'openadapt-lifecycle[bot]'" in report["if"] credentials = next(step for step in steps if step.get("id") == "credentials") assert credentials["env"]["AZURE_CREDENTIALS"] == "${{ secrets.AZURE_CREDENTIALS }}" login = next(step for step in steps if "Azure/login@" in step.get("uses", "")) diff --git a/tests/test_database_backup_dispatch_reconciliation.py b/tests/test_database_backup_dispatch_reconciliation.py new file mode 100644 index 0000000..f2f02a7 --- /dev/null +++ b/tests/test_database_backup_dispatch_reconciliation.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import base64 +import importlib.util +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "database_backup_dispatch_reconciliation", + ROOT / "scripts" / "database_backup_dispatch_reconciliation.py", +) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + +ACTIVATION_ID = "act_2d49200bcccc6d2b70a392991a6390325d1ab2e7e0c16c81dfc63d1673efa10b" +ENVELOPE_DIGEST = "b31d46c369c8a754dba069033a900b46d64009138834b06d4ece5eede827602f" +ATTEMPT_DIGEST = "76c5e382d0045c0bc1ba849090f947b7076d2cbd04e56d35820fdf7e8d9dec6e" +RESOLUTION_ID = ( + "resolution_fada2d1a0ce8d86df505b2df27f89b49145e3ea9ea3798133d9a6fc1f108b4e4" +) + + +def ingress_payload() -> tuple[dict[str, object], dict[str, object]]: + envelope = { + "schema": "openadapt.database-backup-activation-request/v1", + "request": {"activation_id": ACTIVATION_ID}, + "signature": {"algorithm": "fixture", "key_id": "fixture", "value": "fixture"}, + } + digest = MODULE._sha256(MODULE.canonical_json(envelope)) + payload = { + "schema": MODULE.INITIAL_SCHEMA, + "dispatch_attempt_id_sha256": ATTEMPT_DIGEST, + "dispatch_envelope_sha256": digest, + "activation_request_b64": base64.b64encode( + json.dumps(envelope, indent=2).encode() + ).decode(), + } + return payload, envelope + + +def candidate() -> dict[str, object]: + return { + "resolution_id": RESOLUTION_ID, + "dispatch_kind": "INITIAL_ACTIVATION", + "activation_id": ACTIVATION_ID, + "organization_id_sha256": "1" * 64, + "dispatch_attempt_id_sha256": ATTEMPT_DIGEST, + "dispatch_envelope_sha256": ENVELOPE_DIGEST, + "lease_event_id": None, + "prior_lease_sha256": None, + "requested_lease_sequence": None, + "last_error_code": "DISPATCH_NOT_CONFIRMED", + "reconciliation_required_at": "2026-08-27T16:00:00Z", + } + + +def prepare() -> dict[str, object]: + return MODULE.prepare_not_received_resolution( + candidate(), + expected_attempt_sha256=ATTEMPT_DIGEST, + expected_envelope_sha256=ENVELOPE_DIGEST, + issued_at="2026-08-27T16:01:00Z", + ingress_ledger_object=None, + github_runs=[], + ) + + +def test_normal_ingress_retains_exact_attempt_envelope_and_run_identity() -> None: + payload, _ = ingress_payload() + retained = MODULE.retain_ingress( + MODULE.INITIAL_EVENT, + payload, + github_repository="OpenAdaptAI/openadapt-ops", + github_run_id="123456", + github_run_attempt=1, + ) + assert retained["dispatch_attempt_id_sha256"] == ATTEMPT_DIGEST + assert retained["dispatch_envelope_sha256"] == payload["dispatch_envelope_sha256"] + assert retained["github_run_id"] == "123456" + assert MODULE.ingress_ledger_key(retained).endswith(f"/{ATTEMPT_DIGEST}.json") + + +def test_ingress_ledger_is_no_overwrite_idempotent_and_conflict_hard() -> None: + payload, _ = ingress_payload() + retained = MODULE.retain_ingress( + MODULE.INITIAL_EVENT, + payload, + github_repository="OpenAdaptAI/openadapt-ops", + github_run_id="123456", + github_run_attempt=1, + ) + assert MODULE.classify_ingress_write(retained, None) == "CREATE" + assert ( + MODULE.classify_ingress_write(retained, MODULE.canonical_json(retained)) + == "IDEMPOTENT" + ) + with pytest.raises(MODULE.DispatchContractError, match="conflicting bytes"): + MODULE.classify_ingress_write(retained, b"{}") + + +def test_ingress_rejects_missing_or_mismatched_dispatch_identity() -> None: + payload, _ = ingress_payload() + payload["dispatch_envelope_sha256"] = "0" * 64 + with pytest.raises(MODULE.DispatchContractError, match="does not match"): + MODULE.retain_ingress( + MODULE.INITIAL_EVENT, + payload, + github_repository="OpenAdaptAI/openadapt-ops", + github_run_id="123456", + github_run_attempt=1, + ) + + +def test_shared_lost_before_github_vector_builds_exact_five_minute_resolution() -> None: + prepared = prepare() + resolution = prepared["resolution"] + assert len(resolution) == 15 + assert resolution["resolution_id"] == RESOLUTION_ID + assert resolution["resolution_state"] == "NOT_RECEIVED" + assert resolution["dispatch_attempt_id_sha256"] == ATTEMPT_DIGEST + assert resolution["dispatch_envelope_sha256"] == ENVELOPE_DIGEST + assert resolution["issued_at"] == "2026-08-27T16:01:00Z" + assert resolution["expires_at"] == "2026-08-27T16:06:00Z" + canonical = base64.b64decode(prepared["canonical_resolution_b64"]) + assert MODULE._sha256(canonical) == prepared["resolution_sha256"] + + +def test_no_resolution_is_prepared_when_absence_is_uncertain() -> None: + kwargs = { + "expected_attempt_sha256": ATTEMPT_DIGEST, + "expected_envelope_sha256": ENVELOPE_DIGEST, + "issued_at": "2026-08-27T16:01:00Z", + } + with pytest.raises(MODULE.DispatchContractError, match="ingress ledger"): + MODULE.prepare_not_received_resolution( + candidate(), ingress_ledger_object=b"present", github_runs=[], **kwargs + ) + with pytest.raises(MODULE.DispatchContractError, match="can match"): + MODULE.prepare_not_received_resolution( + candidate(), + ingress_ledger_object=None, + github_runs=[ + { + "id": 9, + "event": "repository_dispatch", + "created_at": "2026-08-27T15:59:59Z", + } + ], + **kwargs, + ) + + +def test_local_p256_fixture_signs_the_exact_canonical_resolution( + tmp_path: Path, +) -> None: + private_key = tmp_path / "fixture-private.pem" + public_key = tmp_path / "fixture-public.pem" + message = tmp_path / "resolution.json" + signature_path = tmp_path / "resolution.sig" + subprocess.run( + [ + "openssl", + "ecparam", + "-name", + "prime256v1", + "-genkey", + "-noout", + "-out", + str(private_key), + ], + check=True, + capture_output=True, + ) + subprocess.run( + ["openssl", "ec", "-in", str(private_key), "-pubout", "-out", str(public_key)], + check=True, + capture_output=True, + ) + prepared = prepare() + canonical = base64.b64decode(prepared["canonical_resolution_b64"]) + message.write_bytes(canonical) + subprocess.run( + [ + "openssl", + "dgst", + "-sha256", + "-sign", + str(private_key), + "-out", + str(signature_path), + str(message), + ], + check=True, + capture_output=True, + ) + + def signer(value: bytes) -> dict[str, str]: + assert value == canonical + return { + "algorithm": MODULE.SIGNATURE_ALGORITHM, + "key_id": MODULE.SIGNATURE_KEY_ID, + "value": base64.b64encode(signature_path.read_bytes()).decode(), + } + + envelope = MODULE.sign_prepared_resolution(prepared, signer) + assert set(envelope) == {"schema", "resolution", "resolution_sha256", "signature"} + verified = subprocess.run( + [ + "openssl", + "dgst", + "-sha256", + "-verify", + str(public_key), + "-signature", + str(signature_path), + str(message), + ], + check=False, + capture_output=True, + text=True, + ) + assert verified.returncode == 0 + assert "Verified OK" in verified.stdout + + +def test_shared_hmac_signature_is_rejected() -> None: + with pytest.raises(MODULE.DispatchContractError, match="algorithm is not exact"): + MODULE.sign_prepared_resolution( + prepare(), + lambda _: {"algorithm": "HMAC-SHA256", "key_id": "shared", "value": "AA=="}, + ) diff --git a/tests/test_prepare_production_lifecycle_projection.py b/tests/test_prepare_production_lifecycle_projection.py new file mode 100644 index 0000000..e0dac2d --- /dev/null +++ b/tests/test_prepare_production_lifecycle_projection.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "prepare_production_lifecycle_projection", + ROOT / "scripts" / "prepare_production_lifecycle_projection.py", +) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def feed_bytes(new_commit: str = "b" * 40) -> bytes: + checkpoint = "sha256:" + "c" * 64 + value = { + "schema_version": MODULE.FEED_SCHEMA, + "repository": MODULE.TARGET_REPOSITORY, + "repository_id": MODULE.TARGET_REPOSITORY_ID, + "repository_owner_id": MODULE.TARGET_OWNER_ID, + "ref": MODULE.TARGET_REF, + "feed_revision": 9, + "generated_at": "2026-08-27T16:00:00Z", + "expires_at": "2026-08-28T16:00:00Z", + "registry_source_commit": new_commit, + "registry_revision": 14, + "registry_head_sha256": "sha256:" + "d" * 64, + "signer_registry": {"schema_version": "test"}, + "checkpoints": [ + { + "checkpoint_reference": {"object_sha256": checkpoint}, + "checkpoint_bundle_reference": {"object_sha256": "sha256:" + "e" * 64}, + } + ], + } + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + + +def inputs() -> tuple[dict[str, object], bytes]: + body = feed_bytes() + value: dict[str, object] = { + "source_commit": "a" * 40, + "expected_old_commit": "f" * 40, + "new_commit": "b" * 40, + "feed_sha256": MODULE.sha256(body), + "checkpoint_sha256": "sha256:" + "c" * 64, + "registry_head_sha256": "sha256:" + "d" * 64, + "expires_at": "2026-08-28T16:00:00Z", + } + unsigned = { + "schema_version": MODULE.UPDATE_SCHEMA, + "event_type": MODULE.EVENT_TYPE, + "source_repository": MODULE.SOURCE_REPOSITORY, + "source_repository_id": MODULE.SOURCE_REPOSITORY_ID, + "source_ref": MODULE.SOURCE_REF, + "source_commit": value["source_commit"], + "target_repository": MODULE.TARGET_REPOSITORY, + "target_repository_id": MODULE.TARGET_REPOSITORY_ID, + "target_ref": MODULE.TARGET_REF, + "expected_old_commit": value["expected_old_commit"], + "new_commit": value["new_commit"], + "feed_path": MODULE.FEED_PATH, + "feed_sha256": value["feed_sha256"], + "checkpoint_sha256": value["checkpoint_sha256"], + "registry_head_sha256": value["registry_head_sha256"], + "expires_at": value["expires_at"], + } + value["supplied_idempotency_key"] = MODULE.idempotency_key(unsigned) + return value, body + + +def test_exact_feed_builds_one_closed_canonical_update() -> None: + candidate, body = inputs() + result = MODULE.prepare(**candidate, fetch_bytes=lambda _: body) + assert set(result) == MODULE.UPDATE_FIELDS + assert result["source_repository_id"] == "1172011294" + assert result["target_repository_id"] == "858454062" + assert result["target_ref"] == "refs/heads/production-lifecycle-feed" + assert MODULE.canonical_json(result) == MODULE.canonical_json( + json.loads(MODULE.canonical_json(result)) + ) + + +def test_first_feed_creation_uses_json_null_old_commit() -> None: + candidate, body = inputs() + candidate["expected_old_commit"] = "" + candidate_without_key = { + "schema_version": MODULE.UPDATE_SCHEMA, + "event_type": MODULE.EVENT_TYPE, + "source_repository": MODULE.SOURCE_REPOSITORY, + "source_repository_id": MODULE.SOURCE_REPOSITORY_ID, + "source_ref": MODULE.SOURCE_REF, + "source_commit": candidate["source_commit"], + "target_repository": MODULE.TARGET_REPOSITORY, + "target_repository_id": MODULE.TARGET_REPOSITORY_ID, + "target_ref": MODULE.TARGET_REF, + "expected_old_commit": None, + "new_commit": candidate["new_commit"], + "feed_path": MODULE.FEED_PATH, + "feed_sha256": candidate["feed_sha256"], + "checkpoint_sha256": candidate["checkpoint_sha256"], + "registry_head_sha256": candidate["registry_head_sha256"], + "expires_at": candidate["expires_at"], + } + candidate["supplied_idempotency_key"] = MODULE.idempotency_key( + candidate_without_key + ) + assert ( + MODULE.prepare(**candidate, fetch_bytes=lambda _: body)["expected_old_commit"] + is None + ) + + +@pytest.mark.parametrize( + "field", + [ + "source_commit", + "new_commit", + "feed_sha256", + "checkpoint_sha256", + "registry_head_sha256", + "expires_at", + "supplied_idempotency_key", + ], +) +def test_feed_update_fails_closed_for_mismatched_identity(field: str) -> None: + candidate, body = inputs() + candidate[field] = "wrong" + with pytest.raises(MODULE.ProjectionInputError): + MODULE.prepare(**candidate, fetch_bytes=lambda _: body) + + +def test_feed_update_rejects_checkpoint_not_named_by_feed() -> None: + candidate, body = inputs() + candidate["checkpoint_sha256"] = "sha256:" + "9" * 64 + with pytest.raises(MODULE.ProjectionInputError, match="not in the feed"): + MODULE.prepare(**candidate, fetch_bytes=lambda _: body) diff --git a/tests/test_production_ops_workflows.py b/tests/test_production_ops_workflows.py index 4aef4c4..1314815 100644 --- a/tests/test_production_ops_workflows.py +++ b/tests/test_production_ops_workflows.py @@ -2,15 +2,116 @@ from __future__ import annotations +import re from pathlib import Path +import yaml + ROOT = Path(__file__).resolve().parent.parent +REJECT_LIFECYCLE_WORKFLOWS = ( + ".github/workflows/action-pin-sweep.yml", + ".github/workflows/azure-cost-guard.yml", + ".github/workflows/db-backup-freshness.yml", + ".github/workflows/db-backup.yml", + ".github/workflows/default-branch-sweep.yml", + ".github/workflows/prod-health-alert.yml", + ".github/workflows/production-lifecycle-policy.yml", + ".github/workflows/published-version-claims.yml", + ".github/workflows/workspace-staleness-sweep.yml", +) + def read(path: str) -> str: return (ROOT / path).read_text(encoding="utf-8") +def workflow(path: str) -> dict: + value = yaml.load(read(path), Loader=yaml.BaseLoader) + assert isinstance(value, dict) + return value + + +def test_every_non_lifecycle_dispatch_refuses_the_lifecycle_app() -> None: + for path in REJECT_LIFECYCLE_WORKFLOWS: + value = workflow(path) + concurrency = value["concurrency"] + assert "github.workflow" in concurrency["group"], path + assert "github.event_name" in concurrency["group"], path + assert concurrency["cancel-in-progress"] == "false", path + + jobs = value["jobs"] + guard = jobs["reject-lifecycle-app"] + assert guard["permissions"] == {}, path + guard_text = str(guard) + assert "github.actor" in guard_text, path + assert "github.triggering_actor" in guard_text, path + assert "openadapt-lifecycle[bot]" in guard_text, path + for name, job in jobs.items(): + if name == "reject-lifecycle-app": + continue + needs = job.get("needs", []) + if isinstance(needs, str): + needs = [needs] + assert "reject-lifecycle-app" in needs, f"{path}:{name}" + + +def test_dispatch_workflow_inventory_is_complete() -> None: + discovered = set() + for path in sorted((ROOT / ".github" / "workflows").glob("*.y*ml")): + content = path.read_text(encoding="utf-8") + if re.search(r"(?m)^ (?:workflow_dispatch|repository_dispatch):\s*$", content): + discovered.add(str(path.relative_to(ROOT))) + assert discovered == set(REJECT_LIFECYCLE_WORKFLOWS) | { + ".github/workflows/production-lifecycle-projection.yml", + ".github/workflows/sync.yml", + } + + +def test_lifecycle_feed_source_is_app_only_and_dispatches_one_closed_json() -> None: + content = read(".github/workflows/production-lifecycle-projection.yml") + assert "github.actor == 'openadapt-lifecycle[bot]'" in content + assert "github.triggering_actor == 'openadapt-lifecycle[bot]'" in content + assert "github.actor_id == vars.OPENADAPT_LIFECYCLE_ACTOR_ID" in content + assert "vars.OPENADAPT_LIFECYCLE_APP_ID" in content + assert "vars.OPENADAPT_LIFECYCLE_INSTALLATION_ID" in content + assert "secrets.OPENADAPT_LIFECYCLE_APP_PRIVATE_KEY" in content + assert "environment: production-lifecycle-projection" in content + assert "production_lifecycle_feed_updated" not in content + assert "feed_update_json" in content + assert "production-lifecycle-ref.yml" in content + assert "--repo OpenAdaptAI/.github" in content + assert "--ref main" in content + assert "permission-actions: write" in content + assert "permission-contents: write" in content + assert "git push origin HEAD:main" not in content + assert "gh pr create" not in content + assert "cancel-in-progress: false" in content + + +def test_app_authored_pull_request_validation_does_not_deadlock() -> None: + for path in ( + ".github/workflows/action-pin-sweep.yml", + ".github/workflows/default-branch-sweep.yml", + ".github/workflows/production-lifecycle-policy.yml", + ".github/workflows/published-version-claims.yml", + ".github/workflows/workspace-staleness-sweep.yml", + ): + value = workflow(path) + guard = value["jobs"]["reject-lifecycle-app"] + text = str(guard) + assert "github.event_name" in text, path + assert "pull_request" in text, path + assert "openadapt-lifecycle[bot]" in text, path + + +def test_lifecycle_required_check_runs_on_every_pull_request() -> None: + content = read(".github/workflows/production-lifecycle-policy.yml") + pull_request = content.split(" pull_request:", 1)[1].split(" push:", 1)[0] + assert "paths:" not in pull_request + assert "paths-ignore:" not in pull_request + + def test_backup_configuration_fails_before_credentials_or_tool_install() -> None: workflow = read(".github/workflows/db-backup.yml") preflight = workflow.index("Validate the protected environment configuration") @@ -23,7 +124,7 @@ def test_backup_configuration_fails_before_credentials_or_tool_install() -> None assert "group: production-backup" in workflow assert "labels: self-hosted" in workflow assert "RUNNER_BOUNDARY: ${{ runner.environment }}" in workflow - assert '"${RUNNER_BOUNDARY}" != \'self-hosted\'' in workflow + assert "\"${RUNNER_BOUNDARY}\" != 'self-hosted'" in workflow assert "check_github_environment_gate.py" in workflow target = workflow.index("check_live_database_backup_target.sh") dump = workflow.index("supabase db dump") @@ -44,7 +145,7 @@ def test_backup_uses_one_s3_validated_full_object_put() -> None: verify = workflow.index("verify-single-put") assert prepare < put < verify assert '--checksum-algorithm SHA256 --checksum-sha256 "$local_checksum"' in workflow - assert "--content-length \"$cipher_bytes\"" in workflow + assert '--content-length "$cipher_bytes"' in workflow assert 'aws s3 cp "$cipher"' not in workflow