diff --git a/.github/actions/generic-web-deploy-recovery-dry-run/action.yml b/.github/actions/generic-web-deploy-recovery-dry-run/action.yml new file mode 100644 index 000000000..b707e0510 --- /dev/null +++ b/.github/actions/generic-web-deploy-recovery-dry-run/action.yml @@ -0,0 +1,39 @@ +--- +name: Generic-web deploy recovery dry run +description: Inspect one exact legacy generic-web deploy reservation through Launchplane. + +inputs: + launchplane-url: + description: Base URL for the Launchplane service. + required: true + request-json: + description: Exact legacy deploy coordinates and operator reason as JSON. + required: true + audience: + description: Optional GitHub OIDC audience. Defaults to the service URL host. + required: false + default: "" + timeout-ms: + description: Launchplane request timeout in milliseconds. + required: false + default: "120000" + +outputs: + recovery_digest: + description: Canonical digest binding the observed recovery plan. + proposed_action: + description: Recovery action proposed by Launchplane. + reservation_state: + description: Current durable reservation state. + provider_outcome: + description: Bounded provider inspection outcome. + provider_status: + description: Bounded provider status. + retry_safe: + description: Whether retrying the original operation is safe. + observed_at: + description: Timestamp for the dry-run observation. + +runs: + using: node24 + main: dist/index.mjs diff --git a/.github/actions/generic-web-deploy-recovery-dry-run/dist/index.mjs b/.github/actions/generic-web-deploy-recovery-dry-run/dist/index.mjs new file mode 100644 index 000000000..f4129e6a6 --- /dev/null +++ b/.github/actions/generic-web-deploy-recovery-dry-run/dist/index.mjs @@ -0,0 +1,128 @@ +const runtime = Reflect.get(globalThis, "process"); +const environment = runtime.env; +const requestKeys = new Set([ + "artifact_id", + "instance", + "original_run_attempt", + "original_run_id", + "product", + "reason", + "schema_version", + "source_git_ref", +]); + +function environmentKey(name) { + return `INPUT_${name.replaceAll(" ", "_").toUpperCase()}`; +} + +function input(name, defaultValue = "") { + return String(environment[environmentKey(name)] ?? defaultValue).trim(); +} + +function requiredInput(name) { + const value = input(name); + if (!value) { + throw new Error(`${name} is required.`); + } + return value; +} + +function requestString(request, name) { + const value = request[name]; + if (typeof value !== "string" || !value.trim()) { + throw new Error(`request-json.${name} must be a non-empty string.`); + } + return value.trim(); +} + +function requestPositiveInteger(request, name) { + const value = requestString(request, name); + if (!/^[1-9][0-9]*$/.test(value)) { + throw new Error(`request-json.${name} must be a positive integer string.`); + } + return value; +} + +function parseRequest(value) { + let request; + try { + request = JSON.parse(value); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`request-json must be valid JSON: ${detail}`); + } + if (!request || typeof request !== "object" || Array.isArray(request)) { + throw new Error("request-json must be a JSON object."); + } + const unexpectedKeys = Object.keys(request).filter(key => !requestKeys.has(key)); + if (unexpectedKeys.length > 0) { + throw new Error( + `request-json contains unsupported fields: ${unexpectedKeys.sort().join(", ")}.`, + ); + } + if (request.schema_version !== 1) { + throw new Error("request-json.schema_version must equal 1."); + } + return request; +} + +function configureRequestAction() { + const request = parseRequest(requiredInput("request-json")); + const product = requestString(request, "product"); + const instance = requestString(request, "instance"); + const artifactId = requestString(request, "artifact_id"); + const sourceGitRef = requestString(request, "source_git_ref"); + const originalRunId = requestPositiveInteger(request, "original_run_id"); + const originalRunAttempt = requestPositiveInteger(request, "original_run_attempt"); + const reason = requestString(request, "reason"); + const idempotencyKey = [ + "generic-web-stable-deploy", + product, + instance, + originalRunId, + originalRunAttempt, + ].join(":"); + const payload = { + schema_version: 1, + product, + instance, + original_deploy: { + schema_version: 1, + product, + deploy: { + schema_version: 1, + product, + instance, + artifact_id: artifactId, + source_git_ref: sourceGitRef, + }, + }, + reason, + }; + + environment[environmentKey("launchplane-url")] = requiredInput("launchplane-url"); + environment[environmentKey("route-path")] = + "/v1/admin/generic-web/deploy-recovery/dry-run"; + environment[environmentKey("payload")] = JSON.stringify(payload); + environment[environmentKey("idempotency-key")] = idempotencyKey; + environment[environmentKey("audience")] = input("audience"); + environment[environmentKey("timeout-ms")] = input("timeout-ms", "120000"); + environment[environmentKey("log-response-body")] = "false"; + environment[environmentKey("output-paths")] = [ + "recovery_digest=recovery_digest", + "proposed_action=proposed_action", + "reservation_state=reservation_state", + "provider_outcome=provider_outcome", + "provider_status=provider_status", + "retry_safe=retry_safe", + "observed_at=observed_at", + ].join(","); +} + +try { + configureRequestAction(); + await import(new URL("../../launchplane-request/dist/index.js", import.meta.url)); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + runtime.exitCode = 1; +} diff --git a/docs/operations.md b/docs/operations.md index 2dc8d5a8e..85995c9aa 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -397,6 +397,14 @@ timestamps, hashed identifiers, provider outcome/status, retry safety, one of `retry_original_operation`, or `hold_unknown`, and a canonical recovery digest. Raw scopes, idempotency keys, reconciliation keys, provider-target keys, original payloads, target URLs, and provider payloads are never returned. +Product repositories that need an OIDC-authenticated inspection should use the +Launchplane-owned +`.github/actions/generic-web-deploy-recovery-dry-run` action. Its single request +object accepts only the exact legacy deploy coordinates, original GitHub Actions +run ID and attempt, and operator reason. The action reconstructs the legacy +idempotency key internally, calls only the dry-run route through the shared +request action, suppresses the raw response body, and exposes only the seven +bounded recovery fields documented above. Stage 2 apply is explicit and digest-gated. Operators call `POST /v1/admin/generic-web/deploy-recovery/apply` with the same request body as diff --git a/tests/test_generic_web_deploy_recovery_action.py b/tests/test_generic_web_deploy_recovery_action.py new file mode 100644 index 000000000..710e183a0 --- /dev/null +++ b/tests/test_generic_web_deploy_recovery_action.py @@ -0,0 +1,172 @@ +import json +import os +import shutil +import subprocess +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest + + +ACTION_ENTRYPOINT = Path(".github/actions/generic-web-deploy-recovery-dry-run/dist/index.mjs") +ACTION_METADATA = Path(".github/actions/generic-web-deploy-recovery-dry-run/action.yml") + + +class GenericWebDeployRecoveryActionTests(unittest.TestCase): + def run_action( + self, + *, + request: dict[str, object], + output_path: Path, + ) -> subprocess.CompletedProcess[str]: + if shutil.which("node") is None: + self.skipTest("node is required to test the recovery dry-run action") + + env = os.environ.copy() + env.update( + { + "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "request-token", + "ACTIONS_ID_TOKEN_REQUEST_URL": "https://oidc.example/token", + "GITHUB_OUTPUT": str(output_path), + "INPUT_LAUNCHPLANE-URL": "https://launchplane.example", + "INPUT_REQUEST-JSON": json.dumps(request), + } + ) + script = f""" +const calls = []; +global.fetch = async (url, init) => {{ + calls.push({{url, init}}); + if (url.startsWith('https://oidc.example/token')) {{ + return new Response(JSON.stringify({{value: 'oidc-token'}}), {{status: 200}}); + }} + return new Response(JSON.stringify({{ + recovery_digest: '{"a" * 64}', + proposed_action: 'retry_original_operation', + reservation_state: 'reconcile_required', + provider_outcome: 'absent', + provider_status: 'missing', + retry_safe: true, + observed_at: '2026-08-16T22:00:00Z' + }}), {{status: 200}}); +}}; +process.on('beforeExit', () => {{ + console.error(JSON.stringify(calls.map((call) => ({{ + url: call.url, + method: call.init.method, + headers: call.init.headers, + body: call.init.body || '' + }})))); +}}); +import('./{ACTION_ENTRYPOINT.as_posix()}'); +""" + return subprocess.run( + ["node", "-e", script], + capture_output=True, + env=env, + text=True, + ) + + def test_action_metadata_is_dry_run_only_and_declares_bounded_outputs(self) -> None: + metadata = ACTION_METADATA.read_text(encoding="utf-8") + + self.assertIn("using: node24", metadata) + self.assertNotIn("apply", metadata.lower()) + for output_name in ( + "recovery_digest", + "proposed_action", + "reservation_state", + "provider_outcome", + "provider_status", + "retry_safe", + "observed_at", + ): + with self.subTest(output_name=output_name): + self.assertIn(f" {output_name}:\n", metadata) + + def test_action_reconstructs_exact_legacy_request_and_projects_evidence(self) -> None: + request = { + "schema_version": 1, + "product": "repairshopr-sync", + "instance": "prod", + "artifact_id": "ghcr.io/cbusillo/repairshopr_api@sha256:" + "b" * 64, + "source_git_ref": "2d66fb6b2708f975b1645ac912a5b576a9282853", + "original_run_id": "29609495343", + "original_run_attempt": "1", + "reason": "Inspect the legacy deploy reservation.", + } + with TemporaryDirectory() as temporary_directory: + output_path = Path(temporary_directory) / "github-output.txt" + result = self.run_action(request=request, output_path=output_path) + + self.assertEqual(result.returncode, 0, result.stderr) + calls = json.loads(result.stderr.splitlines()[-1]) + self.assertEqual(len(calls), 2) + launchplane_call = calls[1] + self.assertEqual( + launchplane_call["url"], + "https://launchplane.example/v1/admin/generic-web/deploy-recovery/dry-run", + ) + self.assertEqual(launchplane_call["method"], "POST") + self.assertEqual( + launchplane_call["headers"]["Idempotency-Key"], + "generic-web-stable-deploy:repairshopr-sync:prod:29609495343:1", + ) + self.assertEqual( + json.loads(launchplane_call["body"]), + { + "schema_version": 1, + "product": "repairshopr-sync", + "instance": "prod", + "original_deploy": { + "schema_version": 1, + "product": "repairshopr-sync", + "deploy": { + "schema_version": 1, + "product": "repairshopr-sync", + "instance": "prod", + "artifact_id": request["artifact_id"], + "source_git_ref": request["source_git_ref"], + }, + }, + "reason": request["reason"], + }, + ) + outputs = output_path.read_text(encoding="utf-8") + for output_name, output_value in ( + ("recovery_digest", "a" * 64), + ("proposed_action", "retry_original_operation"), + ("reservation_state", "reconcile_required"), + ("provider_outcome", "absent"), + ("provider_status", "missing"), + ("retry_safe", "true"), + ("observed_at", "2026-08-16T22:00:00Z"), + ): + with self.subTest(output_name=output_name): + self.assertIn(f"{output_name}<<", outputs) + self.assertIn(f"\n{output_value}\n", outputs) + + def test_action_rejects_unknown_request_fields_before_oidc(self) -> None: + request = { + "schema_version": 1, + "product": "repairshopr-sync", + "instance": "prod", + "artifact_id": "artifact", + "source_git_ref": "source", + "original_run_id": "29609495343", + "original_run_attempt": "1", + "reason": "Inspect the legacy deploy reservation.", + "apply": True, + } + with TemporaryDirectory() as temporary_directory: + result = self.run_action( + request=request, + output_path=Path(temporary_directory) / "github-output.txt", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("unsupported fields: apply", result.stderr) + calls = json.loads(result.stderr.splitlines()[-1]) + self.assertEqual(calls, []) + + +if __name__ == "__main__": + unittest.main()