diff --git a/.github/workflows/deploy-launchplane.yml b/.github/workflows/deploy-launchplane.yml index e7070a5..ed9800e 100644 --- a/.github/workflows/deploy-launchplane.yml +++ b/.github/workflows/deploy-launchplane.yml @@ -77,6 +77,9 @@ jobs: LAUNCHPLANE_LOCAL_OPERATOR_TOKEN: ${{ secrets.LAUNCHPLANE_LOCAL_OPERATOR_TOKEN }} LAUNCHPLANE_LOCAL_OPERATOR_SUBJECT: ${{ vars.LAUNCHPLANE_LOCAL_OPERATOR_SUBJECT }} LAUNCHPLANE_LOCAL_OPERATOR_TOKEN_LABEL: ${{ vars.LAUNCHPLANE_LOCAL_OPERATOR_TOKEN_LABEL }} + LAUNCHPLANE_PRODUCT_CONFIG_OPERATOR_LOGINS: ${{ vars.LAUNCHPLANE_PRODUCT_CONFIG_OPERATOR_LOGINS }} + LAUNCHPLANE_PRODUCT_CONFIG_OPERATOR_PRODUCTS: ${{ vars.LAUNCHPLANE_PRODUCT_CONFIG_OPERATOR_PRODUCTS }} + LAUNCHPLANE_PRODUCT_CONFIG_OPERATOR_CONTEXTS: ${{ vars.LAUNCHPLANE_PRODUCT_CONFIG_OPERATOR_CONTEXTS }} ODOO_CM_TESTING_DOKPLOY_TARGET_ID: ${{ vars.ODOO_CM_TESTING_DOKPLOY_TARGET_ID }} ODOO_CM_PROD_DOKPLOY_TARGET_ID: ${{ vars.ODOO_CM_PROD_DOKPLOY_TARGET_ID }} ODOO_OPW_TESTING_DOKPLOY_TARGET_ID: ${{ vars.ODOO_OPW_TESTING_DOKPLOY_TARGET_ID }} diff --git a/control_plane/service.py b/control_plane/service.py index fa249f2..f7225d5 100644 --- a/control_plane/service.py +++ b/control_plane/service.py @@ -2666,7 +2666,7 @@ def _latest_merge_train_batch_candidate_record( latest_record = _latest_merge_train_batch_candidate_progress_record(records) if latest_record is None: return None - terminal_statuses = {"passed", "failed", "stale", "blocked"} + terminal_statuses = {"passed", "stale", "blocked"} if latest_record.candidate.status in terminal_statuses: return None return latest_record @@ -2757,6 +2757,18 @@ def _validate_merge_train_landing_record_for_controller( raise ValueError("merge train landing plan policy digest no longer matches") +def _validate_merge_train_stack_collapse_record_for_controller( + *, + collapse_record: MergeTrainStackCollapsePlanRecord, + policy_key: str, + policy_sha256: str, +) -> None: + if collapse_record.plan.policy_key != policy_key: + raise ValueError("merge train stack collapse policy key no longer matches") + if collapse_record.plan.policy_sha256 != policy_sha256: + raise ValueError("merge train stack collapse policy digest no longer matches") + + def _latest_merge_train_batch_candidate_progress_record( records: tuple[MergeTrainBatchCandidateRecord, ...], ) -> MergeTrainBatchCandidateRecord | None: @@ -7941,45 +7953,61 @@ def product_action_allowed( policy_key=repository_policy.policy_key, policy_sha256=policy_record.policy_sha256, ) - if active_candidate_record.candidate.status in {"planned", "building"}: - controller_action = "build_candidate" - if controller_request.mutate: - candidate = github_client.build_batch_candidate( - candidate=active_candidate_record.candidate - ) - else: - candidate = active_candidate_record.candidate + if active_candidate_record.candidate.status == "failed": + result = { + "repository": controller_request.repository, + "base_branch": controller_request.base_branch, + "mode": "dry-run", + "controller_action": "candidate_failed", + "merge_train_batch_candidate_record_id": active_candidate_record.record_id, + "candidate": active_candidate_record.candidate.model_dump( + mode="json" + ), + } + driver_result = result else: - controller_action = "observe_candidate" + if active_candidate_record.candidate.status in { + "planned", + "building", + }: + controller_action = "build_candidate" + if controller_request.mutate: + candidate = github_client.build_batch_candidate( + candidate=active_candidate_record.candidate + ) + else: + candidate = active_candidate_record.candidate + else: + controller_action = "observe_candidate" + if controller_request.mutate: + candidate = github_client.observe_batch_candidate_checks( + candidate=active_candidate_record.candidate + ) + else: + candidate = active_candidate_record.candidate + result = { + "repository": controller_request.repository, + "base_branch": controller_request.base_branch, + "mode": "dry-run" + if not controller_request.mutate + else controller_action, + "controller_action": controller_action, + "merge_train_batch_candidate_record_id": active_candidate_record.record_id, + } if controller_request.mutate: - candidate = github_client.observe_batch_candidate_checks( - candidate=active_candidate_record.candidate + updated_candidate_record = build_merge_train_batch_candidate_record( + candidate=candidate, + source=f"service:controller:{controller_action}:{request_trace_id}", + updated_at=recorded_at, ) - else: - candidate = active_candidate_record.candidate - result = { - "repository": controller_request.repository, - "base_branch": controller_request.base_branch, - "mode": "dry-run" - if not controller_request.mutate - else controller_action, - "controller_action": controller_action, - "merge_train_batch_candidate_record_id": active_candidate_record.record_id, - } - if controller_request.mutate: - updated_candidate_record = build_merge_train_batch_candidate_record( - candidate=candidate, - source=f"service:controller:{controller_action}:{request_trace_id}", - updated_at=recorded_at, - ) - candidate_store.write_merge_train_batch_candidate_record( - updated_candidate_record - ) - result["merge_train_batch_candidate_record_id"] = ( - updated_candidate_record.record_id - ) - result["candidate"] = candidate.model_dump(mode="json") - driver_result = result + candidate_store.write_merge_train_batch_candidate_record( + updated_candidate_record + ) + result["merge_train_batch_candidate_record_id"] = ( + updated_candidate_record.record_id + ) + result["candidate"] = candidate.model_dump(mode="json") + driver_result = result elif passed_candidate_record is not None: _validate_merge_train_candidate_record_for_controller( candidate_record=passed_candidate_record, @@ -8026,6 +8054,11 @@ def product_action_allowed( plan_status="waiting_for_root_checks", ) if waiting_collapse_record is not None: + _validate_merge_train_stack_collapse_record_for_controller( + collapse_record=waiting_collapse_record, + policy_key=repository_policy.policy_key, + policy_sha256=policy_record.policy_sha256, + ) snapshot = GitHubMergeTrainSnapshotReader( transport=transport ).read_merge_train_snapshot( @@ -8111,6 +8144,11 @@ def product_action_allowed( ) ) if planned_collapse_record is not None: + _validate_merge_train_stack_collapse_record_for_controller( + collapse_record=planned_collapse_record, + policy_key=repository_policy.policy_key, + policy_sha256=policy_record.policy_sha256, + ) result = { "repository": controller_request.repository, "base_branch": controller_request.base_branch, diff --git a/docs/operations.md b/docs/operations.md index 807c732..4ddbe98 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -127,6 +127,17 @@ short-lived bearer token or a Launchplane browser session cookie, and the service validates the caller, writes any new active policy record, stores audit metadata, and reloads the current service worker's active policy. +The Launchplane deploy workflow also reconciles configured signed-in operator +grants for product-config writes. Set +`LAUNCHPLANE_PRODUCT_CONFIG_OPERATOR_LOGINS`, +`LAUNCHPLANE_PRODUCT_CONFIG_OPERATOR_PRODUCTS`, and +`LAUNCHPLANE_PRODUCT_CONFIG_OPERATOR_CONTEXTS` as comma-separated repository +variables. During deploy, `scripts/deploy/ensure-authz-grants.sh` writes +DB-backed GitHub-human grants for `product_config.plan` and +`product_config.apply` through `/v1/authz-policies/github-humans/grants`. +Leave those variables unset to skip reconciliation; do not hard-code human +logins or product-specific operator grants in source. + The deploy workflow maintains DB-backed grants for SellYourOutboard operational workflows, including product profile cutover reads/writes, production promotion, and generic-web preview refresh/destroy requests. The grant request returns only diff --git a/scripts/deploy/ensure-authz-grants.sh b/scripts/deploy/ensure-authz-grants.sh index f146acd..4ad27fb 100644 --- a/scripts/deploy/ensure-authz-grants.sh +++ b/scripts/deploy/ensure-authz-grants.sh @@ -123,6 +123,67 @@ post_terminal_agent_grant() { return 1 } +post_product_config_human_grant() { + local action_name="$1" + local source_label="$2" + local idempotency_suffix="$3" + local operator_logins="${LAUNCHPLANE_PRODUCT_CONFIG_OPERATOR_LOGINS:-}" + local operator_products="${LAUNCHPLANE_PRODUCT_CONFIG_OPERATOR_PRODUCTS:-}" + local operator_contexts="${LAUNCHPLANE_PRODUCT_CONFIG_OPERATOR_CONTEXTS:-}" + local request_payload response_file status_code + + if [ -z "$operator_logins" ] || [ -z "$operator_products" ] || [ -z "$operator_contexts" ]; then + echo "Skipping product-config human grant ${source_label}; operator login/product/context variables are not fully configured." + return 0 + fi + + request_payload="$({ + jq -n \ + --arg logins "$operator_logins" \ + --arg products "$operator_products" \ + --arg contexts "$operator_contexts" \ + --arg action_name "$action_name" \ + --arg source_label "$source_label" \ + 'def csv_list($value): + $value + | split(",") + | map(gsub("^\\s+|\\s+$"; "")) + | map(select(length > 0)); + { + schema_version: 1, + product: "launchplane", + mode: "apply", + reason: ("Deploy workflow ensuring product-config human authz grant " + $source_label), + related_issue: "cbusillo/launchplane#521", + grant: { + logins: csv_list($logins), + roles: ["admin"], + products: csv_list($products), + contexts: csv_list($contexts), + actions: [$action_name], + source_label: $source_label + } + }' + })" + response_file="$(mktemp)" + status_code="$(curl -sS \ + -o "$response_file" \ + -w '%{http_code}' \ + -X POST \ + -H "Authorization: Bearer ${oidc_token}" \ + -H 'Content-Type: application/json' \ + -H "Idempotency-Key: launchplane-product-config-human-grant:${idempotency_suffix}:${GITHUB_SHA}" \ + --data "$request_payload" \ + "${LAUNCHPLANE_SERVICE_URL}/v1/authz-policies/github-humans/grants")" + if [ "$status_code" = "200" ] || [ "$status_code" = "202" ]; then + cat "$response_file" + return 0 + fi + cat "$response_file" >&2 + echo "Launchplane product-config human authz grant request failed with HTTP ${status_code}." >&2 + return 1 +} + apply_product_onboarding() { local idempotency_suffix="$1" local request_payload response_file status_code @@ -772,6 +833,14 @@ post_terminal_agent_grant \ every_code_preview_gate.read \ deploy:terminal-agent-agent-context-preview-grant \ terminal-agent-agent-context-preview +post_product_config_human_grant \ + product_config.plan \ + deploy:product-config-human-plan-grant \ + product-config-human-plan +post_product_config_human_grant \ + product_config.apply \ + deploy:product-config-human-apply-grant \ + product-config-human-apply post_grant \ "$GITHUB_REPOSITORY" \ live-target-runtime.yml \ diff --git a/tests/test_service.py b/tests/test_service.py index 95334b9..5044fb6 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -262,6 +262,13 @@ def close_pull_request( return None +class _FakeFailingMergeTrainGitHubClient(_FakeMergeTrainGitHubClient): + def observe_batch_candidate_checks( + self, *, candidate: MergeTrainBatchCandidate + ) -> MergeTrainBatchCandidate: + return candidate.model_copy(update={"required_checks_status": "fail", "status": "failed"}) + + class _FailingChildDispositionMergeTrainGitHubClient(_FakeMergeTrainGitHubClient): def add_pull_request_label( self, *, repository: str, pull_request_number: int, label: str @@ -1855,6 +1862,83 @@ def test_merge_train_controller_advances_unstacked_batch_flow(self) -> None: self.assertEqual(land_payload["result"]["controller_action"], "land_batch") self.assertEqual(land_payload["result"]["landing_plan"]["entries"][0]["status"], "merged") + def test_merge_train_controller_stops_after_candidate_failure(self) -> None: + with ( + TemporaryDirectory() as temporary_directory_name, + patch.dict("os.environ", {"GH_TOKEN": "token"}, clear=True), + ): + state_dir = Path(temporary_directory_name) / "state" + _seed_merge_train_policy(state_dir) + app = create_launchplane_service_app( + state_dir=state_dir, + verifier=_StubVerifier(_merge_train_service_identity()), + authz_policy=_merge_train_service_policy(), + control_plane_root_path=Path(temporary_directory_name), + ) + with ( + patch( + "control_plane.service.GitHubMergeTrainSnapshotReader", + _FakeMergeTrainSnapshotReader, + ), + patch("control_plane.service.GitHubMergeTrainClient", _FakeMergeTrainGitHubClient), + ): + _invoke_app( + app, + method="POST", + path="/v1/work-graph/merge-train/controller/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mutate": True, + }, + ) + _invoke_app( + app, + method="POST", + path="/v1/work-graph/merge-train/controller/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mutate": True, + }, + ) + with patch( + "control_plane.service.GitHubMergeTrainClient", + _FakeFailingMergeTrainGitHubClient, + ): + failed_status, failed_payload = _invoke_app( + app, + method="POST", + path="/v1/work-graph/merge-train/controller/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mutate": True, + }, + ) + with patch("control_plane.service.GitHubMergeTrainClient", _FakeMergeTrainGitHubClient): + stop_status, stop_payload = _invoke_app( + app, + method="POST", + path="/v1/work-graph/merge-train/controller/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mutate": True, + }, + ) + + self.assertEqual(failed_status, 202) + self.assertEqual(failed_payload["result"]["controller_action"], "observe_candidate") + self.assertEqual(failed_payload["result"]["candidate"]["status"], "failed") + self.assertEqual(stop_status, 202) + self.assertEqual(stop_payload["result"]["controller_action"], "candidate_failed") + self.assertEqual(stop_payload["result"]["candidate"]["status"], "failed") + def test_merge_train_controller_advances_stacked_batch_flow(self) -> None: with ( TemporaryDirectory() as temporary_directory_name, @@ -2040,6 +2124,63 @@ def test_merge_train_controller_rejects_candidate_after_policy_digest_changes( self.assertEqual(status_code, 400) self.assertEqual(payload["error"]["code"], "invalid_request") + def test_merge_train_controller_rejects_planned_stack_after_policy_digest_changes( + self, + ) -> None: + with ( + TemporaryDirectory() as temporary_directory_name, + patch.dict("os.environ", {"GH_TOKEN": "token"}, clear=True), + ): + state_dir = Path(temporary_directory_name) / "state" + _seed_merge_train_policy(state_dir) + app = create_launchplane_service_app( + state_dir=state_dir, + verifier=_StubVerifier(_merge_train_service_identity()), + authz_policy=_merge_train_service_policy(), + control_plane_root_path=Path(temporary_directory_name), + ) + with patch( + "control_plane.service.GitHubMergeTrainSnapshotReader", + _FakeStackedMergeTrainSnapshotReader, + ): + _, plan_payload = _invoke_app( + app, + method="POST", + path="/v1/work-graph/merge-train/controller/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mutate": True, + }, + ) + _seed_merge_train_policy( + state_dir, + policy=MergeTrainPolicyRecord( + record_id="merge-train-policy-20260513T220000Z-test", + status="active", + source="test", + updated_at="2026-05-13T22:00:00Z", + policy=build_test_merge_train_policy_with_codex_skills(), + ), + ) + with patch("control_plane.service.GitHubMergeTrainClient", _FakeMergeTrainGitHubClient): + status_code, payload = _invoke_app( + app, + method="POST", + path="/v1/work-graph/merge-train/controller/run-once", + payload={ + "schema_version": 1, + "repository": "cbusillo/sellyouroutboard", + "base_branch": "main", + "mutate": True, + }, + ) + + self.assertIn("merge_train_stack_collapse_plan_record_id", plan_payload["records"]) + self.assertEqual(status_code, 400) + self.assertEqual(payload["error"]["code"], "invalid_request") + def test_merge_train_stack_collapse_service_executes_existing_plan_record(self) -> None: with ( TemporaryDirectory() as temporary_directory_name,