thinkwork-cli 0.13.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -14
- package/dist/{api-client-4DZ266J5.js → api-client-4P4JZOBX.js} +3 -1
- package/dist/bowser-6Z6JFAIR.js +2822 -0
- package/dist/{chunk-Y2WHJECR.js → chunk-4OMDWYZQ.js} +5 -44
- package/dist/chunk-AD2NIWB5.js +6516 -0
- package/dist/chunk-DV3MRBR5.js +43 -0
- package/dist/chunk-EI5ZX7CN.js +522 -0
- package/dist/chunk-EUJ7X6YD.js +126 -0
- package/dist/chunk-GG5WKBOX.js +436 -0
- package/dist/chunk-I64EZSU5.js +358 -0
- package/dist/chunk-KMIJ5KGT.js +128 -0
- package/dist/chunk-MWQ7RO76.js +137 -0
- package/dist/chunk-OCPHU34C.js +996 -0
- package/dist/chunk-OLUQ4U2Z.js +1728 -0
- package/dist/chunk-XYM7PE2A.js +2264 -0
- package/dist/chunk-YQCYD6NE.js +3299 -0
- package/dist/cli.js +49156 -31257
- package/dist/commands/enterprise/templates/deploy-repo/scripts/apply-release.mjs +0 -1
- package/dist/commands/enterprise/templates/deploy-repo/scripts/smoke.mjs +0 -1
- package/dist/commands/enterprise/templates/deploy-repo/terraform/main.tf +0 -14
- package/dist/dist-es-2OTNTL4N.js +315 -0
- package/dist/dist-es-2XVAH4BQ.js +14726 -0
- package/dist/dist-es-3DPDUJAH.js +2000 -0
- package/dist/dist-es-BNZTBBVC.js +176 -0
- package/dist/dist-es-CD2RBDAC.js +88 -0
- package/dist/dist-es-I4FW57PS.js +88 -0
- package/dist/dist-es-KL7Y5NB2.js +377 -0
- package/dist/dist-es-KVFWJNDY.js +318 -0
- package/dist/dist-es-PS73DWNL.js +70 -0
- package/dist/dist-es-S3NZQFGT.js +170 -0
- package/dist/dist-es-SGCA4P32.js +48 -0
- package/dist/dist-es-WBM6J4RA.js +70 -0
- package/dist/dist-es-ZGVADYIF.js +485 -0
- package/dist/dist-es-ZKC2QKD2.js +377 -0
- package/dist/drizzle/0076_scheduled_jobs_marco_backfill.sql +2 -1
- package/dist/drizzle/0079_seed_tenant_customize_catalog.sql +1 -0
- package/dist/drizzle/0090_brain_schema_extraction.sql +10 -0
- package/dist/drizzle/0132_drop_computer_tables.sql +3 -0
- package/dist/drizzle/0160_compliance_event_types_plugins.sql +2 -0
- package/dist/drizzle/0214_think137_drop_judge_evidence_roi.sql +1 -0
- package/dist/drizzle/0216_drop_agent_loop_versions_goal_worker_policy.sql +1 -0
- package/dist/drizzle/0227_analyst_reader_role.sql +17 -20
- package/dist/drizzle/0230_analyst_rls.sql +28 -36
- package/dist/drizzle/0250_kg_schema_extraction.sql +27 -0
- package/dist/drizzle/0251_brain_stragglers.sql +21 -0
- package/dist/drizzle/0253_drop_brain_substrate.sql +2 -0
- package/dist/drizzle/0257_agents_runtime_allow_harness.sql +24 -0
- package/dist/drizzle/0261_native_auth_control_plane.sql +332 -0
- package/dist/drizzle/0262_auth_subscription_tickets.sql +59 -0
- package/dist/drizzle/0263_agentcore_runtime_identifier.sql +69 -0
- package/dist/drizzle/0263_drop_workos_auth_runtime.sql +236 -0
- package/dist/drizzle/0264_auth_enrollment_attempt_limits.sql +15 -0
- package/dist/drizzle/0265_eval_runs_requester_identity.sql +28 -0
- package/dist/drizzle/0265_require_fully_valid_auth_publication.sql +27 -0
- package/dist/drizzle/0266_auth_identity_recovery_grants.sql +9 -0
- package/dist/drizzle/0266_eval_profiles_runtime_type.sql +12 -0
- package/dist/drizzle/0267_ontology_candidate_rejections.sql +53 -0
- package/dist/drizzle/0268_identity_crosswalk_routing.sql +182 -0
- package/dist/drizzle/0269_ontology_system_map.sql +31 -0
- package/dist/drizzle/0270_tool_execution_ledger.sql +115 -0
- package/dist/drizzle/0271_ontology_twin_declarations.sql +47 -0
- package/dist/drizzle/0272_identity_graph_projection_cursor.sql +23 -0
- package/dist/drizzle/0273_twin_materialization_suggestions.sql +29 -0
- package/dist/drizzle/0274_tenant_mcp_twin_keys.sql +59 -0
- package/dist/drizzle/0275_identity_bulk_rebuild_fence.sql +30 -0
- package/dist/drizzle/0276_tool_execution_ledger_allow_cascade_delete.sql +18 -0
- package/dist/drizzle/0277_knowledge_base_sources.sql +107 -0
- package/dist/drizzle/0278_kb_page_transcription.sql +55 -0
- package/dist/drizzle/0279_mcp_servers_off_plugin_provenance.sql +93 -0
- package/dist/drizzle/0280_work_item_external_refs_drop_plane_provider.sql +46 -0
- package/dist/drizzle/0281_tenant_mcp_twin_keys_expiry_suffix.sql +9 -0
- package/dist/drizzle/0282_tenant_mcp_twin_keys_grants.sql +24 -0
- package/dist/drizzle/0283_agents_agentcore_runtime_dispatch.sql +13 -0
- package/dist/drizzle/0284_user_brain_claims.sql +69 -0
- package/dist/drizzle/0285_tenant_mcp_twin_keys_trusted_subsystem.sql +30 -0
- package/dist/drizzle/0286_brain_analytics_key.sql +26 -0
- package/dist/drizzle/0287_brain_subject.sql +25 -0
- package/dist/drizzle/0288_multi_lane_identities.sql +25 -0
- package/dist/event-streams-7JNLJEEL.js +894 -0
- package/dist/event-streams-KDF5RWR6.js +1515 -0
- package/dist/loadSso-ETCXTR4I.js +576 -0
- package/dist/loadSso-UBD7KUHE.js +583 -0
- package/dist/plugins/README.md +2 -2
- package/dist/plugins/catalog/package.json +1 -5
- package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +6 -4
- package/dist/plugins/catalog/src/__tests__/catalog.test.ts +21 -21
- package/dist/plugins/catalog/src/__tests__/contracts.test.ts +102 -94
- package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +7 -8
- package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -20
- package/dist/plugins/catalog/src/contracts.ts +17 -179
- package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -24
- package/dist/plugins/n8n/test/integrated-app-contract.test.ts +1 -1
- package/dist/plugins/twenty/package.json +2 -1
- package/dist/plugins/twenty/scripts/LAMBDA_DEPLOY.md +114 -0
- package/dist/plugins/twenty/scripts/build-lambda.sh +29 -0
- package/dist/plugins/twenty/scripts/configure-product-views.ts +195 -0
- package/dist/plugins/twenty/scripts/lambda-handler.ts +49 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/load-attachments.test.ts +38 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/load-records.test.ts +145 -2
- package/dist/plugins/twenty/scripts/lib/__tests__/mappers.test.ts +397 -151
- package/dist/plugins/twenty/scripts/lib/__tests__/members-ensure.test.ts +28 -129
- package/dist/plugins/twenty/scripts/lib/__tests__/provision-members-db.test.ts +10 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/schema-ensure.test.ts +13 -8
- package/dist/plugins/twenty/scripts/lib/lastmile-reader.ts +222 -2
- package/dist/plugins/twenty/scripts/lib/load-attachments.ts +29 -0
- package/dist/plugins/twenty/scripts/lib/load-records.ts +160 -43
- package/dist/plugins/twenty/scripts/lib/mappers.ts +368 -152
- package/dist/plugins/twenty/scripts/lib/members-ensure.ts +43 -268
- package/dist/plugins/twenty/scripts/lib/provision-members-db.ts +57 -0
- package/dist/plugins/twenty/scripts/lib/schema-ensure.ts +136 -31
- package/dist/plugins/twenty/scripts/migrate-lastmile.ts +254 -152
- package/dist/plugins/twenty/scripts/provision-twenty-members.ts +44 -1
- package/dist/plugins/twenty/scripts/purge-lastmile-import.ts +178 -0
- package/dist/plugins/twenty/scripts/restructure-products.ts +312 -0
- package/dist/plugins/twenty/scripts/retag-stage-values.ts +362 -0
- package/dist/plugins/twenty/test/manifest.test.ts +2 -4
- package/dist/scripts/post-deploy.sh +79 -1
- package/dist/signin-5QZNRSOO.js +821 -0
- package/dist/sso-oidc-XK673P54.js +837 -0
- package/dist/sts-OXI2UALF.js +1113 -0
- package/dist/sts-SQW4NBK3.js +1117 -0
- package/dist/terraform/examples/greenfield/main.tf +391 -176
- package/dist/terraform/examples/greenfield/terraform.tfvars.example +26 -8
- package/dist/terraform/modules/app/agentcore-admin/main.tf +47 -0
- package/dist/terraform/modules/app/agentcore-code-interpreter/README.md +39 -47
- package/dist/terraform/modules/app/agentcore-code-interpreter/main.tf +13 -66
- package/dist/terraform/modules/app/agentcore-identity/main.tf +253 -0
- package/dist/terraform/modules/app/agentcore-identity/outputs.tf +51 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/bootstrap_twenty_oauth_client.sh +239 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/delete_identity.sh +22 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/delete_twenty_identity.sh +16 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/delete_workload_identity.sh +19 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/ensure_workload_identity.sh +29 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/read_identity.sh +55 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_identity.sh +73 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_identity.sh +45 -0
- package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_provider.mjs +123 -0
- package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/after.tf +15 -0
- package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/before.tf +7 -0
- package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/identity-after.tf +8 -0
- package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/identity-before.tf +9 -0
- package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/thinkwork-after.tf +3 -0
- package/dist/terraform/modules/app/agentcore-identity/tests/tainted-marker/thinkwork-before.tf +3 -0
- package/dist/terraform/modules/app/agentcore-identity/variables.tf +60 -0
- package/dist/terraform/modules/app/agentcore-memory/README.md +46 -9
- package/dist/terraform/modules/app/agentcore-memory/main.tf +27 -10
- package/dist/terraform/modules/app/agentcore-memory/scripts/create_or_find_memory.sh +156 -24
- package/dist/terraform/modules/app/agentcore-pi/README.md +1 -3
- package/dist/terraform/modules/app/agentcore-pi/main.tf +42 -52
- package/dist/terraform/modules/app/agentcore-pi/outputs.tf +3 -3
- package/dist/terraform/modules/app/agentcore-pi/scripts/reconcile_pi_runtime.mjs +175 -0
- package/dist/terraform/modules/app/agentcore-pi/variables.tf +35 -28
- package/dist/terraform/modules/app/agentcore-platform/main.tf +68 -0
- package/dist/terraform/modules/app/agentcore-runtime/main.tf +4 -20
- package/dist/terraform/modules/app/appsync-subscriptions/main.tf +127 -21
- package/dist/terraform/modules/app/appsync-subscriptions/outputs.tf +0 -6
- package/dist/terraform/modules/app/appsync-subscriptions/variables.tf +5 -0
- package/dist/terraform/modules/app/capability-broker/main.tf +2 -2
- package/dist/terraform/modules/app/capability-broker/variables.tf +11 -0
- package/dist/terraform/modules/app/deployment-control-plane/README.md +264 -0
- package/dist/terraform/modules/app/deployment-control-plane/buildspec.yml +4 -1
- package/dist/terraform/modules/app/deployment-control-plane/main.tf +94 -0
- package/dist/terraform/modules/app/deployment-control-plane/outputs.tf +5 -0
- package/dist/terraform/modules/app/deployment-control-plane/runner.py +3689 -233
- package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +2579 -140
- package/dist/terraform/modules/app/deployment-control-plane/variables.tf +33 -0
- package/dist/terraform/modules/app/lambda-api/auth-state.tf +72 -0
- package/dist/terraform/modules/app/lambda-api/chat-latency-observability.tf +466 -0
- package/dist/terraform/modules/app/lambda-api/dispatch-dlq.tf +55 -0
- package/dist/terraform/modules/app/lambda-api/handlers.tf +531 -619
- package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +350 -241
- package/dist/terraform/modules/app/lambda-api/main.tf +75 -13
- package/dist/terraform/modules/app/lambda-api/memory-alarms.tf +3 -28
- package/dist/terraform/modules/app/lambda-api/oauth-secrets.tf +1 -2
- package/dist/terraform/modules/app/lambda-api/outputs.tf +17 -10
- package/dist/terraform/modules/app/lambda-api/runtime-config.tf +3 -21
- package/dist/terraform/modules/app/lambda-api/turn-assertion.tf +45 -0
- package/dist/terraform/modules/app/lambda-api/variables.tf +307 -121
- package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/README.md +1 -1
- package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/main.tf +18 -18
- package/dist/terraform/modules/app/sandbox-log-scrubber/README.md +11 -10
- package/dist/terraform/modules/app/sandbox-log-scrubber/main.tf +5 -5
- package/dist/terraform/modules/app/static-site/main.tf +1 -1
- package/dist/terraform/modules/app/www-dns/main.tf +9 -27
- package/dist/terraform/modules/app/www-dns/variables.tf +4 -10
- package/dist/terraform/modules/data/aurora-postgres/README.md +34 -0
- package/dist/terraform/modules/data/aurora-postgres/alarms.tf +82 -0
- package/dist/terraform/modules/data/aurora-postgres/main.tf +27 -57
- package/dist/terraform/modules/data/aurora-postgres/outputs.tf +0 -9
- package/dist/terraform/modules/data/aurora-postgres/variables.tf +41 -0
- package/dist/terraform/modules/data/s3-buckets/main.tf +5 -6
- package/dist/terraform/modules/foundation/cognito/main.tf +367 -72
- package/dist/terraform/modules/foundation/cognito/outputs.tf +47 -0
- package/dist/terraform/modules/foundation/cognito/variables.tf +131 -9
- package/dist/terraform/modules/thinkwork/README.md +5 -1
- package/dist/terraform/modules/thinkwork/main.tf +459 -234
- package/dist/terraform/modules/thinkwork/outputs.tf +44 -62
- package/dist/terraform/modules/thinkwork/variables.tf +353 -89
- package/dist/terraform/schema.graphql +69 -36
- package/dist/workspace-defaults/files/INSTRUCTIONS.md +9 -11
- package/dist/workspace-defaults/files/MEMORY_GUIDE.md +38 -35
- package/dist/workspace-defaults/files/ROUTER.md +1 -1
- package/dist/workspace-defaults/files/TOOLS.md +9 -39
- package/dist/workspace-defaults/files/USER.md +1 -0
- package/dist/workspace-defaults/files/agents/analyst/INSTRUCTIONS.md +6 -3
- package/dist/workspace-defaults/files/catalog-skills/brain-brief-builder/SKILL.md +165 -0
- package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/SKILL.md +75 -0
- package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/references/mcp-tooling.md +40 -0
- package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/references/validation-and-handoff.md +40 -0
- package/dist/workspace-defaults/files/catalog-skills/n8n-workflow-operator/references/workflow-authoring.md +46 -0
- package/dist/workspace-defaults/files/skills/artifact-builder/references/crm-dashboard.md +1 -1
- package/dist/workspace-defaults/files/skills/document-composer/SKILL.md +22 -3
- package/package.json +3 -2
- package/dist/drizzle/0261_thread_turns_origin_turn_index.sql +0 -11
- package/dist/plugins/company-data/README.md +0 -49
- package/dist/plugins/company-data/package.json +0 -21
- package/dist/plugins/company-data/src/index.ts +0 -30
- package/dist/plugins/company-data/src/manifest.ts +0 -26
- package/dist/plugins/company-data/test/manifest.test.ts +0 -116
- package/dist/plugins/company-data/tsconfig.json +0 -8
- package/dist/plugins/company-etl/README.md +0 -46
- package/dist/plugins/company-etl/package.json +0 -21
- package/dist/plugins/company-etl/src/index.ts +0 -29
- package/dist/plugins/company-etl/src/manifest.ts +0 -26
- package/dist/plugins/company-etl/test/manifest.test.ts +0 -108
- package/dist/plugins/company-etl/tsconfig.json +0 -8
- package/dist/plugins/lastmile/README.md +0 -50
- package/dist/plugins/lastmile/package.json +0 -23
- package/dist/plugins/lastmile/smoke/lastmile-plugin-smoke.mjs +0 -661
- package/dist/plugins/lastmile/src/api/tasks-adapter.ts +0 -572
- package/dist/plugins/lastmile/src/discovery.fixture.ts +0 -64
- package/dist/plugins/lastmile/src/index.ts +0 -46
- package/dist/plugins/lastmile/src/manifest.ts +0 -139
- package/dist/plugins/lastmile/test/api/tasks-adapter.test.ts +0 -266
- package/dist/plugins/lastmile/test/discovery.test.ts +0 -88
- package/dist/plugins/lastmile/tsconfig.json +0 -8
- package/dist/plugins/n8n/smoke/n8n-managed-app-smoke.mjs +0 -409
- package/dist/plugins/twenty/smoke/twenty-managed-app-smoke.mjs +0 -359
- package/dist/plugins/workos-auth/package.json +0 -22
- package/dist/plugins/workos-auth/src/index.ts +0 -30
- package/dist/plugins/workos-auth/src/manifest.ts +0 -40
- package/dist/plugins/workos-auth/src/provider-contract.ts +0 -39
- package/dist/plugins/workos-auth/test/manifest.test.ts +0 -94
- package/dist/plugins/workos-auth/tsconfig.json +0 -8
- package/dist/terraform/modules/app/agentcore-code-interpreter/Dockerfile.sandbox-base +0 -61
- package/dist/terraform/modules/app/agentcore-code-interpreter/sandbox/sitecustomize.py +0 -227
- package/dist/terraform/modules/app/agentcore-code-interpreter/sandbox/test_sitecustomize.py +0 -277
- package/dist/terraform/modules/app/agentcore-code-interpreter/scripts/build_and_push_sandbox_base.sh +0 -70
- package/dist/terraform/modules/app/agentcore-harness/main.tf +0 -101
- package/dist/terraform/modules/app/agentcore-harness/outputs.tf +0 -9
- package/dist/terraform/modules/app/agentcore-harness/variables.tf +0 -25
- package/dist/terraform/modules/app/hindsight-memory/README.md +0 -89
- package/dist/terraform/modules/app/hindsight-memory/main.tf +0 -411
- package/dist/terraform/modules/data/bedrock-knowledge-base/main.tf +0 -102
- /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/outputs.tf +0 -0
- /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/scripts/sync-database.py +0 -0
- /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/scripts/test_sync_database.py +0 -0
- /package/dist/{plugins/n8n/terraform → terraform/modules/app}/n8n/variables.tf +0 -0
- /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/README.md +0 -0
- /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/main.tf +0 -0
- /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/outputs.tf +0 -0
- /package/dist/{plugins/twenty/terraform → terraform/modules/app}/twenty/variables.tf +0 -0
|
@@ -8,6 +8,7 @@ import subprocess
|
|
|
8
8
|
import tarfile
|
|
9
9
|
from hashlib import sha256
|
|
10
10
|
from pathlib import Path
|
|
11
|
+
from types import SimpleNamespace
|
|
11
12
|
|
|
12
13
|
import pytest
|
|
13
14
|
|
|
@@ -125,18 +126,635 @@ def test_customer_update_rejects_non_customer_agentcore_pi_source_image() -> Non
|
|
|
125
126
|
def test_customer_update_accepts_customer_ecr_agentcore_pi_source_image() -> None:
|
|
126
127
|
runner = load_runner()
|
|
127
128
|
image = (
|
|
128
|
-
"637423202447.dkr.ecr.us-east-1.amazonaws.com/"
|
|
129
|
-
"thinkwork-tei-e2e-agentcore:pinned@sha256:abc"
|
|
129
|
+
"637423202447.dkr.ecr.us-east-1.amazonaws.com/thinkwork-tei-e2e-agentcore:pinned@sha256:abc"
|
|
130
130
|
)
|
|
131
131
|
|
|
132
|
-
assert
|
|
132
|
+
assert (
|
|
133
|
+
runner.resolve_agentcore_pi_source_image_uri(
|
|
134
|
+
{
|
|
135
|
+
"action": "update",
|
|
136
|
+
"awsAccountId": "637423202447",
|
|
137
|
+
"awsRegion": "us-east-1",
|
|
138
|
+
"agentcorePiSourceImageUri": image,
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
== image
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
SECRET_PEM = "-----BEGIN PUBLIC KEY-----\nMIIFakePemBody\n-----END PUBLIC KEY-----"
|
|
146
|
+
SECRET_API = "fake-api-auth-secret-000"
|
|
147
|
+
PI_RECONCILE_PAYLOAD = {
|
|
148
|
+
"action": "update",
|
|
149
|
+
"awsAccountId": "637423202447",
|
|
150
|
+
"awsRegion": "us-east-1",
|
|
151
|
+
# The runtime reconcile resolves the arm64 mirror by release version tag
|
|
152
|
+
# (AgentCore is arm64-only); the amd64 Lambda pin below is NOT used for
|
|
153
|
+
# the runtime image (tei-e2e canary, 2026-08-04).
|
|
154
|
+
"releaseVersion": "v0.1.0-canary.434",
|
|
155
|
+
"agentcorePiSourceImageUri": (
|
|
156
|
+
"637423202447.dkr.ecr.us-east-1.amazonaws.com/thinkwork-tei-e2e-agentcore:pinned"
|
|
157
|
+
),
|
|
158
|
+
}
|
|
159
|
+
PI_DIGEST = "sha256:" + "a" * 64
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _pi_reconcile_fixture(runner, tmp_path, monkeypatch, ssm_runtime_id):
|
|
163
|
+
entry_dir = tmp_path / "control-runtime"
|
|
164
|
+
entry_dir.mkdir()
|
|
165
|
+
(entry_dir / "reconcile_pi_runtime.js").write_text("// stub", encoding="utf-8")
|
|
166
|
+
monkeypatch.setenv("THINKWORK_AGENTCORE_CONTROL_RUNTIME_DIR", str(entry_dir))
|
|
167
|
+
|
|
168
|
+
calls = {"output": [], "run": [], "node": []}
|
|
169
|
+
|
|
170
|
+
def fake_output(args, **kwargs):
|
|
171
|
+
calls["output"].append(args)
|
|
172
|
+
if args[1] == "lambda":
|
|
173
|
+
return json.dumps(
|
|
174
|
+
{
|
|
175
|
+
"Role": "arn:aws:iam::637423202447:role/thinkwork-tei-e2e-agentcore-pi-role",
|
|
176
|
+
"FunctionArn": (
|
|
177
|
+
"arn:aws:lambda:us-east-1:637423202447:function:thinkwork-tei-e2e-agentcore-pi"
|
|
178
|
+
),
|
|
179
|
+
"Environment": {
|
|
180
|
+
"Variables": {
|
|
181
|
+
"CAPABILITY_SIGNING_PUBLIC_KEY": SECRET_PEM,
|
|
182
|
+
"API_AUTH_SECRET": SECRET_API,
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
if args[1] == "ecr":
|
|
188
|
+
return PI_DIGEST
|
|
189
|
+
if args[1] == "ssm":
|
|
190
|
+
if ssm_runtime_id is None:
|
|
191
|
+
raise subprocess.CalledProcessError(255, args)
|
|
192
|
+
return ssm_runtime_id
|
|
193
|
+
raise AssertionError(f"unexpected output() args: {args}")
|
|
194
|
+
|
|
195
|
+
def fake_run(args, **kwargs):
|
|
196
|
+
calls["run"].append(args)
|
|
197
|
+
return SimpleNamespace(returncode=0)
|
|
198
|
+
|
|
199
|
+
def fake_subprocess_run(args, **kwargs):
|
|
200
|
+
calls["node"].append({"args": args, "input": kwargs.get("input", "")})
|
|
201
|
+
return SimpleNamespace(
|
|
202
|
+
returncode=0,
|
|
203
|
+
stdout=json.dumps(
|
|
204
|
+
{
|
|
205
|
+
"runtimeId": "rt-pi-123",
|
|
206
|
+
"created": ssm_runtime_id is None,
|
|
207
|
+
"version": "7",
|
|
208
|
+
"status": "READY",
|
|
209
|
+
"envCount": 2,
|
|
210
|
+
}
|
|
211
|
+
),
|
|
212
|
+
stderr="",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
monkeypatch.setattr(runner, "output", fake_output)
|
|
216
|
+
monkeypatch.setattr(runner, "run", fake_run)
|
|
217
|
+
monkeypatch.setattr(runner.subprocess, "run", fake_subprocess_run)
|
|
218
|
+
return calls
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_pi_runtime_reconcile_creates_when_absent_and_writes_ssm(
|
|
222
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
|
|
223
|
+
) -> None:
|
|
224
|
+
runner = load_runner()
|
|
225
|
+
calls = _pi_reconcile_fixture(runner, tmp_path, monkeypatch, ssm_runtime_id=None)
|
|
226
|
+
|
|
227
|
+
result = runner.reconcile_agentcore_pi_runtime({"stage": "tei-e2e"}, PI_RECONCILE_PAYLOAD)
|
|
228
|
+
|
|
229
|
+
assert result["runtimeId"] == "rt-pi-123"
|
|
230
|
+
request = json.loads(calls["node"][0]["input"])
|
|
231
|
+
assert request["runtimeId"] == ""
|
|
232
|
+
# AgentCore names forbid hyphens ([a-zA-Z][a-zA-Z0-9_]{0,47}) — the
|
|
233
|
+
# tei-e2e canary proved the unsanitized name 400s (2026-08-04).
|
|
234
|
+
assert request["runtimeName"] == "thinkwork_tei_e2e_pi"
|
|
235
|
+
# The runtime image resolves from the versioned arm64 mirror tag, never
|
|
236
|
+
# from the amd64 Lambda pin.
|
|
237
|
+
ecr_calls = [args for args in calls["output"] if args[1] == "ecr"]
|
|
238
|
+
assert any("imageTag=v0.1.0-canary.434-pi-arm64" in " ".join(args) for args in ecr_calls)
|
|
239
|
+
assert request["imageUri"].endswith(f"@{PI_DIGEST}")
|
|
240
|
+
assert request["environment"]["API_AUTH_SECRET"] == SECRET_API
|
|
241
|
+
assert "\\n" in request["environment"]["CAPABILITY_SIGNING_PUBLIC_KEY"]
|
|
242
|
+
assert "\n" not in request["environment"]["CAPABILITY_SIGNING_PUBLIC_KEY"]
|
|
243
|
+
ssm_put = [args for args in calls["run"] if args[2] == "put-parameter"]
|
|
244
|
+
assert len(ssm_put) == 1 and "rt-pi-123" in ssm_put[0]
|
|
245
|
+
# R19: secret values never reach argv or logs.
|
|
246
|
+
for args in calls["node"]:
|
|
247
|
+
assert SECRET_API not in " ".join(args["args"])
|
|
248
|
+
captured = capsys.readouterr()
|
|
249
|
+
assert SECRET_API not in captured.out + captured.err
|
|
250
|
+
assert "MIIFakePemBody" not in captured.out + captured.err
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_pi_runtime_reconcile_updates_when_present(
|
|
254
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
255
|
+
) -> None:
|
|
256
|
+
runner = load_runner()
|
|
257
|
+
calls = _pi_reconcile_fixture(runner, tmp_path, monkeypatch, ssm_runtime_id="rt-pi-123")
|
|
258
|
+
|
|
259
|
+
result = runner.reconcile_agentcore_pi_runtime({"stage": "tei-e2e"}, PI_RECONCILE_PAYLOAD)
|
|
260
|
+
|
|
261
|
+
assert result["runtimeId"] == "rt-pi-123"
|
|
262
|
+
request = json.loads(calls["node"][0]["input"])
|
|
263
|
+
assert request["runtimeId"] == "rt-pi-123"
|
|
264
|
+
assert [args for args in calls["run"] if args[2] == "put-parameter"]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_pi_runtime_reconcile_aborts_on_empty_lambda_env(
|
|
268
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
269
|
+
) -> None:
|
|
270
|
+
runner = load_runner()
|
|
271
|
+
calls = _pi_reconcile_fixture(runner, tmp_path, monkeypatch, ssm_runtime_id="rt-pi-123")
|
|
272
|
+
|
|
273
|
+
def empty_env_output(args, **kwargs):
|
|
274
|
+
if args[1] == "lambda":
|
|
275
|
+
return json.dumps(
|
|
276
|
+
{
|
|
277
|
+
"Role": "arn:aws:iam::637423202447:role/r",
|
|
278
|
+
"FunctionArn": "arn:aws:lambda:us-east-1:637423202447:function:f",
|
|
279
|
+
"Environment": {"Variables": {}},
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
raise AssertionError("must abort before any non-lambda call")
|
|
283
|
+
|
|
284
|
+
monkeypatch.setattr(runner, "output", empty_env_output)
|
|
285
|
+
|
|
286
|
+
with pytest.raises(RuntimeError, match="EMPTY environment"):
|
|
287
|
+
runner.reconcile_agentcore_pi_runtime({"stage": "tei-e2e"}, PI_RECONCILE_PAYLOAD)
|
|
288
|
+
assert not calls["node"]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_pi_runtime_reconcile_skips_on_legacy_bundle(
|
|
292
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
|
|
293
|
+
) -> None:
|
|
294
|
+
runner = load_runner()
|
|
295
|
+
monkeypatch.delenv("THINKWORK_AGENTCORE_CONTROL_RUNTIME_DIR", raising=False)
|
|
296
|
+
|
|
297
|
+
assert runner.reconcile_agentcore_pi_runtime({"stage": "tei-e2e"}, PI_RECONCILE_PAYLOAD) is None
|
|
298
|
+
assert "SKIPPED" in capsys.readouterr().out
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_native_auth_custom_attributes_skip_before_user_pool_exists(
|
|
302
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
303
|
+
) -> None:
|
|
304
|
+
runner = load_runner()
|
|
305
|
+
monkeypatch.setattr(
|
|
306
|
+
runner,
|
|
307
|
+
"output",
|
|
308
|
+
lambda *_args, **_kwargs: pytest.fail("Cognito should not be queried"),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
assert runner.ensure_native_auth_custom_attributes({}) == {
|
|
312
|
+
"status": "skipped",
|
|
313
|
+
"reason": "user-pool-not-created",
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def test_native_auth_custom_attributes_are_added_before_route_clients(
|
|
318
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
319
|
+
) -> None:
|
|
320
|
+
runner = load_runner()
|
|
321
|
+
descriptions = iter(
|
|
322
|
+
[
|
|
323
|
+
json.dumps(
|
|
324
|
+
{
|
|
325
|
+
"UserPool": {
|
|
326
|
+
"SchemaAttributes": [
|
|
327
|
+
{"Name": "email"},
|
|
328
|
+
{"Name": "custom:tenant_id"},
|
|
329
|
+
]
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
),
|
|
333
|
+
json.dumps(
|
|
334
|
+
{
|
|
335
|
+
"UserPool": {
|
|
336
|
+
"SchemaAttributes": [
|
|
337
|
+
{"Name": "email"},
|
|
338
|
+
{"Name": "custom:tenant_id"},
|
|
339
|
+
{"Name": "custom:entra_tenant_id"},
|
|
340
|
+
{"Name": "custom:entra_object_id"},
|
|
341
|
+
]
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
),
|
|
345
|
+
]
|
|
346
|
+
)
|
|
347
|
+
calls = []
|
|
348
|
+
monkeypatch.setattr(runner, "output", lambda *_args, **_kwargs: next(descriptions))
|
|
349
|
+
monkeypatch.setattr(
|
|
350
|
+
runner,
|
|
351
|
+
"run",
|
|
352
|
+
lambda args, **kwargs: calls.append((args, kwargs)),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
result = runner.ensure_native_auth_custom_attributes(
|
|
356
|
+
{"user_pool_id": {"value": "us-east-1_Example123"}}
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
assert result == {
|
|
360
|
+
"status": "reconciled",
|
|
361
|
+
"userPoolId": "us-east-1_Example123",
|
|
362
|
+
"added": ["entra_tenant_id", "entra_object_id"],
|
|
363
|
+
}
|
|
364
|
+
assert len(calls) == 1
|
|
365
|
+
command, kwargs = calls[0]
|
|
366
|
+
assert command[:3] == ["aws", "cognito-idp", "add-custom-attributes"]
|
|
367
|
+
request = json.loads(command[command.index("--cli-input-json") + 1])
|
|
368
|
+
assert request == {
|
|
369
|
+
"UserPoolId": "us-east-1_Example123",
|
|
370
|
+
"CustomAttributes": runner.NATIVE_AUTH_CUSTOM_ATTRIBUTES,
|
|
371
|
+
}
|
|
372
|
+
assert kwargs == {"stdout": subprocess.DEVNULL}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_native_auth_custom_attributes_are_idempotent(
|
|
376
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
377
|
+
) -> None:
|
|
378
|
+
runner = load_runner()
|
|
379
|
+
monkeypatch.setattr(
|
|
380
|
+
runner,
|
|
381
|
+
"output",
|
|
382
|
+
lambda *_args, **_kwargs: json.dumps(
|
|
383
|
+
{
|
|
384
|
+
"UserPool": {
|
|
385
|
+
"SchemaAttributes": [
|
|
386
|
+
{"Name": "custom:entra_tenant_id"},
|
|
387
|
+
{"Name": "custom:entra_object_id"},
|
|
388
|
+
]
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
),
|
|
392
|
+
)
|
|
393
|
+
monkeypatch.setattr(
|
|
394
|
+
runner,
|
|
395
|
+
"run",
|
|
396
|
+
lambda *_args, **_kwargs: pytest.fail("No mutation should be required"),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
assert runner.ensure_native_auth_custom_attributes(
|
|
400
|
+
{"user_pool_id": {"value": "us-east-1_Example123"}}
|
|
401
|
+
) == {
|
|
402
|
+
"status": "current",
|
|
403
|
+
"userPoolId": "us-east-1_Example123",
|
|
404
|
+
"added": [],
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def test_native_auth_reconciliation_builds_route_specific_secret_free_manifest(
|
|
409
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
410
|
+
) -> None:
|
|
411
|
+
runner = load_runner()
|
|
412
|
+
outputs = {
|
|
413
|
+
"user_pool_id": {"value": "us-east-1_Example123"},
|
|
414
|
+
"auth_route_clients": {
|
|
415
|
+
"value": {
|
|
416
|
+
"web:local": {
|
|
417
|
+
"client_id": "1234567890abcdefghijklmnop",
|
|
418
|
+
"route_key": "local",
|
|
419
|
+
"client_family": "web",
|
|
420
|
+
"provider_names": ["COGNITO"],
|
|
421
|
+
"explicit_auth_flows": [
|
|
422
|
+
"ALLOW_USER_PASSWORD_AUTH",
|
|
423
|
+
"ALLOW_USER_SRP_AUTH",
|
|
424
|
+
"ALLOW_REFRESH_TOKEN_AUTH",
|
|
425
|
+
],
|
|
426
|
+
"callback_urls": ["https://app.example/auth/callback"],
|
|
427
|
+
"logout_urls": ["https://app.example"],
|
|
428
|
+
"lifecycle_state": "native",
|
|
429
|
+
},
|
|
430
|
+
"web:google": {
|
|
431
|
+
"client_id": "abcdefghijklmnopqrstuvwxyz",
|
|
432
|
+
"route_key": "google",
|
|
433
|
+
"client_family": "web",
|
|
434
|
+
"provider_names": ["Google"],
|
|
435
|
+
"explicit_auth_flows": ["ALLOW_REFRESH_TOKEN_AUTH"],
|
|
436
|
+
"callback_urls": ["https://app.example/auth/callback"],
|
|
437
|
+
"logout_urls": ["https://app.example"],
|
|
438
|
+
"lifecycle_state": "native",
|
|
439
|
+
},
|
|
440
|
+
"web:microsoft": {
|
|
441
|
+
"client_id": "zyxwvutsrqponmlkjihgfedcba",
|
|
442
|
+
"route_key": "microsoft",
|
|
443
|
+
"client_family": "web",
|
|
444
|
+
"provider_names": ["MicrosoftOrganizations"],
|
|
445
|
+
"explicit_auth_flows": ["ALLOW_REFRESH_TOKEN_AUTH"],
|
|
446
|
+
"callback_urls": ["https://app.example/auth/callback"],
|
|
447
|
+
"logout_urls": ["https://app.example"],
|
|
448
|
+
"lifecycle_state": "native",
|
|
449
|
+
},
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
}
|
|
453
|
+
scope = {
|
|
454
|
+
"stage": "dev",
|
|
455
|
+
"account_id": "123456789012",
|
|
456
|
+
"region": "us-east-1",
|
|
457
|
+
"microsoft_oauth_tenant": "00000000-0000-4000-8000-000000000123",
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
payload, state = runner.native_auth_reconciliation_payload(outputs, scope, {})
|
|
461
|
+
|
|
462
|
+
assert payload["revision"] == 1
|
|
463
|
+
assert [item["connectionKey"] for item in payload["connections"]] == [
|
|
464
|
+
"google",
|
|
465
|
+
"local",
|
|
466
|
+
"microsoft:organizations",
|
|
467
|
+
]
|
|
468
|
+
by_route = {item["routeKey"]: item for item in payload["routeClients"]}
|
|
469
|
+
assert by_route["local"]["providerNames"] == ["COGNITO"]
|
|
470
|
+
assert by_route["google"]["providerNames"] == ["Google"]
|
|
471
|
+
assert by_route["microsoft"]["providerNames"] == ["MicrosoftOrganizations"]
|
|
472
|
+
microsoft_connection = next(
|
|
473
|
+
item
|
|
474
|
+
for item in payload["connections"]
|
|
475
|
+
if item["connectionKey"] == "microsoft:organizations"
|
|
476
|
+
)
|
|
477
|
+
assert microsoft_connection["issuerUrl"] == (
|
|
478
|
+
"https://login.microsoftonline.com/00000000-0000-4000-8000-000000000123/v2.0"
|
|
479
|
+
)
|
|
480
|
+
assert "secret" not in json.dumps(payload).lower()
|
|
481
|
+
assert len(payload["manifestFingerprint"]) == 64
|
|
482
|
+
|
|
483
|
+
retry_payload, _ = runner.native_auth_reconciliation_payload(outputs, scope, {})
|
|
484
|
+
assert retry_payload["idempotencyKey"] == payload["idempotencyKey"]
|
|
485
|
+
assert retry_payload["manifestFingerprint"] == payload["manifestFingerprint"]
|
|
486
|
+
|
|
487
|
+
replay, replay_state = runner.native_auth_reconciliation_payload(outputs, scope, state)
|
|
488
|
+
assert replay is None
|
|
489
|
+
assert replay_state == state
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _microsoft_reconciliation_scope(tenant: str) -> tuple[dict, dict]:
|
|
493
|
+
outputs = {
|
|
494
|
+
"user_pool_id": {"value": "us-east-1_TESTPOOL"},
|
|
495
|
+
"auth_route_clients": {
|
|
496
|
+
"value": {
|
|
497
|
+
"web:microsoft": {
|
|
498
|
+
"client_id": "zyxwvutsrqponmlkjihgfedcba",
|
|
499
|
+
"route_key": "microsoft",
|
|
500
|
+
"client_family": "web",
|
|
501
|
+
"provider_names": ["MicrosoftOrganizations"],
|
|
502
|
+
"explicit_auth_flows": ["ALLOW_REFRESH_TOKEN_AUTH"],
|
|
503
|
+
"callback_urls": ["https://app.example/auth/callback"],
|
|
504
|
+
"logout_urls": ["https://app.example"],
|
|
505
|
+
"lifecycle_state": "native",
|
|
506
|
+
},
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
}
|
|
510
|
+
scope = {
|
|
511
|
+
"stage": "dev",
|
|
512
|
+
"account_id": "123456789012",
|
|
513
|
+
"region": "us-east-1",
|
|
514
|
+
"microsoft_oauth_tenant": tenant,
|
|
515
|
+
}
|
|
516
|
+
return outputs, scope
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@pytest.mark.parametrize("alias", ["common", "organizations", "consumers"])
|
|
520
|
+
def test_native_auth_reconciliation_accepts_cognito_tenant_aliases(alias: str) -> None:
|
|
521
|
+
runner = load_runner()
|
|
522
|
+
outputs, scope = _microsoft_reconciliation_scope(alias)
|
|
523
|
+
payload, _ = runner.native_auth_reconciliation_payload(outputs, scope, {})
|
|
524
|
+
microsoft_connection = next(
|
|
525
|
+
item
|
|
526
|
+
for item in payload["connections"]
|
|
527
|
+
if item["connectionKey"] == "microsoft:organizations"
|
|
528
|
+
)
|
|
529
|
+
assert microsoft_connection["issuerUrl"] == (f"https://login.microsoftonline.com/{alias}/v2.0")
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@pytest.mark.parametrize("tenant", ["tenant.example", "contoso", "9d65869f", ""])
|
|
533
|
+
def test_native_auth_reconciliation_rejects_arbitrary_tenant_values(tenant: str) -> None:
|
|
534
|
+
runner = load_runner()
|
|
535
|
+
outputs, scope = _microsoft_reconciliation_scope(tenant)
|
|
536
|
+
with pytest.raises(RuntimeError, match="Entra directory GUID or one of"):
|
|
537
|
+
runner.native_auth_reconciliation_payload(outputs, scope, {})
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def tenant_entra_operation(action: str, revision: int = 1) -> dict:
|
|
541
|
+
tenant_id = "00000000-0000-4000-8000-000000000123"
|
|
542
|
+
payload = {
|
|
543
|
+
"action": "update",
|
|
544
|
+
"operation": {
|
|
545
|
+
"kind": "identity_provider",
|
|
546
|
+
"action": action,
|
|
547
|
+
"revision": revision,
|
|
548
|
+
"expectedPreviousRevision": revision - 1,
|
|
549
|
+
"connection": {
|
|
550
|
+
"connectionKey": f"microsoft:tenant:{tenant_id}",
|
|
551
|
+
"tenantId": tenant_id,
|
|
552
|
+
"providerName": "Entra_0000000000004000_deadbeef",
|
|
553
|
+
"displayName": "Acme Microsoft",
|
|
554
|
+
"clientId": "entra-client-id",
|
|
555
|
+
"clientSecretRef": (
|
|
556
|
+
"arn:aws:secretsmanager:us-east-1:123456789012:"
|
|
557
|
+
f"secret:thinkwork/dev/auth/entra/{tenant_id}-ABC123"
|
|
558
|
+
),
|
|
559
|
+
"issuerUrl": f"https://login.microsoftonline.com/{tenant_id}/v2.0",
|
|
560
|
+
"tenantBindings": [
|
|
561
|
+
{
|
|
562
|
+
"tenantId": "019f762b-683c-7153-acff-24ee932d73f6",
|
|
563
|
+
"label": "Acme",
|
|
564
|
+
"hostnames": ["Login.Acme.Example", "login.acme.example"],
|
|
565
|
+
}
|
|
566
|
+
],
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
}
|
|
570
|
+
if action == "rotate":
|
|
571
|
+
payload["operation"]["connection"]["clientSecretVersionStage"] = "AWSPENDING"
|
|
572
|
+
return payload
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def test_identity_provider_operation_builds_revisioned_secret_free_desired_state() -> None:
|
|
576
|
+
runner = load_runner()
|
|
577
|
+
payload = tenant_entra_operation("create")
|
|
578
|
+
|
|
579
|
+
desired = runner.identity_provider_desired_connections(
|
|
580
|
+
payload,
|
|
581
|
+
stage="dev",
|
|
582
|
+
account_id="123456789012",
|
|
583
|
+
region="us-east-1",
|
|
584
|
+
previous_state={},
|
|
585
|
+
current_outputs={"user_pool_id": {"value": "us-east-1_Example123"}},
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
assert len(desired) == 1
|
|
589
|
+
assert desired[0]["lifecycleState"] == "native"
|
|
590
|
+
assert desired[0]["tenantDirectoryId"] == "00000000-0000-4000-8000-000000000123"
|
|
591
|
+
assert desired[0]["tenantBindings"][0]["hostnames"] == ["login.acme.example"]
|
|
592
|
+
assert runner.tenant_entra_terraform_projection(desired) == [
|
|
133
593
|
{
|
|
134
|
-
"
|
|
135
|
-
"
|
|
136
|
-
"
|
|
137
|
-
"
|
|
594
|
+
"connection_key": "microsoft:tenant:00000000-0000-4000-8000-000000000123",
|
|
595
|
+
"tenant_id": "00000000-0000-4000-8000-000000000123",
|
|
596
|
+
"provider_name": "Entra_0000000000004000_deadbeef",
|
|
597
|
+
"display_name": "Acme Microsoft",
|
|
138
598
|
}
|
|
139
|
-
|
|
599
|
+
]
|
|
600
|
+
assert "tenant-super-secret" not in json.dumps(desired)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def test_identity_provider_disable_removes_route_before_provider_retirement() -> None:
|
|
604
|
+
runner = load_runner()
|
|
605
|
+
created = runner.identity_provider_desired_connections(
|
|
606
|
+
tenant_entra_operation("create"),
|
|
607
|
+
stage="dev",
|
|
608
|
+
account_id="123456789012",
|
|
609
|
+
region="us-east-1",
|
|
610
|
+
previous_state={},
|
|
611
|
+
current_outputs={"user_pool_id": {"value": "us-east-1_Example123"}},
|
|
612
|
+
)
|
|
613
|
+
disabled = runner.identity_provider_desired_connections(
|
|
614
|
+
tenant_entra_operation("disable", revision=2),
|
|
615
|
+
stage="dev",
|
|
616
|
+
account_id="123456789012",
|
|
617
|
+
region="us-east-1",
|
|
618
|
+
previous_state={"desiredRevision": 1, "tenantConnections": created},
|
|
619
|
+
current_outputs={"user_pool_id": {"value": "us-east-1_Example123"}},
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
assert disabled[0]["lifecycleState"] == "denied"
|
|
623
|
+
assert disabled[0]["tenantBindings"][0]["status"] == "disabled"
|
|
624
|
+
assert runner.tenant_entra_terraform_projection(disabled) == []
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def test_identity_provider_operation_rejects_cross_stage_secret_reference() -> None:
|
|
628
|
+
runner = load_runner()
|
|
629
|
+
payload = tenant_entra_operation("create")
|
|
630
|
+
payload["operation"]["connection"]["clientSecretRef"] = (
|
|
631
|
+
"arn:aws:secretsmanager:us-east-1:123456789012:secret:thinkwork/prod/auth/entra/example"
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
with pytest.raises(RuntimeError, match="account, region, stage"):
|
|
635
|
+
runner.identity_provider_desired_connections(
|
|
636
|
+
payload,
|
|
637
|
+
stage="dev",
|
|
638
|
+
account_id="123456789012",
|
|
639
|
+
region="us-east-1",
|
|
640
|
+
previous_state={},
|
|
641
|
+
current_outputs={"user_pool_id": {"value": "us-east-1_Example123"}},
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def test_identity_provider_operation_rejects_invalid_secret_version_stage() -> None:
|
|
646
|
+
runner = load_runner()
|
|
647
|
+
payload = tenant_entra_operation("rotate", revision=2)
|
|
648
|
+
payload["operation"]["connection"]["clientSecretVersionStage"] = "AWS_PENDING"
|
|
649
|
+
|
|
650
|
+
with pytest.raises(RuntimeError, match="must be AWSPENDING"):
|
|
651
|
+
runner.identity_provider_desired_connections(
|
|
652
|
+
payload,
|
|
653
|
+
stage="dev",
|
|
654
|
+
account_id="123456789012",
|
|
655
|
+
region="us-east-1",
|
|
656
|
+
previous_state={"desiredRevision": 1, "tenantConnections": []},
|
|
657
|
+
current_outputs={"user_pool_id": {"value": "us-east-1_Example123"}},
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def test_identity_provider_operation_rejects_pending_stage_outside_rotation() -> None:
|
|
662
|
+
runner = load_runner()
|
|
663
|
+
payload = tenant_entra_operation("create")
|
|
664
|
+
payload["operation"]["connection"]["clientSecretVersionStage"] = "AWSPENDING"
|
|
665
|
+
|
|
666
|
+
with pytest.raises(RuntimeError, match="valid only.*rotation"):
|
|
667
|
+
runner.identity_provider_desired_connections(
|
|
668
|
+
payload,
|
|
669
|
+
stage="dev",
|
|
670
|
+
account_id="123456789012",
|
|
671
|
+
region="us-east-1",
|
|
672
|
+
previous_state={},
|
|
673
|
+
current_outputs={"user_pool_id": {"value": "us-east-1_Example123"}},
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def test_identity_provider_rotation_requires_pending_stage() -> None:
|
|
678
|
+
runner = load_runner()
|
|
679
|
+
created = runner.identity_provider_desired_connections(
|
|
680
|
+
tenant_entra_operation("create"),
|
|
681
|
+
stage="dev",
|
|
682
|
+
account_id="123456789012",
|
|
683
|
+
region="us-east-1",
|
|
684
|
+
previous_state={},
|
|
685
|
+
current_outputs={"user_pool_id": {"value": "us-east-1_Example123"}},
|
|
686
|
+
)
|
|
687
|
+
payload = tenant_entra_operation("rotate", revision=2)
|
|
688
|
+
payload["operation"]["connection"].pop("clientSecretVersionStage")
|
|
689
|
+
|
|
690
|
+
with pytest.raises(RuntimeError, match="requires clientSecretVersionStage AWSPENDING"):
|
|
691
|
+
runner.identity_provider_desired_connections(
|
|
692
|
+
payload,
|
|
693
|
+
stage="dev",
|
|
694
|
+
account_id="123456789012",
|
|
695
|
+
region="us-east-1",
|
|
696
|
+
previous_state={"desiredRevision": 1, "tenantConnections": created},
|
|
697
|
+
current_outputs={"user_pool_id": {"value": "us-east-1_Example123"}},
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def test_identity_provider_update_does_not_require_agent_image_pin() -> None:
|
|
702
|
+
runner = load_runner()
|
|
703
|
+
|
|
704
|
+
assert (
|
|
705
|
+
runner.resolve_agentcore_pi_source_image_uri(
|
|
706
|
+
{"action": "update", "operation": {"kind": "identity_provider"}}
|
|
707
|
+
)
|
|
708
|
+
== ""
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def test_identity_provider_rotate_reads_pending_secret_and_promotes_only_after_reconcile(
|
|
713
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
714
|
+
) -> None:
|
|
715
|
+
runner = load_runner()
|
|
716
|
+
connection = runner.identity_provider_desired_connections(
|
|
717
|
+
tenant_entra_operation("rotate", revision=2),
|
|
718
|
+
stage="dev",
|
|
719
|
+
account_id="123456789012",
|
|
720
|
+
region="us-east-1",
|
|
721
|
+
previous_state={
|
|
722
|
+
"desiredRevision": 1,
|
|
723
|
+
"tenantConnections": runner.identity_provider_desired_connections(
|
|
724
|
+
tenant_entra_operation("create"),
|
|
725
|
+
stage="dev",
|
|
726
|
+
account_id="123456789012",
|
|
727
|
+
region="us-east-1",
|
|
728
|
+
previous_state={},
|
|
729
|
+
current_outputs={"user_pool_id": {"value": "us-east-1_Example123"}},
|
|
730
|
+
),
|
|
731
|
+
},
|
|
732
|
+
current_outputs={"user_pool_id": {"value": "us-east-1_Example123"}},
|
|
733
|
+
)[0]
|
|
734
|
+
calls = []
|
|
735
|
+
|
|
736
|
+
def fake_output(args, **_kwargs):
|
|
737
|
+
calls.append(args)
|
|
738
|
+
if "get-secret-value" in args:
|
|
739
|
+
return json.dumps({"clientId": "entra-client-id", "clientSecret": "pending-secret"})
|
|
740
|
+
if "describe-secret" in args:
|
|
741
|
+
return json.dumps(
|
|
742
|
+
{"pending-version": ["AWSPENDING"], "current-version": ["AWSCURRENT"]}
|
|
743
|
+
)
|
|
744
|
+
return "{}"
|
|
745
|
+
|
|
746
|
+
monkeypatch.setattr(runner, "output", fake_output)
|
|
747
|
+
assert runner._read_tenant_entra_secret(connection) == "pending-secret"
|
|
748
|
+
assert "AWSPENDING" in calls[0]
|
|
749
|
+
|
|
750
|
+
vars_json = {"auth_tenant_connection_metadata": [connection]}
|
|
751
|
+
promoted = runner.promote_tenant_entra_pending_secret(
|
|
752
|
+
tenant_entra_operation("rotate", revision=2), vars_json
|
|
753
|
+
)
|
|
754
|
+
assert promoted["status"] == "promoted"
|
|
755
|
+
promotion = next(args for args in calls if "update-secret-version-stage" in args)
|
|
756
|
+
assert "pending-version" in promotion
|
|
757
|
+
assert "current-version" in promotion
|
|
140
758
|
|
|
141
759
|
|
|
142
760
|
def test_bootstrap_can_fall_back_to_release_agentcore_pi_image(
|
|
@@ -146,9 +764,11 @@ def test_bootstrap_can_fall_back_to_release_agentcore_pi_image(
|
|
|
146
764
|
monkeypatch.setattr(
|
|
147
765
|
runner,
|
|
148
766
|
"release_runtime_image",
|
|
149
|
-
lambda name:
|
|
150
|
-
|
|
151
|
-
|
|
767
|
+
lambda name: (
|
|
768
|
+
"ghcr.io/thinkwork-ai/thinkwork-agentcore@sha256:abc"
|
|
769
|
+
if name == "agentcore-pi-amd64"
|
|
770
|
+
else ""
|
|
771
|
+
),
|
|
152
772
|
)
|
|
153
773
|
|
|
154
774
|
assert runner.resolve_agentcore_pi_source_image_uri({"action": "deploy"}) == (
|
|
@@ -230,53 +850,261 @@ def test_sync_release_artifacts_stages_artifacts_from_platform_bundle(
|
|
|
230
850
|
assert {artifact["source"] for artifact in runner.RELEASE_EVIDENCE["artifacts"]} == {"bundle"}
|
|
231
851
|
|
|
232
852
|
|
|
233
|
-
def
|
|
853
|
+
def test_packaged_agentcore_control_runtime_is_exact_and_runnable(
|
|
234
854
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
235
855
|
) -> None:
|
|
236
856
|
runner = load_runner()
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
857
|
+
repo_root = Path(__file__).resolve().parents[4]
|
|
858
|
+
release_dir = tmp_path / "release"
|
|
859
|
+
runner_dir = release_dir / "bundles" / "platform" / "runner"
|
|
860
|
+
subprocess.run(
|
|
861
|
+
[
|
|
862
|
+
"bash",
|
|
863
|
+
str(repo_root / "scripts/release/package-agentcore-control-runtime.sh"),
|
|
864
|
+
str(runner_dir),
|
|
865
|
+
],
|
|
866
|
+
cwd=repo_root,
|
|
867
|
+
check=True,
|
|
868
|
+
text=True,
|
|
869
|
+
)
|
|
244
870
|
|
|
245
|
-
|
|
246
|
-
|
|
871
|
+
monkeypatch.setattr(runner, "RELEASE", release_dir)
|
|
872
|
+
monkeypatch.setattr(runner, "RELEASE_EVIDENCE", {})
|
|
873
|
+
monkeypatch.delenv("THINKWORK_AGENTCORE_CONTROL_RUNTIME_DIR", raising=False)
|
|
247
874
|
|
|
248
|
-
|
|
875
|
+
evidence = runner.prepare_agentcore_control_runtime()
|
|
249
876
|
|
|
250
|
-
|
|
877
|
+
assert evidence["sdkVersion"] == "3.1089.0"
|
|
878
|
+
assert int(evidence["nodeVersion"].removeprefix("v").split(".", 1)[0]) >= 22
|
|
879
|
+
assert evidence["bundledRuntimeVerified"] is True
|
|
880
|
+
assert evidence["bundledEntrypoints"] == [
|
|
881
|
+
"preflight.js",
|
|
882
|
+
"reconcile_pi_runtime.js",
|
|
883
|
+
"reconcile_twenty_provider.js",
|
|
884
|
+
]
|
|
885
|
+
assert json.loads(
|
|
886
|
+
(runner_dir / "agentcore-control-runtime" / "package.json").read_text(encoding="utf-8")
|
|
887
|
+
) == {"type": "module"}
|
|
888
|
+
assert os.environ["THINKWORK_AGENTCORE_CONTROL_RUNTIME_DIR"] == str(
|
|
889
|
+
runner_dir / "agentcore-control-runtime"
|
|
890
|
+
)
|
|
891
|
+
assert all(item["sdkImportReady"] for item in evidence["entrypointPreflights"])
|
|
251
892
|
|
|
252
|
-
assert downloaded_path.exists()
|
|
253
|
-
assert digest(downloaded_path.read_bytes()) == manifest_sha
|
|
254
893
|
|
|
255
|
-
|
|
894
|
+
@pytest.mark.parametrize("version", ["v22.14.0", "v24.1.0", "26.5.0"])
|
|
895
|
+
def test_agentcore_control_runtime_accepts_supported_node_versions(version: str) -> None:
|
|
896
|
+
runner = load_runner()
|
|
256
897
|
|
|
257
|
-
|
|
258
|
-
runner.ensure_release_manifest_available(file_url(manifest_path), canonical_sha)
|
|
898
|
+
assert runner.validate_agentcore_node_runtime(version).startswith("v")
|
|
259
899
|
|
|
260
900
|
|
|
261
|
-
|
|
262
|
-
|
|
901
|
+
@pytest.mark.parametrize("version", ["v20.19.5", "v18.20.8", "unknown", ""])
|
|
902
|
+
def test_agentcore_control_runtime_rejects_unsupported_node_versions(version: str) -> None:
|
|
903
|
+
runner = load_runner()
|
|
904
|
+
|
|
905
|
+
with pytest.raises(RuntimeError, match="Node.js 22|Cannot determine"):
|
|
906
|
+
runner.validate_agentcore_node_runtime(version)
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
@pytest.mark.parametrize("action", ["deploy", "update", "plan", "apply", "destroy"])
|
|
910
|
+
def test_agentcore_control_runtime_is_required_even_when_proof_is_disabled(
|
|
911
|
+
action: str,
|
|
263
912
|
) -> None:
|
|
264
913
|
runner = load_runner()
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
docs_bytes = b"docs-tarball"
|
|
271
|
-
write_tar(
|
|
272
|
-
bundle_path,
|
|
273
|
-
{
|
|
274
|
-
"lambdas/graphql-http.zip": lambda_bytes,
|
|
275
|
-
"static/web.tar.gz": web_bytes,
|
|
276
|
-
"static/docs.tar.gz": docs_bytes,
|
|
277
|
-
},
|
|
914
|
+
|
|
915
|
+
assert runner.requires_agentcore_control_runtime(
|
|
916
|
+
action,
|
|
917
|
+
{"enableAgentCoreHarness": False},
|
|
918
|
+
None,
|
|
278
919
|
)
|
|
279
|
-
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def test_web_only_release_sync_does_not_require_agentcore_control_runtime() -> None:
|
|
923
|
+
runner = load_runner()
|
|
924
|
+
|
|
925
|
+
assert runner.release_sync_request("web", {}, None) == {
|
|
926
|
+
"artifact_types": {"static-site"},
|
|
927
|
+
"artifact_names": {"web"},
|
|
928
|
+
}
|
|
929
|
+
assert not runner.requires_agentcore_control_runtime("web", {}, None)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _state_document(check_results):
|
|
933
|
+
return {
|
|
934
|
+
"version": 4,
|
|
935
|
+
"terraform_version": "1.9.8",
|
|
936
|
+
"serial": 820,
|
|
937
|
+
"lineage": "61f4093e-41da-ac29-852a-267bc853e24f",
|
|
938
|
+
"resources": [{"type": "aws_s3_bucket", "name": "evidence"}],
|
|
939
|
+
"check_results": check_results,
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def test_state_identity_is_stable_across_check_results_render_order(
|
|
944
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
945
|
+
) -> None:
|
|
946
|
+
runner = load_runner()
|
|
947
|
+
check_a = {"object_kind": "var", "config_addr": "module.a.var.x", "status": "pass"}
|
|
948
|
+
check_b = {"object_kind": "var", "config_addr": "module.b.var.y", "status": "pass"}
|
|
949
|
+
pulls = iter(
|
|
950
|
+
[
|
|
951
|
+
json.dumps(_state_document([check_a, check_b]), indent=2) + "\n",
|
|
952
|
+
json.dumps(_state_document([check_b, check_a]), indent=2) + "\n",
|
|
953
|
+
]
|
|
954
|
+
)
|
|
955
|
+
monkeypatch.setattr(runner, "output", lambda *_args, **_kwargs: next(pulls))
|
|
956
|
+
|
|
957
|
+
first = runner.terraform_state_identity()
|
|
958
|
+
second = runner.terraform_state_identity()
|
|
959
|
+
|
|
960
|
+
assert first == second
|
|
961
|
+
assert first["lineage"] == "61f4093e-41da-ac29-852a-267bc853e24f"
|
|
962
|
+
assert first["serial"] == 820
|
|
963
|
+
assert first["terraformVersion"] == "1.9.8"
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def test_state_identity_changes_when_resources_change(
|
|
967
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
968
|
+
) -> None:
|
|
969
|
+
runner = load_runner()
|
|
970
|
+
base = _state_document([])
|
|
971
|
+
changed = dict(base, serial=821, resources=[])
|
|
972
|
+
pulls = iter([json.dumps(base), json.dumps(changed)])
|
|
973
|
+
monkeypatch.setattr(runner, "output", lambda *_args, **_kwargs: next(pulls))
|
|
974
|
+
|
|
975
|
+
first = runner.terraform_state_identity()
|
|
976
|
+
second = runner.terraform_state_identity()
|
|
977
|
+
|
|
978
|
+
assert first["sha256"] != second["sha256"]
|
|
979
|
+
assert second["serial"] == 821
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def test_state_identity_still_refuses_missing_lineage(
|
|
983
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
984
|
+
) -> None:
|
|
985
|
+
runner = load_runner()
|
|
986
|
+
document = _state_document([])
|
|
987
|
+
document.pop("lineage")
|
|
988
|
+
monkeypatch.setattr(runner, "output", lambda *_args, **_kwargs: json.dumps(document))
|
|
989
|
+
|
|
990
|
+
with pytest.raises(RuntimeError, match="no lineage"):
|
|
991
|
+
runner.terraform_state_identity()
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def test_deployment_status_pointer_writes_for_saved_plan_apply(
|
|
995
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
996
|
+
) -> None:
|
|
997
|
+
runner = load_runner()
|
|
998
|
+
monkeypatch.chdir(tmp_path)
|
|
999
|
+
monkeypatch.setenv("THINKWORK_EVIDENCE_BUCKET", "evidence-bucket")
|
|
1000
|
+
monkeypatch.setenv("THINKWORK_DEPLOYMENT_ACTION", "apply")
|
|
1001
|
+
monkeypatch.delenv("THINKWORK_MANAGED_APP_OPERATION", raising=False)
|
|
1002
|
+
commands = []
|
|
1003
|
+
monkeypatch.setattr(runner, "read_current_status_pointer", lambda _bucket: {})
|
|
1004
|
+
monkeypatch.setattr(runner, "run", lambda args, **_kwargs: commands.append(args))
|
|
1005
|
+
|
|
1006
|
+
runner.write_deployment_status_pointer(
|
|
1007
|
+
"succeeded", {"stage": "tei-e2e", "account_id": "637423202447"}
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
assert any(
|
|
1011
|
+
arg.endswith("deployment/status/current.json") for command in commands for arg in command
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def test_saved_plan_apply_stages_the_approved_release_artifacts() -> None:
|
|
1016
|
+
runner = load_runner()
|
|
1017
|
+
|
|
1018
|
+
assert runner.release_sync_request("apply", {}, None) == {
|
|
1019
|
+
"artifact_types": None,
|
|
1020
|
+
"artifact_names": None,
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def test_packaged_agentcore_control_runtime_rebuild_removes_stale_files(
|
|
1025
|
+
tmp_path: Path,
|
|
1026
|
+
) -> None:
|
|
1027
|
+
repo_root = Path(__file__).resolve().parents[4]
|
|
1028
|
+
runner_dir = tmp_path / "runner"
|
|
1029
|
+
runtime_dir = runner_dir / "agentcore-control-runtime"
|
|
1030
|
+
runtime_dir.mkdir(parents=True)
|
|
1031
|
+
(runtime_dir / "stale-chunk.js").write_text("stale\n", encoding="utf-8")
|
|
1032
|
+
|
|
1033
|
+
subprocess.run(
|
|
1034
|
+
[
|
|
1035
|
+
"bash",
|
|
1036
|
+
str(repo_root / "scripts/release/package-agentcore-control-runtime.sh"),
|
|
1037
|
+
str(runner_dir),
|
|
1038
|
+
],
|
|
1039
|
+
cwd=repo_root,
|
|
1040
|
+
check=True,
|
|
1041
|
+
text=True,
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
manifest = json.loads(
|
|
1045
|
+
(runner_dir / "agentcore-control-runtime.json").read_text(encoding="utf-8")
|
|
1046
|
+
)
|
|
1047
|
+
paths = {item["path"] for item in manifest["files"]}
|
|
1048
|
+
generated = {
|
|
1049
|
+
str(path.relative_to(runtime_dir)) for path in runtime_dir.rglob("*") if path.is_file()
|
|
1050
|
+
}
|
|
1051
|
+
assert "stale-chunk.js" not in generated
|
|
1052
|
+
assert paths == generated
|
|
1053
|
+
assert {path for path in paths if "/" not in path} == {
|
|
1054
|
+
"package.json",
|
|
1055
|
+
"preflight.js",
|
|
1056
|
+
"reconcile_pi_runtime.js",
|
|
1057
|
+
"reconcile_twenty_provider.js",
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def test_ensure_release_manifest_available_accepts_manifest_file_digest(
|
|
1062
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1063
|
+
) -> None:
|
|
1064
|
+
runner = load_runner()
|
|
1065
|
+
manifest_path = tmp_path / "thinkwork-release.json"
|
|
1066
|
+
downloaded_path = tmp_path / "downloaded-release.json"
|
|
1067
|
+
bundle_path = tmp_path / "platform-artifacts.tar.gz"
|
|
1068
|
+
write_tar(bundle_path, {})
|
|
1069
|
+
manifest = release_manifest(bundle_path, runner.sha256_file(bundle_path), [])
|
|
1070
|
+
manifest_sha = write_manifest(manifest_path, manifest)
|
|
1071
|
+
canonical_sha = runner.release_manifest_sha256(manifest)
|
|
1072
|
+
|
|
1073
|
+
assert digest(manifest_path.read_bytes()) == manifest_sha
|
|
1074
|
+
assert canonical_sha != manifest_sha
|
|
1075
|
+
|
|
1076
|
+
monkeypatch.setattr(runner, "MANIFEST", downloaded_path)
|
|
1077
|
+
|
|
1078
|
+
runner.ensure_release_manifest_available(file_url(manifest_path), manifest_sha)
|
|
1079
|
+
|
|
1080
|
+
assert downloaded_path.exists()
|
|
1081
|
+
assert digest(downloaded_path.read_bytes()) == manifest_sha
|
|
1082
|
+
|
|
1083
|
+
downloaded_path.unlink()
|
|
1084
|
+
|
|
1085
|
+
with pytest.raises(RuntimeError, match="Release manifest digest mismatch"):
|
|
1086
|
+
runner.ensure_release_manifest_available(file_url(manifest_path), canonical_sha)
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def test_sync_release_artifacts_can_materialize_only_web_static_bundle(
|
|
1090
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1091
|
+
) -> None:
|
|
1092
|
+
runner = load_runner()
|
|
1093
|
+
release_dir = tmp_path / "release-work"
|
|
1094
|
+
manifest_path = tmp_path / "thinkwork-release.json"
|
|
1095
|
+
bundle_path = tmp_path / "platform-artifacts.tar.gz"
|
|
1096
|
+
lambda_bytes = b"lambda-zip"
|
|
1097
|
+
web_bytes = b"web-tarball"
|
|
1098
|
+
extra_bytes = b"extra-tarball"
|
|
1099
|
+
write_tar(
|
|
1100
|
+
bundle_path,
|
|
1101
|
+
{
|
|
1102
|
+
"lambdas/graphql-http.zip": lambda_bytes,
|
|
1103
|
+
"static/web.tar.gz": web_bytes,
|
|
1104
|
+
"static/extra.tar.gz": extra_bytes,
|
|
1105
|
+
},
|
|
1106
|
+
)
|
|
1107
|
+
artifacts = [
|
|
280
1108
|
{
|
|
281
1109
|
"name": "graphql-http",
|
|
282
1110
|
"type": "lambda",
|
|
@@ -296,13 +1124,13 @@ def test_sync_release_artifacts_can_materialize_only_web_static_bundle(
|
|
|
296
1124
|
"sizeBytes": len(web_bytes),
|
|
297
1125
|
},
|
|
298
1126
|
{
|
|
299
|
-
"name": "
|
|
1127
|
+
"name": "extra",
|
|
300
1128
|
"type": "static-site",
|
|
301
|
-
"fileName": "
|
|
302
|
-
"relativePath": "static/
|
|
1129
|
+
"fileName": "extra.tar.gz",
|
|
1130
|
+
"relativePath": "static/extra.tar.gz",
|
|
303
1131
|
"url": None,
|
|
304
|
-
"sha256": digest(
|
|
305
|
-
"sizeBytes": len(
|
|
1132
|
+
"sha256": digest(extra_bytes),
|
|
1133
|
+
"sizeBytes": len(extra_bytes),
|
|
306
1134
|
},
|
|
307
1135
|
]
|
|
308
1136
|
manifest_sha = write_manifest(
|
|
@@ -320,10 +1148,12 @@ def test_sync_release_artifacts_can_materialize_only_web_static_bundle(
|
|
|
320
1148
|
monkeypatch.setenv("THINKWORK_RELEASE_ARTIFACT_BUCKET", "thinkwork-artifacts")
|
|
321
1149
|
monkeypatch.setattr(runner, "run", lambda args, **_kwargs: calls.append(args))
|
|
322
1150
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
1151
|
+
release_request = runner.release_sync_request("web", {}, None)
|
|
1152
|
+
assert release_request == {
|
|
1153
|
+
"artifact_types": {"static-site"},
|
|
1154
|
+
"artifact_names": {"web"},
|
|
1155
|
+
}
|
|
1156
|
+
static_files = runner.sync_release_artifacts(**release_request)
|
|
327
1157
|
|
|
328
1158
|
assert static_files == {"web": release_dir / "static/web.tar.gz"}
|
|
329
1159
|
assert static_files["web"].read_bytes() == web_bytes
|
|
@@ -624,6 +1454,61 @@ def run_push_database_schema(
|
|
|
624
1454
|
return db.events
|
|
625
1455
|
|
|
626
1456
|
|
|
1457
|
+
def test_prepare_additive_schema_before_app_applies_data_before_app_retirement(
|
|
1458
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1459
|
+
) -> None:
|
|
1460
|
+
runner = load_runner()
|
|
1461
|
+
commands: list[list[str]] = []
|
|
1462
|
+
pushed_vars: list[dict] = []
|
|
1463
|
+
|
|
1464
|
+
def fake_run(command, **_kwargs):
|
|
1465
|
+
commands.append(command)
|
|
1466
|
+
return SimpleNamespace(returncode=0)
|
|
1467
|
+
|
|
1468
|
+
monkeypatch.setattr(runner, "TF", tmp_path)
|
|
1469
|
+
monkeypatch.setattr(runner, "TERRAFORM_EVIDENCE", {})
|
|
1470
|
+
monkeypatch.setattr(runner.subprocess, "run", fake_run)
|
|
1471
|
+
monkeypatch.setattr(runner, "output", lambda *_args, **_kwargs: "{}")
|
|
1472
|
+
monkeypatch.setattr(
|
|
1473
|
+
runner,
|
|
1474
|
+
"push_database_schema",
|
|
1475
|
+
lambda _outputs_path, vars_json: pushed_vars.append(vars_json),
|
|
1476
|
+
)
|
|
1477
|
+
|
|
1478
|
+
result = runner.prepare_additive_schema_before_app(
|
|
1479
|
+
{"action": "deploy"},
|
|
1480
|
+
{
|
|
1481
|
+
"stage": "prod",
|
|
1482
|
+
"auth_retirement_phase": "retired",
|
|
1483
|
+
"finalize_auth_retirement": True,
|
|
1484
|
+
},
|
|
1485
|
+
)
|
|
1486
|
+
|
|
1487
|
+
assert result.returncode == 0
|
|
1488
|
+
assert commands == [
|
|
1489
|
+
[
|
|
1490
|
+
"terraform",
|
|
1491
|
+
"plan",
|
|
1492
|
+
"-target=module.thinkwork.module.database",
|
|
1493
|
+
"-out=tfplan-data",
|
|
1494
|
+
"-no-color",
|
|
1495
|
+
],
|
|
1496
|
+
["terraform", "apply", "-auto-approve", "-no-color", "tfplan-data"],
|
|
1497
|
+
]
|
|
1498
|
+
assert pushed_vars == [
|
|
1499
|
+
{
|
|
1500
|
+
"stage": "prod",
|
|
1501
|
+
"auth_retirement_phase": "retired",
|
|
1502
|
+
"finalize_auth_retirement": False,
|
|
1503
|
+
}
|
|
1504
|
+
]
|
|
1505
|
+
assert runner.TERRAFORM_EVIDENCE["schemaOrdering"] == {
|
|
1506
|
+
"status": "succeeded",
|
|
1507
|
+
"target": "module.thinkwork.module.database",
|
|
1508
|
+
"phase": "foundation-data-additive-before-app",
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
|
|
627
1512
|
def test_push_database_schema_checks_out_release_selected_module_source(
|
|
628
1513
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
629
1514
|
) -> None:
|
|
@@ -710,6 +1595,47 @@ def test_push_database_schema_applies_only_unrecorded_migrations(
|
|
|
710
1595
|
)
|
|
711
1596
|
|
|
712
1597
|
|
|
1598
|
+
@pytest.mark.parametrize(
|
|
1599
|
+
("phase", "finalize", "expected"),
|
|
1600
|
+
[
|
|
1601
|
+
("coexistence", False, []),
|
|
1602
|
+
("coexistence", True, []),
|
|
1603
|
+
("cutover", False, []),
|
|
1604
|
+
("cutover", True, []),
|
|
1605
|
+
("retired", False, []),
|
|
1606
|
+
("retired", True, ["0263_drop_workos_auth_runtime.sql"]),
|
|
1607
|
+
],
|
|
1608
|
+
)
|
|
1609
|
+
def test_push_database_schema_requires_retired_phase_and_explicit_finalization(
|
|
1610
|
+
tmp_path: Path,
|
|
1611
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1612
|
+
phase: str,
|
|
1613
|
+
finalize: bool,
|
|
1614
|
+
expected: list[str],
|
|
1615
|
+
) -> None:
|
|
1616
|
+
runner = load_runner()
|
|
1617
|
+
monkeypatch.setattr(runner, "POST_SEED_MIGRATIONS", [])
|
|
1618
|
+
db = FakeLedgerDb(tenants_exist=True, ledger=set())
|
|
1619
|
+
run_push_database_schema(
|
|
1620
|
+
runner,
|
|
1621
|
+
tmp_path,
|
|
1622
|
+
monkeypatch,
|
|
1623
|
+
db,
|
|
1624
|
+
["0263_drop_workos_auth_runtime.sql"],
|
|
1625
|
+
migration_bodies={
|
|
1626
|
+
"0263_drop_workos_auth_runtime.sql": ("-- deployment-phase: auth-retired\nSELECT 1;\n")
|
|
1627
|
+
},
|
|
1628
|
+
vars_json={
|
|
1629
|
+
"stage": "tei-e2e",
|
|
1630
|
+
"auth_retirement_phase": phase,
|
|
1631
|
+
"finalize_auth_retirement": finalize,
|
|
1632
|
+
},
|
|
1633
|
+
)
|
|
1634
|
+
|
|
1635
|
+
assert db.applied_files == expected
|
|
1636
|
+
assert ("0263_drop_workos_auth_runtime.sql" in db.ledger) is bool(expected)
|
|
1637
|
+
|
|
1638
|
+
|
|
713
1639
|
def test_push_database_schema_transition_backfills_ledger_via_markers(
|
|
714
1640
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
715
1641
|
) -> None:
|
|
@@ -747,6 +1673,40 @@ def test_push_database_schema_transition_backfills_ledger_via_markers(
|
|
|
747
1673
|
assert db.ledger["0001_ancient.sql"] == "transition-assumed"
|
|
748
1674
|
|
|
749
1675
|
|
|
1676
|
+
def test_push_database_schema_transition_applies_post_cutoff_migrations(
|
|
1677
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1678
|
+
) -> None:
|
|
1679
|
+
runner = load_runner()
|
|
1680
|
+
db = FakeLedgerDb(
|
|
1681
|
+
tenants_exist=True,
|
|
1682
|
+
ledger=None,
|
|
1683
|
+
present_objects={"public.existing_native_auth_table"},
|
|
1684
|
+
)
|
|
1685
|
+
run_push_database_schema(
|
|
1686
|
+
runner,
|
|
1687
|
+
tmp_path,
|
|
1688
|
+
monkeypatch,
|
|
1689
|
+
db,
|
|
1690
|
+
[
|
|
1691
|
+
"0155_tenant_model_catalog.sql",
|
|
1692
|
+
"0261_native_auth_control_plane.sql",
|
|
1693
|
+
"0262_auth_subscription_tickets.sql",
|
|
1694
|
+
],
|
|
1695
|
+
migration_bodies={
|
|
1696
|
+
"0261_native_auth_control_plane.sql": (
|
|
1697
|
+
"-- creates: public.missing_native_auth_table\nSELECT 1;\n"
|
|
1698
|
+
),
|
|
1699
|
+
"0262_auth_subscription_tickets.sql": (
|
|
1700
|
+
"-- creates: public.existing_native_auth_table\nSELECT 1;\n"
|
|
1701
|
+
),
|
|
1702
|
+
},
|
|
1703
|
+
)
|
|
1704
|
+
|
|
1705
|
+
assert "0261_native_auth_control_plane.sql" in db.applied_files
|
|
1706
|
+
assert db.ledger["0261_native_auth_control_plane.sql"] == "runner"
|
|
1707
|
+
assert db.ledger["0262_auth_subscription_tickets.sql"] == "transition-verified"
|
|
1708
|
+
|
|
1709
|
+
|
|
750
1710
|
def test_push_database_schema_greenfield_records_full_ledger(
|
|
751
1711
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
752
1712
|
) -> None:
|
|
@@ -1028,6 +1988,13 @@ def test_write_runner_files_persists_selected_release_to_controller_module(
|
|
|
1028
1988
|
assert "deployment_release_manifest_signature_url" in main_tf
|
|
1029
1989
|
assert "deployment_release_manifest_trusted_keys_json" in main_tf
|
|
1030
1990
|
assert "deployment_terraform_module_source" in main_tf
|
|
1991
|
+
assert 'required_version = ">= 1.8.5"' in main_tf
|
|
1992
|
+
assert (
|
|
1993
|
+
"from = "
|
|
1994
|
+
"module.thinkwork.module.agentcore_proof_identity."
|
|
1995
|
+
"terraform_data.twenty_identity_lifecycle"
|
|
1996
|
+
) in main_tf
|
|
1997
|
+
assert "destroy = false" in main_tf
|
|
1031
1998
|
|
|
1032
1999
|
|
|
1033
2000
|
def _cognito_email_runner_env(runner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
@@ -1057,6 +2024,7 @@ def _cognito_email_runner_env(runner, tmp_path: Path, monkeypatch: pytest.Monkey
|
|
|
1057
2024
|
monkeypatch.setenv("THINKWORK_TERRAFORM_LOCK_TABLE", "thinkwork-locks")
|
|
1058
2025
|
monkeypatch.setenv("THINKWORK_RELEASE_ARTIFACT_BUCKET", "thinkwork-artifacts")
|
|
1059
2026
|
monkeypatch.setenv("THINKWORK_RELEASE_VERSION", "v0.1.0-canary.150")
|
|
2027
|
+
monkeypatch.setenv("AUTH_MIGRATION_RECOVERY_DEADLINE", "2099-01-01T00:00:00Z")
|
|
1060
2028
|
monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_URL", file_url(manifest_path))
|
|
1061
2029
|
monkeypatch.setenv("THINKWORK_RELEASE_MANIFEST_SHA256", manifest_sha)
|
|
1062
2030
|
return tf_dir
|
|
@@ -1114,6 +2082,44 @@ def test_write_runner_files_threads_cognito_email_vars_from_payload(
|
|
|
1114
2082
|
assert f"= var.{name}" in main_tf
|
|
1115
2083
|
|
|
1116
2084
|
|
|
2085
|
+
def test_write_runner_files_threads_agentcore_harness_config_from_preserved_payload(
|
|
2086
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
2087
|
+
) -> None:
|
|
2088
|
+
runner = load_runner()
|
|
2089
|
+
tf_dir = _cognito_email_runner_env(runner, tmp_path, monkeypatch)
|
|
2090
|
+
|
|
2091
|
+
vars_json = runner.write_runner_files(
|
|
2092
|
+
{
|
|
2093
|
+
"stage": "tei-e2e",
|
|
2094
|
+
"awsRegion": "us-east-1",
|
|
2095
|
+
"awsAccountId": "637423202447",
|
|
2096
|
+
"dbPassword": "db-secret",
|
|
2097
|
+
"apiAuthSecret": "api-secret",
|
|
2098
|
+
"preservedConfig": {
|
|
2099
|
+
"enableAgentCoreHarness": True,
|
|
2100
|
+
"agentCoreHarnessTenantSlug": "tei",
|
|
2101
|
+
"agentCoreHarnessOwnerAllowlist": "owner-a,owner-b",
|
|
2102
|
+
},
|
|
2103
|
+
},
|
|
2104
|
+
{},
|
|
2105
|
+
)
|
|
2106
|
+
|
|
2107
|
+
assert vars_json["enable_agentcore_multiplayer_proof"] is True
|
|
2108
|
+
assert vars_json["agentcore_multiplayer_proof_tenant_slug"] == "tei"
|
|
2109
|
+
assert vars_json["agentcore_multiplayer_proof_owner_allowlist"] == "owner-a,owner-b"
|
|
2110
|
+
|
|
2111
|
+
tfvars = json.loads((tf_dir / "terraform.auto.tfvars.json").read_text(encoding="utf-8"))
|
|
2112
|
+
assert tfvars["enable_agentcore_multiplayer_proof"] is True
|
|
2113
|
+
main_tf = (tf_dir / "main.tf").read_text(encoding="utf-8")
|
|
2114
|
+
for name in (
|
|
2115
|
+
"enable_agentcore_multiplayer_proof",
|
|
2116
|
+
"agentcore_multiplayer_proof_tenant_slug",
|
|
2117
|
+
"agentcore_multiplayer_proof_owner_allowlist",
|
|
2118
|
+
):
|
|
2119
|
+
assert f'variable "{name}"' in main_tf
|
|
2120
|
+
assert f"= var.{name}" in main_tf
|
|
2121
|
+
|
|
2122
|
+
|
|
1117
2123
|
def test_write_runner_files_repins_stale_thinkwork_git_module_source_to_release(
|
|
1118
2124
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1119
2125
|
) -> None:
|
|
@@ -1163,9 +2169,17 @@ def test_write_runner_files_cognito_email_vars_prefer_runner_secrets_and_default
|
|
|
1163
2169
|
"apiAuthSecret": "api-secret",
|
|
1164
2170
|
"cognitoEmailSourceArn": "arn:aws:ses:us-east-1:637423202447:identity/payload.example.com",
|
|
1165
2171
|
},
|
|
1166
|
-
{
|
|
2172
|
+
{
|
|
2173
|
+
"cognitoEmailSourceArn": secret_arn,
|
|
2174
|
+
"authRetirementPhase": "cutover",
|
|
2175
|
+
},
|
|
1167
2176
|
)
|
|
1168
2177
|
assert vars_json["cognito_email_source_arn"] == secret_arn
|
|
2178
|
+
assert vars_json["auth_retirement_phase"] == "cutover"
|
|
2179
|
+
assert vars_json["finalize_auth_retirement"] is False
|
|
2180
|
+
assert "auth_retirement_phase = var.auth_retirement_phase" in (runner.TF / "main.tf").read_text(
|
|
2181
|
+
encoding="utf-8"
|
|
2182
|
+
)
|
|
1169
2183
|
|
|
1170
2184
|
vars_json_default = runner.write_runner_files(
|
|
1171
2185
|
{
|
|
@@ -1182,45 +2196,86 @@ def test_write_runner_files_cognito_email_vars_prefer_runner_secrets_and_default
|
|
|
1182
2196
|
assert vars_json_default["cognito_reply_to_email_address"] == ""
|
|
1183
2197
|
assert vars_json_default["app_domain"] == ""
|
|
1184
2198
|
assert vars_json_default["app_certificate_arn"] == ""
|
|
2199
|
+
assert vars_json_default["auth_retirement_phase"] == "retired"
|
|
2200
|
+
assert vars_json_default["finalize_auth_retirement"] is False
|
|
2201
|
+
|
|
2202
|
+
vars_json_retired = runner.write_runner_files(
|
|
2203
|
+
{
|
|
2204
|
+
"stage": "tei-e2e",
|
|
2205
|
+
"awsRegion": "us-east-1",
|
|
2206
|
+
"awsAccountId": "637423202447",
|
|
2207
|
+
"dbPassword": "db-secret",
|
|
2208
|
+
"apiAuthSecret": "api-secret",
|
|
2209
|
+
"authRetirementPhase": "retired",
|
|
2210
|
+
"finalizeAuthRetirement": True,
|
|
2211
|
+
},
|
|
2212
|
+
{},
|
|
2213
|
+
)
|
|
2214
|
+
assert vars_json_retired["auth_retirement_phase"] == "retired"
|
|
2215
|
+
assert vars_json_retired["finalize_auth_retirement"] is True
|
|
1185
2216
|
|
|
1186
2217
|
|
|
1187
|
-
def
|
|
2218
|
+
def test_write_runner_files_threads_additional_admin_callback_urls(
|
|
1188
2219
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1189
2220
|
) -> None:
|
|
1190
|
-
"""analyst_lambda_vpc_egress needs all three wiring points (vars_json,
|
|
1191
|
-
root variable, module argument) and reads runner-secrets first — the
|
|
1192
|
-
durable per-environment home for the flag — with a payload fallback.
|
|
1193
|
-
Secrets Manager JSON strings ("true") must land as real booleans."""
|
|
1194
2221
|
runner = load_runner()
|
|
1195
|
-
|
|
1196
|
-
base_payload = {
|
|
1197
|
-
"stage": "tei-e2e",
|
|
1198
|
-
"awsRegion": "us-east-1",
|
|
1199
|
-
"awsAccountId": "637423202447",
|
|
1200
|
-
"dbPassword": "db-secret",
|
|
1201
|
-
"apiAuthSecret": "api-secret",
|
|
1202
|
-
}
|
|
2222
|
+
_cognito_email_runner_env(runner, tmp_path, monkeypatch)
|
|
1203
2223
|
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
2224
|
+
# runner-secrets carries the customer's hosted console origins; a
|
|
2225
|
+
# comma-separated string and a JSON-array string must both parse, and the
|
|
2226
|
+
# module wiring must be present so the URLs actually reach the web clients.
|
|
2227
|
+
vars_json = runner.write_runner_files(
|
|
2228
|
+
{
|
|
2229
|
+
"stage": "mcpherson",
|
|
2230
|
+
"awsRegion": "us-east-1",
|
|
2231
|
+
"awsAccountId": "024350822488",
|
|
2232
|
+
"dbPassword": "db-secret",
|
|
2233
|
+
"apiAuthSecret": "api-secret",
|
|
2234
|
+
},
|
|
2235
|
+
{
|
|
2236
|
+
"adminCallbackUrls": (
|
|
2237
|
+
"https://console.example.net, https://console.example.net/auth/callback"
|
|
2238
|
+
),
|
|
2239
|
+
"adminLogoutUrls": '["https://console.example.net"]',
|
|
2240
|
+
},
|
|
2241
|
+
)
|
|
2242
|
+
assert vars_json["additional_admin_callback_urls"] == [
|
|
2243
|
+
"https://console.example.net",
|
|
2244
|
+
"https://console.example.net/auth/callback",
|
|
2245
|
+
]
|
|
2246
|
+
assert vars_json["additional_admin_logout_urls"] == ["https://console.example.net"]
|
|
2247
|
+
main_tf = (runner.TF / "main.tf").read_text(encoding="utf-8")
|
|
2248
|
+
assert "additional_admin_callback_urls = var.additional_admin_callback_urls" in main_tf
|
|
2249
|
+
assert "additional_admin_logout_urls = var.additional_admin_logout_urls" in main_tf
|
|
1213
2250
|
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
2251
|
+
# Default: no runner-secrets keys -> empty lists (no behavior change).
|
|
2252
|
+
vars_json_default = runner.write_runner_files(
|
|
2253
|
+
{
|
|
2254
|
+
"stage": "mcpherson",
|
|
2255
|
+
"awsRegion": "us-east-1",
|
|
2256
|
+
"awsAccountId": "024350822488",
|
|
2257
|
+
"dbPassword": "db-secret",
|
|
2258
|
+
"apiAuthSecret": "api-secret",
|
|
2259
|
+
},
|
|
2260
|
+
{},
|
|
2261
|
+
)
|
|
2262
|
+
assert vars_json_default["additional_admin_callback_urls"] == []
|
|
2263
|
+
assert vars_json_default["additional_admin_logout_urls"] == []
|
|
1217
2264
|
|
|
1218
2265
|
|
|
1219
|
-
def
|
|
2266
|
+
def test_write_runner_files_defaults_existing_preauth_stack_to_coexistence(
|
|
1220
2267
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1221
2268
|
) -> None:
|
|
1222
2269
|
runner = load_runner()
|
|
1223
|
-
|
|
2270
|
+
_cognito_email_runner_env(runner, tmp_path, monkeypatch)
|
|
2271
|
+
monkeypatch.setattr(
|
|
2272
|
+
runner,
|
|
2273
|
+
"current_terraform_state",
|
|
2274
|
+
lambda _stage: {
|
|
2275
|
+
"outputs": {"user_pool_id": {"value": "us-east-1_Existing"}},
|
|
2276
|
+
"resources": [{"type": "aws_cognito_user_pool", "name": "main"}],
|
|
2277
|
+
},
|
|
2278
|
+
)
|
|
1224
2279
|
|
|
1225
2280
|
vars_json = runner.write_runner_files(
|
|
1226
2281
|
{
|
|
@@ -1229,8 +2284,91 @@ def test_write_runner_files_threads_customer_domain_vars_from_payload(
|
|
|
1229
2284
|
"awsAccountId": "637423202447",
|
|
1230
2285
|
"dbPassword": "db-secret",
|
|
1231
2286
|
"apiAuthSecret": "api-secret",
|
|
1232
|
-
|
|
1233
|
-
|
|
2287
|
+
},
|
|
2288
|
+
{},
|
|
2289
|
+
)
|
|
2290
|
+
|
|
2291
|
+
assert vars_json["auth_retirement_phase"] == "coexistence"
|
|
2292
|
+
assert vars_json["auth_migration_recovery_deadline"] == "2099-01-01T00:00:00Z"
|
|
2293
|
+
|
|
2294
|
+
|
|
2295
|
+
def test_write_runner_files_requires_deadline_for_coexistence(
|
|
2296
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
2297
|
+
) -> None:
|
|
2298
|
+
runner = load_runner()
|
|
2299
|
+
_cognito_email_runner_env(runner, tmp_path, monkeypatch)
|
|
2300
|
+
monkeypatch.delenv("AUTH_MIGRATION_RECOVERY_DEADLINE")
|
|
2301
|
+
|
|
2302
|
+
with pytest.raises(RuntimeError, match="authMigrationRecoveryDeadline is required"):
|
|
2303
|
+
runner.write_runner_files(
|
|
2304
|
+
{
|
|
2305
|
+
"stage": "tei-e2e",
|
|
2306
|
+
"awsRegion": "us-east-1",
|
|
2307
|
+
"awsAccountId": "637423202447",
|
|
2308
|
+
"dbPassword": "db-secret",
|
|
2309
|
+
"apiAuthSecret": "api-secret",
|
|
2310
|
+
"authRetirementPhase": "coexistence",
|
|
2311
|
+
},
|
|
2312
|
+
{},
|
|
2313
|
+
)
|
|
2314
|
+
|
|
2315
|
+
|
|
2316
|
+
def test_write_runner_files_preserves_deployed_auth_retirement_phase(
|
|
2317
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
2318
|
+
) -> None:
|
|
2319
|
+
runner = load_runner()
|
|
2320
|
+
_cognito_email_runner_env(runner, tmp_path, monkeypatch)
|
|
2321
|
+
monkeypatch.setattr(
|
|
2322
|
+
runner,
|
|
2323
|
+
"current_terraform_state",
|
|
2324
|
+
lambda _stage: {
|
|
2325
|
+
"outputs": {
|
|
2326
|
+
"auth_retirement_phase": {"value": "cutover"},
|
|
2327
|
+
"user_pool_id": {"value": "us-east-1_Existing"},
|
|
2328
|
+
}
|
|
2329
|
+
},
|
|
2330
|
+
)
|
|
2331
|
+
|
|
2332
|
+
vars_json = runner.write_runner_files(
|
|
2333
|
+
{
|
|
2334
|
+
"stage": "tei-e2e",
|
|
2335
|
+
"awsRegion": "us-east-1",
|
|
2336
|
+
"awsAccountId": "637423202447",
|
|
2337
|
+
"dbPassword": "db-secret",
|
|
2338
|
+
"apiAuthSecret": "api-secret",
|
|
2339
|
+
},
|
|
2340
|
+
{},
|
|
2341
|
+
)
|
|
2342
|
+
|
|
2343
|
+
assert vars_json["auth_retirement_phase"] == "cutover"
|
|
2344
|
+
|
|
2345
|
+
|
|
2346
|
+
def test_main_reconciles_native_auth_before_destructive_schema() -> None:
|
|
2347
|
+
source = Path(__file__).with_name("runner.py").read_text(encoding="utf-8")
|
|
2348
|
+
post_apply = source[source.index("def main():") :]
|
|
2349
|
+
reconciliation = post_apply.index(
|
|
2350
|
+
'CONTROLLER_EVIDENCE["authReconciliation"] = reconcile_native_auth_metadata'
|
|
2351
|
+
)
|
|
2352
|
+
schema = post_apply.index("push_database_schema(outputs_path, vars_json)", reconciliation)
|
|
2353
|
+
|
|
2354
|
+
assert reconciliation < schema
|
|
2355
|
+
|
|
2356
|
+
|
|
2357
|
+
def test_write_runner_files_threads_customer_domain_vars_from_payload(
|
|
2358
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
2359
|
+
) -> None:
|
|
2360
|
+
runner = load_runner()
|
|
2361
|
+
tf_dir = _cognito_email_runner_env(runner, tmp_path, monkeypatch)
|
|
2362
|
+
|
|
2363
|
+
vars_json = runner.write_runner_files(
|
|
2364
|
+
{
|
|
2365
|
+
"stage": "tei-e2e",
|
|
2366
|
+
"awsRegion": "us-east-1",
|
|
2367
|
+
"awsAccountId": "637423202447",
|
|
2368
|
+
"dbPassword": "db-secret",
|
|
2369
|
+
"apiAuthSecret": "api-secret",
|
|
2370
|
+
"customerDomain": "tei.thinkwork.ai",
|
|
2371
|
+
"customerDomainDelegated": True,
|
|
1234
2372
|
"customerDomainLegacyRetired": False,
|
|
1235
2373
|
},
|
|
1236
2374
|
{},
|
|
@@ -2814,7 +3952,6 @@ def test_runtime_profile_contains_customer_authority_metadata(
|
|
|
2814
3952
|
"app_url": {"value": "https://app.example.com"},
|
|
2815
3953
|
"appsync_api_url": {"value": "https://appsync.example.com/graphql"},
|
|
2816
3954
|
"appsync_realtime_url": {"value": "wss://appsync.example.com/graphql"},
|
|
2817
|
-
"appsync_api_key": {"value": "api-key"},
|
|
2818
3955
|
"auth_domain": {"value": "thinkwork-tei-e2e"},
|
|
2819
3956
|
"user_pool_id": {"value": "us-east-1_abc"},
|
|
2820
3957
|
"admin_client_id": {"value": "client-id"},
|
|
@@ -2858,79 +3995,816 @@ def test_redacted_tfvars_removes_secret_values() -> None:
|
|
|
2858
3995
|
"google_oauth_client_secret": "secret-google",
|
|
2859
3996
|
}
|
|
2860
3997
|
)
|
|
2861
|
-
|
|
2862
|
-
assert redacted == {
|
|
2863
|
-
"stage": "tei-e2e",
|
|
2864
|
-
"db_password": "[redacted]",
|
|
2865
|
-
"api_auth_secret": "[redacted]",
|
|
2866
|
-
"google_oauth_client_secret": "[redacted]",
|
|
2867
|
-
}
|
|
3998
|
+
|
|
3999
|
+
assert redacted == {
|
|
4000
|
+
"stage": "tei-e2e",
|
|
4001
|
+
"db_password": "[redacted]",
|
|
4002
|
+
"api_auth_secret": "[redacted]",
|
|
4003
|
+
"google_oauth_client_secret": "[redacted]",
|
|
4004
|
+
}
|
|
4005
|
+
|
|
4006
|
+
|
|
4007
|
+
def test_write_controller_release_selection_to_ssm_persists_selected_release(
|
|
4008
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
4009
|
+
) -> None:
|
|
4010
|
+
runner = load_runner()
|
|
4011
|
+
calls: list[list[str]] = []
|
|
4012
|
+
|
|
4013
|
+
monkeypatch.setenv("THINKWORK_SSM_PREFIX", "/thinkwork/tei-e2e/deployment")
|
|
4014
|
+
monkeypatch.setattr(runner, "run", lambda args, **_kwargs: calls.append(args))
|
|
4015
|
+
|
|
4016
|
+
selected = runner.write_controller_release_selection_to_ssm(
|
|
4017
|
+
{
|
|
4018
|
+
"deployment_release_version": "v0.1.0-canary.147",
|
|
4019
|
+
"deployment_release_manifest_url": "https://example.test/thinkwork-release.json",
|
|
4020
|
+
"deployment_release_manifest_sha256": "f" * 64,
|
|
4021
|
+
"deployment_release_manifest_signature_url": "",
|
|
4022
|
+
"deployment_release_manifest_trust_policy": "allow_unsigned_canary",
|
|
4023
|
+
"deployment_release_manifest_trusted_keys_json": "[]",
|
|
4024
|
+
"deployment_terraform_module_source": "thinkwork-ai/thinkwork/aws",
|
|
4025
|
+
"deployment_terraform_module_version": "",
|
|
4026
|
+
}
|
|
4027
|
+
)
|
|
4028
|
+
|
|
4029
|
+
written = {call[7]: call[9] for call in calls}
|
|
4030
|
+
assert selected["terraform-module-version"] == "0.1.0-canary.147"
|
|
4031
|
+
assert written == {
|
|
4032
|
+
"/thinkwork/tei-e2e/deployment/selected-release-version": "v0.1.0-canary.147",
|
|
4033
|
+
"/thinkwork/tei-e2e/deployment/selected-release-manifest-url": (
|
|
4034
|
+
"https://example.test/thinkwork-release.json"
|
|
4035
|
+
),
|
|
4036
|
+
"/thinkwork/tei-e2e/deployment/selected-release-manifest-sha256": "f" * 64,
|
|
4037
|
+
"/thinkwork/tei-e2e/deployment/selected-release-trust-policy": "allow_unsigned_canary",
|
|
4038
|
+
"/thinkwork/tei-e2e/deployment/selected-release-trusted-keys-json": "[]",
|
|
4039
|
+
"/thinkwork/tei-e2e/deployment/terraform-module-source": "thinkwork-ai/thinkwork/aws",
|
|
4040
|
+
"/thinkwork/tei-e2e/deployment/terraform-module-version": "0.1.0-canary.147",
|
|
4041
|
+
}
|
|
4042
|
+
|
|
4043
|
+
|
|
4044
|
+
def test_write_controller_release_selection_to_ssm_skips_empty_git_module_version(
|
|
4045
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
4046
|
+
) -> None:
|
|
4047
|
+
runner = load_runner()
|
|
4048
|
+
calls: list[list[str]] = []
|
|
4049
|
+
|
|
4050
|
+
monkeypatch.setenv("THINKWORK_SSM_PREFIX", "/thinkwork/dev/deployment")
|
|
4051
|
+
monkeypatch.setattr(runner, "run", lambda args, **_kwargs: calls.append(args))
|
|
4052
|
+
|
|
4053
|
+
selected = runner.write_controller_release_selection_to_ssm(
|
|
4054
|
+
{
|
|
4055
|
+
"deployment_release_version": "v0.1.0-canary.147",
|
|
4056
|
+
"deployment_release_manifest_url": "https://example.test/thinkwork-release.json",
|
|
4057
|
+
"deployment_release_manifest_sha256": "f" * 64,
|
|
4058
|
+
"deployment_release_manifest_trust_policy": "allow_unsigned_canary",
|
|
4059
|
+
"deployment_release_manifest_trusted_keys_json": "[]",
|
|
4060
|
+
"deployment_terraform_module_source": (
|
|
4061
|
+
"git::https://github.com/thinkwork-ai/thinkwork.git"
|
|
4062
|
+
"//terraform/modules/thinkwork?ref=37d7246"
|
|
4063
|
+
),
|
|
4064
|
+
"deployment_terraform_module_version": "",
|
|
4065
|
+
}
|
|
4066
|
+
)
|
|
4067
|
+
|
|
4068
|
+
written_names = [call[7] for call in calls]
|
|
4069
|
+
assert "terraform-module-version" not in selected
|
|
4070
|
+
assert "/thinkwork/dev/deployment/terraform-module-version" not in written_names
|
|
4071
|
+
|
|
4072
|
+
|
|
4073
|
+
def _approval_payload(action: str, *, plan: bool, apply: bool) -> dict:
|
|
4074
|
+
return {
|
|
4075
|
+
"contract": "thinkwork.deployment.controller.v1",
|
|
4076
|
+
"action": action,
|
|
4077
|
+
"phase": action,
|
|
4078
|
+
"sessionId": f"session-{action}",
|
|
4079
|
+
"environmentName": "tei-e2e",
|
|
4080
|
+
"awsAccountId": "637423202447",
|
|
4081
|
+
"awsRegion": "us-east-1",
|
|
4082
|
+
"release": {
|
|
4083
|
+
"version": "v0.1.0-canary.381",
|
|
4084
|
+
"manifestUrl": "https://example.test/thinkwork-release.json",
|
|
4085
|
+
"manifestSha256": "a" * 64,
|
|
4086
|
+
},
|
|
4087
|
+
"agentcorePiSourceImageUri": (
|
|
4088
|
+
"637423202447.dkr.ecr.us-east-1.amazonaws.com/"
|
|
4089
|
+
"thinkwork-tei-e2e-agentcore:pinned@sha256:abc"
|
|
4090
|
+
),
|
|
4091
|
+
"preservedConfig": {
|
|
4092
|
+
"enableAgentCoreHarness": True,
|
|
4093
|
+
"agentCoreHarnessTenantSlug": "tei",
|
|
4094
|
+
},
|
|
4095
|
+
"operation": {
|
|
4096
|
+
"kind": "foundation",
|
|
4097
|
+
"action": action,
|
|
4098
|
+
"targetAction": "update",
|
|
4099
|
+
"plan": plan,
|
|
4100
|
+
"apply": apply,
|
|
4101
|
+
"destroy": False,
|
|
4102
|
+
},
|
|
4103
|
+
}
|
|
4104
|
+
|
|
4105
|
+
|
|
4106
|
+
def test_plan_phase_never_implies_apply() -> None:
|
|
4107
|
+
runner = load_runner()
|
|
4108
|
+
payload = _approval_payload("plan", plan=True, apply=False)
|
|
4109
|
+
|
|
4110
|
+
assert runner.validate_terraform_execution_phase(payload, "plan") == "plan"
|
|
4111
|
+
|
|
4112
|
+
|
|
4113
|
+
def test_existing_managed_application_plan_remains_plan_only() -> None:
|
|
4114
|
+
runner = load_runner()
|
|
4115
|
+
payload = {
|
|
4116
|
+
"action": "plan",
|
|
4117
|
+
"phase": "plan",
|
|
4118
|
+
"appKey": "twenty",
|
|
4119
|
+
"operation": "ENABLE",
|
|
4120
|
+
}
|
|
4121
|
+
|
|
4122
|
+
assert runner.validate_terraform_execution_phase(payload, "plan") == "plan"
|
|
4123
|
+
|
|
4124
|
+
|
|
4125
|
+
def test_explicit_plan_execution_never_invokes_terraform_apply(
|
|
4126
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
4127
|
+
) -> None:
|
|
4128
|
+
runner = load_runner()
|
|
4129
|
+
calls: list[list[str]] = []
|
|
4130
|
+
monkeypatch.setattr(
|
|
4131
|
+
runner.subprocess,
|
|
4132
|
+
"run",
|
|
4133
|
+
lambda args, **_kwargs: calls.append(args) or SimpleNamespace(returncode=0),
|
|
4134
|
+
)
|
|
4135
|
+
monkeypatch.setattr(
|
|
4136
|
+
runner,
|
|
4137
|
+
"write_terraform_plan_evidence",
|
|
4138
|
+
lambda *_args, **_kwargs: {"status": "awaiting-approval"},
|
|
4139
|
+
)
|
|
4140
|
+
|
|
4141
|
+
result = runner.execute_terraform_plan_phase(
|
|
4142
|
+
_approval_payload("plan", plan=True, apply=False),
|
|
4143
|
+
{"lineage": "lineage", "serial": 1},
|
|
4144
|
+
"update",
|
|
4145
|
+
"plan",
|
|
4146
|
+
[],
|
|
4147
|
+
)
|
|
4148
|
+
|
|
4149
|
+
assert result.returncode == 0
|
|
4150
|
+
assert calls == [["terraform", "plan", "-out=tfplan", "-no-color"]]
|
|
4151
|
+
assert runner.TERRAFORM_EVIDENCE["plan"]["status"] == "awaiting-approval"
|
|
4152
|
+
|
|
4153
|
+
|
|
4154
|
+
def test_legacy_execution_still_applies_its_successful_plan(
|
|
4155
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
4156
|
+
) -> None:
|
|
4157
|
+
runner = load_runner()
|
|
4158
|
+
calls: list[list[str]] = []
|
|
4159
|
+
monkeypatch.setattr(
|
|
4160
|
+
runner.subprocess,
|
|
4161
|
+
"run",
|
|
4162
|
+
lambda args, **_kwargs: calls.append(args) or SimpleNamespace(returncode=0),
|
|
4163
|
+
)
|
|
4164
|
+
monkeypatch.setattr(
|
|
4165
|
+
runner,
|
|
4166
|
+
"write_terraform_plan_evidence",
|
|
4167
|
+
lambda *_args, **_kwargs: {"status": "awaiting-approval"},
|
|
4168
|
+
)
|
|
4169
|
+
|
|
4170
|
+
runner.execute_terraform_plan_phase(
|
|
4171
|
+
_approval_payload("update", plan=True, apply=True),
|
|
4172
|
+
{"lineage": "lineage", "serial": 1},
|
|
4173
|
+
"update",
|
|
4174
|
+
"legacy",
|
|
4175
|
+
[],
|
|
4176
|
+
)
|
|
4177
|
+
|
|
4178
|
+
assert calls == [
|
|
4179
|
+
["terraform", "plan", "-out=tfplan", "-no-color"],
|
|
4180
|
+
["terraform", "apply", "-auto-approve", "-no-color", "tfplan"],
|
|
4181
|
+
]
|
|
4182
|
+
|
|
4183
|
+
|
|
4184
|
+
@pytest.mark.parametrize("action", ["deploy", "update", "destroy"])
|
|
4185
|
+
def test_legacy_combined_terraform_actions_remain_compatible(action: str) -> None:
|
|
4186
|
+
runner = load_runner()
|
|
4187
|
+
payload = _approval_payload(action, plan=True, apply=True)
|
|
4188
|
+
|
|
4189
|
+
assert runner.validate_terraform_execution_phase(payload, action) == "legacy"
|
|
4190
|
+
|
|
4191
|
+
|
|
4192
|
+
def test_legacy_and_approved_apply_reconcile_native_auth_schema() -> None:
|
|
4193
|
+
runner = load_runner()
|
|
4194
|
+
|
|
4195
|
+
assert runner.should_reconcile_native_auth_schema("legacy", "deploy", {}) is True
|
|
4196
|
+
assert runner.should_reconcile_native_auth_schema("legacy", "update", {}) is True
|
|
4197
|
+
assert runner.should_reconcile_native_auth_schema("apply", "update", {}) is True
|
|
4198
|
+
assert runner.should_reconcile_native_auth_schema("plan", "update", {}) is False
|
|
4199
|
+
assert runner.should_reconcile_native_auth_schema("legacy", "destroy", {}) is False
|
|
4200
|
+
assert (
|
|
4201
|
+
runner.should_reconcile_native_auth_schema(
|
|
4202
|
+
"legacy",
|
|
4203
|
+
"update",
|
|
4204
|
+
{"appKey": "twenty"},
|
|
4205
|
+
)
|
|
4206
|
+
is False
|
|
4207
|
+
)
|
|
4208
|
+
|
|
4209
|
+
|
|
4210
|
+
def test_apply_phase_requires_separate_approved_plan() -> None:
|
|
4211
|
+
runner = load_runner()
|
|
4212
|
+
payload = _approval_payload("apply", plan=False, apply=True)
|
|
4213
|
+
|
|
4214
|
+
with pytest.raises(RuntimeError, match="requires approvedPlan"):
|
|
4215
|
+
runner.validate_terraform_execution_phase(payload, "apply")
|
|
4216
|
+
|
|
4217
|
+
payload["approvedPlan"] = {
|
|
4218
|
+
"descriptor": {"s3Uri": "s3://evidence/descriptor.json", "sha256": "a" * 64}
|
|
4219
|
+
}
|
|
4220
|
+
assert runner.validate_terraform_execution_phase(payload, "apply") == "apply"
|
|
4221
|
+
|
|
4222
|
+
|
|
4223
|
+
def test_plan_and_apply_share_only_immutable_deployment_config_digest() -> None:
|
|
4224
|
+
runner = load_runner()
|
|
4225
|
+
planned = _approval_payload("plan", plan=True, apply=False)
|
|
4226
|
+
applied = _approval_payload("apply", plan=False, apply=True)
|
|
4227
|
+
applied["approvedPlan"] = {
|
|
4228
|
+
"descriptor": {"s3Uri": "s3://evidence/descriptor.json", "sha256": "a" * 64}
|
|
4229
|
+
}
|
|
4230
|
+
|
|
4231
|
+
assert runner.controller_input_sha256(planned) != runner.controller_input_sha256(applied)
|
|
4232
|
+
assert runner.deployment_config_sha256(planned) == runner.deployment_config_sha256(applied)
|
|
4233
|
+
|
|
4234
|
+
applied["preservedConfig"]["agentCoreHarnessTenantSlug"] = "wrong"
|
|
4235
|
+
assert runner.deployment_config_sha256(planned) != runner.deployment_config_sha256(applied)
|
|
4236
|
+
|
|
4237
|
+
|
|
4238
|
+
def test_saved_plan_apply_revalidates_config_release_state_vars_and_rendered_plan(
|
|
4239
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
4240
|
+
) -> None:
|
|
4241
|
+
runner = load_runner()
|
|
4242
|
+
work = tmp_path / "work"
|
|
4243
|
+
tf = work / "terraform"
|
|
4244
|
+
tf.mkdir(parents=True)
|
|
4245
|
+
monkeypatch.setattr(runner, "WORK", work)
|
|
4246
|
+
monkeypatch.setattr(runner, "TF", tf)
|
|
4247
|
+
payload = _approval_payload("apply", plan=False, apply=True)
|
|
4248
|
+
payload["approvedPlan"] = {
|
|
4249
|
+
"descriptor": {"s3Uri": "s3://evidence/descriptor.json", "sha256": "d" * 64}
|
|
4250
|
+
}
|
|
4251
|
+
state = {
|
|
4252
|
+
"lineage": "lineage",
|
|
4253
|
+
"serial": 7,
|
|
4254
|
+
"sha256": "s" * 64,
|
|
4255
|
+
"terraformVersion": "1.8.5",
|
|
4256
|
+
}
|
|
4257
|
+
tfvars = b'{"stage":"tei-e2e"}\n'
|
|
4258
|
+
(tf / "terraform.auto.tfvars.json").write_bytes(tfvars)
|
|
4259
|
+
saved_plan_bytes = b"opaque-saved-plan"
|
|
4260
|
+
accepted_plan = json.dumps(
|
|
4261
|
+
{
|
|
4262
|
+
"resource_changes": [],
|
|
4263
|
+
"format_version": "1.2",
|
|
4264
|
+
"timestamp": "2026-07-20T18:40:21Z",
|
|
4265
|
+
"configuration": {
|
|
4266
|
+
"expression": {
|
|
4267
|
+
"references": [
|
|
4268
|
+
"aws_security_group.twenty.id",
|
|
4269
|
+
"aws_security_group.twenty",
|
|
4270
|
+
"var.cache_port",
|
|
4271
|
+
]
|
|
4272
|
+
}
|
|
4273
|
+
},
|
|
4274
|
+
}
|
|
4275
|
+
).encode()
|
|
4276
|
+
rendered_plan = json.dumps(
|
|
4277
|
+
{
|
|
4278
|
+
"format_version": "1.2",
|
|
4279
|
+
"resource_changes": [],
|
|
4280
|
+
"timestamp": "2026-07-20T18:49:12Z",
|
|
4281
|
+
"configuration": {
|
|
4282
|
+
"expression": {
|
|
4283
|
+
"references": [
|
|
4284
|
+
"var.cache_port",
|
|
4285
|
+
"aws_security_group.twenty.id",
|
|
4286
|
+
"aws_security_group.twenty",
|
|
4287
|
+
]
|
|
4288
|
+
}
|
|
4289
|
+
},
|
|
4290
|
+
},
|
|
4291
|
+
indent=2,
|
|
4292
|
+
).encode()
|
|
4293
|
+
descriptor = {
|
|
4294
|
+
"contract": runner.TERRAFORM_PLAN_APPROVAL_CONTRACT,
|
|
4295
|
+
"deploymentConfigSha256": runner.deployment_config_sha256(payload),
|
|
4296
|
+
"release": runner.release_selection(payload),
|
|
4297
|
+
"state": state,
|
|
4298
|
+
"terraformVariablesSha256": digest(tfvars),
|
|
4299
|
+
"savedPlan": {
|
|
4300
|
+
"s3Uri": "s3://evidence/plan.bin",
|
|
4301
|
+
"sha256": digest(saved_plan_bytes),
|
|
4302
|
+
},
|
|
4303
|
+
"jsonPlan": {
|
|
4304
|
+
"s3Uri": "s3://evidence/plan.json",
|
|
4305
|
+
"sha256": digest(accepted_plan),
|
|
4306
|
+
},
|
|
4307
|
+
"summary": {"resourceChangeCount": 0},
|
|
4308
|
+
"plannedAction": "update",
|
|
4309
|
+
"sessionId": "plan-session",
|
|
4310
|
+
}
|
|
4311
|
+
monkeypatch.setattr(
|
|
4312
|
+
runner,
|
|
4313
|
+
"load_approved_plan_descriptor",
|
|
4314
|
+
lambda _payload: (descriptor, payload["approvedPlan"]["descriptor"]),
|
|
4315
|
+
)
|
|
4316
|
+
|
|
4317
|
+
def fake_download(uri, destination, expected_sha, _label):
|
|
4318
|
+
if uri == "s3://evidence/plan.bin":
|
|
4319
|
+
content = saved_plan_bytes
|
|
4320
|
+
else:
|
|
4321
|
+
assert uri == "s3://evidence/plan.json"
|
|
4322
|
+
content = accepted_plan
|
|
4323
|
+
destination.write_bytes(content)
|
|
4324
|
+
assert digest(content) == expected_sha
|
|
4325
|
+
return expected_sha
|
|
4326
|
+
|
|
4327
|
+
def fake_run(args, **kwargs):
|
|
4328
|
+
assert args == ["terraform", "show", "-json", "tfplan"]
|
|
4329
|
+
kwargs["stdout"].write(rendered_plan.decode())
|
|
4330
|
+
|
|
4331
|
+
monkeypatch.setattr(runner, "_download_approved_artifact", fake_download)
|
|
4332
|
+
monkeypatch.setattr(runner, "run", fake_run)
|
|
4333
|
+
|
|
4334
|
+
approved = runner.validate_and_materialize_approved_plan(payload, state)
|
|
4335
|
+
assert approved["plannedAction"] == "update"
|
|
4336
|
+
assert approved["sourceSessionId"] == "plan-session"
|
|
4337
|
+
assert (tf / "tfplan").read_bytes() == saved_plan_bytes
|
|
4338
|
+
|
|
4339
|
+
rendered_plan = b'{"format_version":"1.2","resource_changes":[{"address":"changed"}]}\n'
|
|
4340
|
+
with pytest.raises(RuntimeError, match="no longer renders"):
|
|
4341
|
+
runner.validate_and_materialize_approved_plan(payload, state)
|
|
4342
|
+
|
|
4343
|
+
descriptor["state"] = {**state, "serial": 8}
|
|
4344
|
+
with pytest.raises(RuntimeError, match="state changed after plan approval"):
|
|
4345
|
+
runner.validate_and_materialize_approved_plan(payload, state)
|
|
4346
|
+
|
|
4347
|
+
|
|
4348
|
+
def test_saved_plan_artifacts_must_use_configured_evidence_bucket(
|
|
4349
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
4350
|
+
) -> None:
|
|
4351
|
+
runner = load_runner()
|
|
4352
|
+
monkeypatch.setenv("THINKWORK_EVIDENCE_BUCKET", "expected-evidence")
|
|
4353
|
+
|
|
4354
|
+
assert (
|
|
4355
|
+
runner._validated_evidence_s3_uri(
|
|
4356
|
+
"s3://expected-evidence/sessions/plan.json", "approved plan"
|
|
4357
|
+
)
|
|
4358
|
+
== "s3://expected-evidence/sessions/plan.json"
|
|
4359
|
+
)
|
|
4360
|
+
with pytest.raises(RuntimeError, match="configured deployment evidence bucket"):
|
|
4361
|
+
runner._validated_evidence_s3_uri("s3://attacker-controlled/plan.json", "approved plan")
|
|
4362
|
+
|
|
4363
|
+
|
|
4364
|
+
def test_tei_v380_saved_plan_apply_refuses_any_delete_action(
|
|
4365
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
4366
|
+
) -> None:
|
|
4367
|
+
runner = load_runner()
|
|
4368
|
+
work = tmp_path / "work"
|
|
4369
|
+
tf = work / "terraform"
|
|
4370
|
+
tf.mkdir(parents=True)
|
|
4371
|
+
monkeypatch.setattr(runner, "WORK", work)
|
|
4372
|
+
monkeypatch.setattr(runner, "TF", tf)
|
|
4373
|
+
payload = _approval_payload("apply", plan=False, apply=True)
|
|
4374
|
+
payload["incidentRecovery"] = {"contract": runner.TEI_V380_RECOVERY_CONTRACT}
|
|
4375
|
+
payload["approvedPlan"] = {
|
|
4376
|
+
"descriptor": {"s3Uri": "s3://evidence/descriptor.json", "sha256": "d" * 64}
|
|
4377
|
+
}
|
|
4378
|
+
state = {
|
|
4379
|
+
"lineage": "lineage",
|
|
4380
|
+
"serial": 7,
|
|
4381
|
+
"sha256": "e" * 64,
|
|
4382
|
+
"terraformVersion": "1.8.5",
|
|
4383
|
+
}
|
|
4384
|
+
tfvars = b'{"stage":"tei-e2e"}\n'
|
|
4385
|
+
(tf / "terraform.auto.tfvars.json").write_bytes(tfvars)
|
|
4386
|
+
saved_plan = b"opaque-saved-plan"
|
|
4387
|
+
rendered_plan = json.dumps(
|
|
4388
|
+
{
|
|
4389
|
+
"format_version": "1.2",
|
|
4390
|
+
"resource_changes": [
|
|
4391
|
+
{
|
|
4392
|
+
"address": "module.thinkwork.proof_resource",
|
|
4393
|
+
"change": {"actions": ["delete"]},
|
|
4394
|
+
}
|
|
4395
|
+
],
|
|
4396
|
+
}
|
|
4397
|
+
).encode()
|
|
4398
|
+
descriptor = {
|
|
4399
|
+
"contract": runner.TERRAFORM_PLAN_APPROVAL_CONTRACT,
|
|
4400
|
+
"deploymentConfigSha256": runner.deployment_config_sha256(payload),
|
|
4401
|
+
"release": runner.release_selection(payload),
|
|
4402
|
+
"state": state,
|
|
4403
|
+
"terraformVariablesSha256": digest(tfvars),
|
|
4404
|
+
"savedPlan": {
|
|
4405
|
+
"s3Uri": "s3://evidence/plan.bin",
|
|
4406
|
+
"sha256": digest(saved_plan),
|
|
4407
|
+
},
|
|
4408
|
+
"jsonPlan": {
|
|
4409
|
+
"s3Uri": "s3://evidence/plan.json",
|
|
4410
|
+
"sha256": digest(rendered_plan),
|
|
4411
|
+
},
|
|
4412
|
+
"plannedAction": "update",
|
|
4413
|
+
}
|
|
4414
|
+
monkeypatch.setattr(
|
|
4415
|
+
runner,
|
|
4416
|
+
"load_approved_plan_descriptor",
|
|
4417
|
+
lambda _payload: (descriptor, payload["approvedPlan"]["descriptor"]),
|
|
4418
|
+
)
|
|
4419
|
+
|
|
4420
|
+
def fake_download(uri, destination, _sha, _label):
|
|
4421
|
+
destination.write_bytes(saved_plan if uri.endswith("plan.bin") else rendered_plan)
|
|
4422
|
+
|
|
4423
|
+
monkeypatch.setattr(runner, "_download_approved_artifact", fake_download)
|
|
4424
|
+
monkeypatch.setattr(
|
|
4425
|
+
runner,
|
|
4426
|
+
"run",
|
|
4427
|
+
lambda _args, **kwargs: kwargs["stdout"].write(rendered_plan.decode()),
|
|
4428
|
+
)
|
|
4429
|
+
|
|
4430
|
+
with pytest.raises(RuntimeError, match="contains delete actions"):
|
|
4431
|
+
runner.validate_and_materialize_approved_plan(payload, state)
|
|
4432
|
+
|
|
4433
|
+
|
|
4434
|
+
def _tei_v380_recovery_payload(runner) -> dict:
|
|
4435
|
+
return {
|
|
4436
|
+
"action": "recover",
|
|
4437
|
+
"environmentName": "tei-e2e",
|
|
4438
|
+
"stage": "tei-e2e",
|
|
4439
|
+
"awsAccountId": "637423202447",
|
|
4440
|
+
"accountId": "637423202447",
|
|
4441
|
+
"awsRegion": "us-east-1",
|
|
4442
|
+
"region": "us-east-1",
|
|
4443
|
+
"operation": {
|
|
4444
|
+
"kind": "incident_recovery",
|
|
4445
|
+
**runner.tei_v380_recovery_scope(),
|
|
4446
|
+
},
|
|
4447
|
+
}
|
|
4448
|
+
|
|
4449
|
+
|
|
4450
|
+
def _tei_v380_state(runner) -> dict:
|
|
4451
|
+
return {
|
|
4452
|
+
"bucket": runner.TEI_V380_STATE_BUCKET,
|
|
4453
|
+
"key": runner.TEI_V380_STATE_KEY,
|
|
4454
|
+
"etag": "state-etag",
|
|
4455
|
+
"size": 123,
|
|
4456
|
+
"lastModified": "2026-07-20T13:31:15Z",
|
|
4457
|
+
"serverSideEncryption": "AES256",
|
|
4458
|
+
"versionId": None,
|
|
4459
|
+
"sha256": runner.TEI_V380_STATE_SHA256,
|
|
4460
|
+
"lineage": runner.TEI_V380_STATE_LINEAGE,
|
|
4461
|
+
"serial": runner.TEI_V380_STATE_SERIAL,
|
|
4462
|
+
"terraformVersion": "1.8.5",
|
|
4463
|
+
}
|
|
4464
|
+
|
|
4465
|
+
|
|
4466
|
+
def test_tei_v380_recovery_contract_is_exact_and_rejects_alias_drift() -> None:
|
|
4467
|
+
runner = load_runner()
|
|
4468
|
+
payload = _tei_v380_recovery_payload(runner)
|
|
4469
|
+
|
|
4470
|
+
assert runner.validate_tei_v380_recovery_contract(payload) == (runner.tei_v380_recovery_scope())
|
|
4471
|
+
|
|
4472
|
+
payload["stage"] = "dev"
|
|
4473
|
+
with pytest.raises(RuntimeError, match="stage does not match exact incident scope"):
|
|
4474
|
+
runner.validate_tei_v380_recovery_contract(payload)
|
|
4475
|
+
|
|
4476
|
+
payload = _tei_v380_recovery_payload(runner)
|
|
4477
|
+
payload["operation"]["unexpected"] = True
|
|
4478
|
+
with pytest.raises(RuntimeError, match="unexpected fields"):
|
|
4479
|
+
runner.validate_tei_v380_recovery_contract(payload)
|
|
4480
|
+
|
|
4481
|
+
|
|
4482
|
+
def test_incident_recovery_exclusivity_ignores_only_current_operation(
|
|
4483
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
4484
|
+
) -> None:
|
|
4485
|
+
runner = load_runner()
|
|
4486
|
+
execution_arn = "arn:aws:states:us-east-1:637423202447:execution:deploy:current"
|
|
4487
|
+
build_id = "runner:current"
|
|
4488
|
+
monkeypatch.setenv("THINKWORK_DEPLOYMENT_EXECUTION_ARN", execution_arn)
|
|
4489
|
+
monkeypatch.setenv("THINKWORK_DEPLOYMENT_STATE_MACHINE_ARN", "arn:state-machine")
|
|
4490
|
+
monkeypatch.setenv("THINKWORK_DEPLOYMENT_RUNNER_PROJECT_NAME", "runner")
|
|
4491
|
+
monkeypatch.setenv("CODEBUILD_BUILD_ID", build_id)
|
|
4492
|
+
|
|
4493
|
+
include_other = False
|
|
4494
|
+
|
|
4495
|
+
def fake_output(args, **_kwargs):
|
|
4496
|
+
if args[1:3] == ["stepfunctions", "list-executions"]:
|
|
4497
|
+
executions = [{"executionArn": execution_arn}]
|
|
4498
|
+
if include_other:
|
|
4499
|
+
executions.append({"executionArn": "arn:other"})
|
|
4500
|
+
return json.dumps({"executions": executions})
|
|
4501
|
+
if args[1:3] == ["codebuild", "list-builds-for-project"]:
|
|
4502
|
+
assert "--no-paginate" in args
|
|
4503
|
+
return json.dumps({"ids": [build_id]})
|
|
4504
|
+
if args[1:3] == ["codebuild", "batch-get-builds"]:
|
|
4505
|
+
return json.dumps({"builds": [{"id": build_id, "buildStatus": "IN_PROGRESS"}]})
|
|
4506
|
+
raise AssertionError(args)
|
|
4507
|
+
|
|
4508
|
+
monkeypatch.setattr(runner, "output", fake_output)
|
|
4509
|
+
assert runner.assert_incident_recovery_operation_is_exclusive() == {
|
|
4510
|
+
"currentExecutionArn": execution_arn,
|
|
4511
|
+
"currentBuildId": build_id,
|
|
4512
|
+
"otherRunningExecutions": 0,
|
|
4513
|
+
"otherRunningBuilds": 0,
|
|
4514
|
+
}
|
|
4515
|
+
|
|
4516
|
+
include_other = True
|
|
4517
|
+
with pytest.raises(RuntimeError, match="Another deployment execution"):
|
|
4518
|
+
runner.assert_incident_recovery_operation_is_exclusive()
|
|
4519
|
+
|
|
4520
|
+
|
|
4521
|
+
def test_incident_recovery_exclusivity_supports_one_managed_transition_execution(
|
|
4522
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
4523
|
+
) -> None:
|
|
4524
|
+
runner = load_runner()
|
|
4525
|
+
execution_arn = "arn:aws:states:us-east-1:637423202447:execution:deploy:transition"
|
|
4526
|
+
build_id = "runner:transition"
|
|
4527
|
+
monkeypatch.delenv("THINKWORK_DEPLOYMENT_EXECUTION_ARN", raising=False)
|
|
4528
|
+
monkeypatch.setenv("THINKWORK_DEPLOYMENT_STATE_MACHINE_ARN", "arn:state-machine")
|
|
4529
|
+
monkeypatch.setenv("THINKWORK_DEPLOYMENT_RUNNER_PROJECT_NAME", "runner")
|
|
4530
|
+
monkeypatch.setenv("CODEBUILD_BUILD_ID", build_id)
|
|
4531
|
+
|
|
4532
|
+
def fake_output(args, **_kwargs):
|
|
4533
|
+
if args[1:3] == ["stepfunctions", "list-executions"]:
|
|
4534
|
+
return json.dumps({"executions": [{"executionArn": execution_arn}]})
|
|
4535
|
+
if args[1:3] == ["codebuild", "list-builds-for-project"]:
|
|
4536
|
+
assert "--no-paginate" in args
|
|
4537
|
+
return json.dumps({"ids": [build_id]})
|
|
4538
|
+
if args[1:3] == ["codebuild", "batch-get-builds"]:
|
|
4539
|
+
return json.dumps({"builds": [{"id": build_id, "buildStatus": "IN_PROGRESS"}]})
|
|
4540
|
+
raise AssertionError(args)
|
|
4541
|
+
|
|
4542
|
+
monkeypatch.setattr(runner, "output", fake_output)
|
|
4543
|
+
assert (
|
|
4544
|
+
runner.assert_incident_recovery_operation_is_exclusive()["currentExecutionArn"]
|
|
4545
|
+
== execution_arn
|
|
4546
|
+
)
|
|
4547
|
+
|
|
4548
|
+
|
|
4549
|
+
def test_tei_v380_recovery_restores_only_exact_resources_without_secret_read(
|
|
4550
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
4551
|
+
) -> None:
|
|
4552
|
+
runner = load_runner()
|
|
4553
|
+
payload = _tei_v380_recovery_payload(runner)
|
|
4554
|
+
state = _tei_v380_state(runner)
|
|
4555
|
+
calls: list[list[str]] = []
|
|
4556
|
+
evidence: list[tuple[str, dict]] = []
|
|
4557
|
+
secret_describes = 0
|
|
4558
|
+
kms_describes = 0
|
|
4559
|
+
|
|
4560
|
+
monkeypatch.setattr(
|
|
4561
|
+
runner,
|
|
4562
|
+
"assert_incident_recovery_operation_is_exclusive",
|
|
4563
|
+
lambda: {
|
|
4564
|
+
"currentExecutionArn": "arn:current",
|
|
4565
|
+
"currentBuildId": "build:current",
|
|
4566
|
+
"otherRunningExecutions": 0,
|
|
4567
|
+
"otherRunningBuilds": 0,
|
|
4568
|
+
},
|
|
4569
|
+
)
|
|
4570
|
+
monkeypatch.setattr(runner, "terraform_state_object_snapshot", lambda _stage: state)
|
|
4571
|
+
monkeypatch.setattr(
|
|
4572
|
+
runner,
|
|
4573
|
+
"force_unlock_tei_v380_state",
|
|
4574
|
+
lambda: {
|
|
4575
|
+
"method": "terraform-force-unlock",
|
|
4576
|
+
"lockId": runner.TEI_V380_LOCK_ID,
|
|
4577
|
+
"directDynamoDbMutation": False,
|
|
4578
|
+
},
|
|
4579
|
+
)
|
|
4580
|
+
monkeypatch.setattr(runner.time, "sleep", lambda _seconds: None)
|
|
4581
|
+
|
|
4582
|
+
def fake_output(args, **_kwargs):
|
|
4583
|
+
nonlocal secret_describes, kms_describes
|
|
4584
|
+
calls.append(args)
|
|
4585
|
+
if args[1:3] == ["sts", "get-caller-identity"]:
|
|
4586
|
+
return "637423202447"
|
|
4587
|
+
if args[1:3] == ["secretsmanager", "describe-secret"]:
|
|
4588
|
+
secret_describes += 1
|
|
4589
|
+
return json.dumps(
|
|
4590
|
+
{
|
|
4591
|
+
"ARN": runner.TEI_V380_SECRET_ARN,
|
|
4592
|
+
"Name": runner.TEI_V380_SECRET_NAME,
|
|
4593
|
+
**({"DeletedDate": "2026-07-20T13:30:54Z"} if secret_describes == 1 else {}),
|
|
4594
|
+
}
|
|
4595
|
+
)
|
|
4596
|
+
if args[1:3] == ["kms", "describe-key"]:
|
|
4597
|
+
kms_describes += 1
|
|
4598
|
+
states = [
|
|
4599
|
+
("PendingDeletion", False),
|
|
4600
|
+
("Disabled", False),
|
|
4601
|
+
("Enabled", True),
|
|
4602
|
+
]
|
|
4603
|
+
key_state, enabled = states[min(kms_describes - 1, len(states) - 1)]
|
|
4604
|
+
return json.dumps(
|
|
4605
|
+
{
|
|
4606
|
+
"KeyMetadata": {
|
|
4607
|
+
"KeyId": runner.TEI_V380_KMS_KEY_ID,
|
|
4608
|
+
"Arn": runner.TEI_V380_KMS_KEY_ARN,
|
|
4609
|
+
"KeyState": key_state,
|
|
4610
|
+
"Enabled": enabled,
|
|
4611
|
+
"KeyManager": "CUSTOMER",
|
|
4612
|
+
"Origin": "AWS_KMS",
|
|
4613
|
+
}
|
|
4614
|
+
}
|
|
4615
|
+
)
|
|
4616
|
+
raise AssertionError(args)
|
|
4617
|
+
|
|
4618
|
+
def fake_run(args, **_kwargs):
|
|
4619
|
+
calls.append(args)
|
|
4620
|
+
return None
|
|
4621
|
+
|
|
4622
|
+
def fake_evidence(name, body):
|
|
4623
|
+
evidence.append((name, json.loads(json.dumps(body))))
|
|
4624
|
+
return {"fileName": name, "sha256": "a" * 64, "s3Uri": f"s3://evidence/{name}"}
|
|
4625
|
+
|
|
4626
|
+
monkeypatch.setattr(runner, "output", fake_output)
|
|
4627
|
+
monkeypatch.setattr(runner, "run", fake_run)
|
|
4628
|
+
monkeypatch.setattr(runner, "write_json_evidence_artifact", fake_evidence)
|
|
4629
|
+
|
|
4630
|
+
recovered = runner.recover_tei_v380_incident(payload)
|
|
4631
|
+
|
|
4632
|
+
assert recovered["proof"]["status"] == "succeeded"
|
|
4633
|
+
assert recovered["proof"]["secretValueRead"] is False
|
|
4634
|
+
assert recovered["proof"]["terraformStateMutation"] is False
|
|
4635
|
+
assert recovered["proof"]["terraformLock"]["directDynamoDbMutation"] is False
|
|
4636
|
+
assert [
|
|
4637
|
+
"aws",
|
|
4638
|
+
"secretsmanager",
|
|
4639
|
+
"restore-secret",
|
|
4640
|
+
"--secret-id",
|
|
4641
|
+
runner.TEI_V380_SECRET_ARN,
|
|
4642
|
+
] in calls
|
|
4643
|
+
assert ["aws", "kms", "cancel-key-deletion", "--key-id", runner.TEI_V380_KMS_KEY_ID] in calls
|
|
4644
|
+
assert ["aws", "kms", "enable-key", "--key-id", runner.TEI_V380_KMS_KEY_ID] in calls
|
|
4645
|
+
assert not any("get-secret-value" in call for call in calls)
|
|
4646
|
+
assert evidence[-1][0] == "incident-recovery.json"
|
|
4647
|
+
assert all("SecretString" not in json.dumps(body) for _, body in evidence)
|
|
4648
|
+
|
|
4649
|
+
|
|
4650
|
+
def test_tei_v380_recovery_refuses_changed_state_before_mutation(
|
|
4651
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
4652
|
+
) -> None:
|
|
4653
|
+
runner = load_runner()
|
|
4654
|
+
payload = _tei_v380_recovery_payload(runner)
|
|
4655
|
+
changed_state = {**_tei_v380_state(runner), "serial": runner.TEI_V380_STATE_SERIAL + 1}
|
|
4656
|
+
mutation_calls: list[list[str]] = []
|
|
4657
|
+
|
|
4658
|
+
monkeypatch.setattr(runner, "assert_incident_recovery_operation_is_exclusive", lambda: {})
|
|
4659
|
+
monkeypatch.setattr(runner, "terraform_state_object_snapshot", lambda _stage: changed_state)
|
|
4660
|
+
monkeypatch.setattr(
|
|
4661
|
+
runner,
|
|
4662
|
+
"output",
|
|
4663
|
+
lambda args, **_kwargs: (
|
|
4664
|
+
"637423202447"
|
|
4665
|
+
if args[1:3] == ["sts", "get-caller-identity"]
|
|
4666
|
+
else (_ for _ in ()).throw(AssertionError(args))
|
|
4667
|
+
),
|
|
4668
|
+
)
|
|
4669
|
+
monkeypatch.setattr(runner, "run", lambda args, **_kwargs: mutation_calls.append(args))
|
|
4670
|
+
|
|
4671
|
+
with pytest.raises(RuntimeError, match="state serial changed"):
|
|
4672
|
+
runner.recover_tei_v380_incident(payload)
|
|
4673
|
+
assert mutation_calls == []
|
|
4674
|
+
|
|
4675
|
+
|
|
4676
|
+
def test_tei_v380_force_unlock_uses_terraform_and_preserves_digest(
|
|
4677
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
4678
|
+
) -> None:
|
|
4679
|
+
runner = load_runner()
|
|
4680
|
+
monkeypatch.setattr(runner, "WORK", tmp_path)
|
|
4681
|
+
calls: list[tuple[list[str], dict]] = []
|
|
4682
|
+
snapshots = iter(
|
|
4683
|
+
[
|
|
4684
|
+
{
|
|
4685
|
+
"table": runner.TEI_V380_LOCK_TABLE,
|
|
4686
|
+
"key": runner.TEI_V380_LOCK_KEY,
|
|
4687
|
+
"present": True,
|
|
4688
|
+
"info": {
|
|
4689
|
+
"id": runner.TEI_V380_LOCK_ID,
|
|
4690
|
+
"operation": "OperationTypeApply",
|
|
4691
|
+
"version": "1.8.5",
|
|
4692
|
+
"created": "2026-07-20T13:30:42Z",
|
|
4693
|
+
"path": runner.TEI_V380_LOCK_KEY,
|
|
4694
|
+
},
|
|
4695
|
+
"digestKey": f"{runner.TEI_V380_LOCK_KEY}-md5",
|
|
4696
|
+
"digest": runner.TEI_V380_STATE_DIGEST,
|
|
4697
|
+
},
|
|
4698
|
+
{
|
|
4699
|
+
"table": runner.TEI_V380_LOCK_TABLE,
|
|
4700
|
+
"key": runner.TEI_V380_LOCK_KEY,
|
|
4701
|
+
"present": False,
|
|
4702
|
+
"info": None,
|
|
4703
|
+
"digestKey": f"{runner.TEI_V380_LOCK_KEY}-md5",
|
|
4704
|
+
"digest": runner.TEI_V380_STATE_DIGEST,
|
|
4705
|
+
},
|
|
4706
|
+
]
|
|
4707
|
+
)
|
|
4708
|
+
monkeypatch.setattr(runner, "tei_v380_lock_snapshot", lambda: next(snapshots))
|
|
4709
|
+
monkeypatch.setattr(runner, "run", lambda args, **kwargs: calls.append((args, kwargs)))
|
|
4710
|
+
|
|
4711
|
+
proof = runner.force_unlock_tei_v380_state()
|
|
4712
|
+
|
|
4713
|
+
assert proof["method"] == "terraform-force-unlock"
|
|
4714
|
+
assert proof["directDynamoDbMutation"] is False
|
|
4715
|
+
assert calls[0][0][:3] == ["terraform", "init", "-reconfigure"]
|
|
4716
|
+
assert calls[1][0] == [
|
|
4717
|
+
"terraform",
|
|
4718
|
+
"force-unlock",
|
|
4719
|
+
"-force",
|
|
4720
|
+
runner.TEI_V380_LOCK_ID,
|
|
4721
|
+
]
|
|
4722
|
+
assert not any("dynamodb" in arg for call, _kwargs in calls for arg in call)
|
|
4723
|
+
backend = (tmp_path / "tei-v380-force-unlock/backend.hcl").read_text()
|
|
4724
|
+
assert runner.TEI_V380_STATE_BUCKET in backend
|
|
4725
|
+
assert runner.TEI_V380_LOCK_TABLE in backend
|
|
2868
4726
|
|
|
2869
4727
|
|
|
2870
|
-
def
|
|
4728
|
+
def test_tei_v380_force_unlock_is_retry_safe_after_exact_unlock(
|
|
2871
4729
|
monkeypatch: pytest.MonkeyPatch,
|
|
2872
4730
|
) -> None:
|
|
2873
4731
|
runner = load_runner()
|
|
2874
4732
|
calls: list[list[str]] = []
|
|
2875
|
-
|
|
2876
|
-
|
|
4733
|
+
unlocked = {
|
|
4734
|
+
"table": runner.TEI_V380_LOCK_TABLE,
|
|
4735
|
+
"key": runner.TEI_V380_LOCK_KEY,
|
|
4736
|
+
"present": False,
|
|
4737
|
+
"info": None,
|
|
4738
|
+
"digestKey": f"{runner.TEI_V380_LOCK_KEY}-md5",
|
|
4739
|
+
"digest": runner.TEI_V380_STATE_DIGEST,
|
|
4740
|
+
}
|
|
4741
|
+
monkeypatch.setattr(runner, "tei_v380_lock_snapshot", lambda: unlocked)
|
|
2877
4742
|
monkeypatch.setattr(runner, "run", lambda args, **_kwargs: calls.append(args))
|
|
2878
4743
|
|
|
2879
|
-
|
|
2880
|
-
{
|
|
2881
|
-
"deployment_release_version": "v0.1.0-canary.147",
|
|
2882
|
-
"deployment_release_manifest_url": "https://example.test/thinkwork-release.json",
|
|
2883
|
-
"deployment_release_manifest_sha256": "f" * 64,
|
|
2884
|
-
"deployment_release_manifest_signature_url": "",
|
|
2885
|
-
"deployment_release_manifest_trust_policy": "allow_unsigned_canary",
|
|
2886
|
-
"deployment_release_manifest_trusted_keys_json": "[]",
|
|
2887
|
-
"deployment_terraform_module_source": "thinkwork-ai/thinkwork/aws",
|
|
2888
|
-
"deployment_terraform_module_version": "",
|
|
2889
|
-
}
|
|
2890
|
-
)
|
|
4744
|
+
proof = runner.force_unlock_tei_v380_state()
|
|
2891
4745
|
|
|
2892
|
-
|
|
2893
|
-
assert
|
|
2894
|
-
assert
|
|
2895
|
-
"/thinkwork/tei-e2e/deployment/selected-release-version": "v0.1.0-canary.147",
|
|
2896
|
-
"/thinkwork/tei-e2e/deployment/selected-release-manifest-url": (
|
|
2897
|
-
"https://example.test/thinkwork-release.json"
|
|
2898
|
-
),
|
|
2899
|
-
"/thinkwork/tei-e2e/deployment/selected-release-manifest-sha256": "f" * 64,
|
|
2900
|
-
"/thinkwork/tei-e2e/deployment/selected-release-trust-policy": "allow_unsigned_canary",
|
|
2901
|
-
"/thinkwork/tei-e2e/deployment/selected-release-trusted-keys-json": "[]",
|
|
2902
|
-
"/thinkwork/tei-e2e/deployment/terraform-module-source": "thinkwork-ai/thinkwork/aws",
|
|
2903
|
-
"/thinkwork/tei-e2e/deployment/terraform-module-version": "0.1.0-canary.147",
|
|
2904
|
-
}
|
|
4746
|
+
assert proof["alreadyUnlocked"] is True
|
|
4747
|
+
assert proof["before"] == proof["after"]
|
|
4748
|
+
assert calls == []
|
|
2905
4749
|
|
|
2906
4750
|
|
|
2907
|
-
def
|
|
2908
|
-
monkeypatch: pytest.MonkeyPatch
|
|
4751
|
+
def test_tei_v380_recovery_imports_require_exact_evidence_and_current_state(
|
|
4752
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
2909
4753
|
) -> None:
|
|
2910
4754
|
runner = load_runner()
|
|
2911
|
-
|
|
4755
|
+
monkeypatch.setattr(runner, "WORK", tmp_path)
|
|
4756
|
+
state = _tei_v380_state(runner)
|
|
4757
|
+
scope = runner.tei_v380_recovery_scope()
|
|
4758
|
+
evidence = {
|
|
4759
|
+
"contract": runner.TEI_V380_RECOVERY_CONTRACT,
|
|
4760
|
+
"incidentId": runner.TEI_V380_RECOVERY_INCIDENT_ID,
|
|
4761
|
+
"rejectedPlanSha256": runner.TEI_V380_REJECTED_PLAN_SHA256,
|
|
4762
|
+
"status": "succeeded",
|
|
4763
|
+
"terraformStateMutation": False,
|
|
4764
|
+
"secretValueRead": False,
|
|
4765
|
+
"stateBefore": state,
|
|
4766
|
+
"stateAfter": state,
|
|
4767
|
+
}
|
|
4768
|
+
payload = {
|
|
4769
|
+
"incidentRecovery": {
|
|
4770
|
+
**scope,
|
|
4771
|
+
"evidence": {"s3Uri": "s3://evidence/recovery.json", "sha256": "a" * 64},
|
|
4772
|
+
}
|
|
4773
|
+
}
|
|
4774
|
+
vars_json = {
|
|
4775
|
+
"enable_agentcore_multiplayer_proof": True,
|
|
4776
|
+
"agentcore_multiplayer_proof_tenant_slug": "tei",
|
|
4777
|
+
"agentcore_multiplayer_proof_owner_allowlist": runner.TEI_V380_OWNER_ALLOWLIST,
|
|
4778
|
+
"finalize_auth_retirement": False,
|
|
4779
|
+
"auth_retirement_phase": "coexistence",
|
|
4780
|
+
"microsoft_oauth_tenant": "organizations",
|
|
4781
|
+
}
|
|
2912
4782
|
|
|
2913
|
-
|
|
2914
|
-
|
|
4783
|
+
def fake_download(_uri, destination, _sha, _label):
|
|
4784
|
+
destination.write_text(json.dumps(evidence), encoding="utf-8")
|
|
4785
|
+
return "a" * 64
|
|
2915
4786
|
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
"deployment_release_manifest_trusted_keys_json": "[]",
|
|
2923
|
-
"deployment_terraform_module_source": (
|
|
2924
|
-
"git::https://github.com/thinkwork-ai/thinkwork.git"
|
|
2925
|
-
"//terraform/modules/thinkwork?ref=37d7246"
|
|
2926
|
-
),
|
|
2927
|
-
"deployment_terraform_module_version": "",
|
|
2928
|
-
}
|
|
4787
|
+
monkeypatch.setattr(runner, "_download_approved_artifact", fake_download)
|
|
4788
|
+
snapshots = [state]
|
|
4789
|
+
monkeypatch.setattr(
|
|
4790
|
+
runner,
|
|
4791
|
+
"terraform_state_object_snapshot",
|
|
4792
|
+
lambda stage: snapshots.pop(0) if stage == "tei-e2e" else {},
|
|
2929
4793
|
)
|
|
4794
|
+
blocks = runner.tei_v380_recovery_import_blocks(payload, vars_json)
|
|
4795
|
+
assert f"to = {runner.TEI_V380_SECRET_ADDRESS}" in blocks
|
|
4796
|
+
assert f"id = {runner.hcl_string(runner.TEI_V380_SECRET_ARN)}" in blocks
|
|
4797
|
+
assert f"to = {runner.TEI_V380_KMS_KEY_ADDRESS}" in blocks
|
|
4798
|
+
assert runner.TEI_V380_KMS_ALIAS_ADDRESS not in blocks
|
|
2930
4799
|
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
4800
|
+
moved_state = {**state, "sha256": "f" * 64}
|
|
4801
|
+
snapshots.append(moved_state)
|
|
4802
|
+
with pytest.raises(RuntimeError, match="state moved"):
|
|
4803
|
+
runner.tei_v380_recovery_import_blocks(payload, vars_json)
|
|
4804
|
+
|
|
4805
|
+
payload["incidentRecovery"]["stage"] = "dev"
|
|
4806
|
+
with pytest.raises(RuntimeError, match="scope does not match"):
|
|
4807
|
+
runner.tei_v380_recovery_import_blocks(payload, vars_json)
|
|
2934
4808
|
|
|
2935
4809
|
|
|
2936
4810
|
def test_terraform_plan_summary_counts_resource_actions() -> None:
|
|
@@ -3172,3 +5046,568 @@ def test_ensure_first_admin_failure_is_nonfatal(
|
|
|
3172
5046
|
)
|
|
3173
5047
|
assert runner.FIRST_ADMIN_EVIDENCE["status"] == "failed"
|
|
3174
5048
|
assert "user_pool_id" in runner.FIRST_ADMIN_EVIDENCE["error"]
|
|
5049
|
+
|
|
5050
|
+
|
|
5051
|
+
def test_write_runner_files_threads_deployment_runner_in_vpc(
|
|
5052
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
5053
|
+
) -> None:
|
|
5054
|
+
"""THINK-1082: the runner-in-VPC flag rides the same three wiring points
|
|
5055
|
+
as every other environment-posture variable — vars_json, the tfvars
|
|
5056
|
+
file, and the generated root's variable + module argument — and is
|
|
5057
|
+
ship-inert (False) unless the runner-secrets document or payload says
|
|
5058
|
+
otherwise."""
|
|
5059
|
+
runner = load_runner()
|
|
5060
|
+
tf_dir = _cognito_email_runner_env(runner, tmp_path, monkeypatch)
|
|
5061
|
+
base_payload = {
|
|
5062
|
+
"stage": "tei-e2e",
|
|
5063
|
+
"awsRegion": "us-east-1",
|
|
5064
|
+
"awsAccountId": "637423202447",
|
|
5065
|
+
"dbPassword": "db-secret",
|
|
5066
|
+
"apiAuthSecret": "api-secret",
|
|
5067
|
+
}
|
|
5068
|
+
|
|
5069
|
+
vars_json_default = runner.write_runner_files(dict(base_payload), {})
|
|
5070
|
+
assert vars_json_default["deployment_runner_in_vpc"] is False
|
|
5071
|
+
|
|
5072
|
+
vars_json_payload = runner.write_runner_files(
|
|
5073
|
+
{**base_payload, "deploymentRunnerInVpc": True}, {}
|
|
5074
|
+
)
|
|
5075
|
+
assert vars_json_payload["deployment_runner_in_vpc"] is True
|
|
5076
|
+
tfvars = json.loads((tf_dir / "terraform.auto.tfvars.json").read_text(encoding="utf-8"))
|
|
5077
|
+
assert tfvars["deployment_runner_in_vpc"] is True
|
|
5078
|
+
main_tf = (tf_dir / "main.tf").read_text(encoding="utf-8")
|
|
5079
|
+
assert 'variable "deployment_runner_in_vpc"' in main_tf
|
|
5080
|
+
assert "deployment_runner_in_vpc = var.deployment_runner_in_vpc" in main_tf
|
|
5081
|
+
|
|
5082
|
+
# Secrets Manager JSON often carries strings; the runner-secrets document
|
|
5083
|
+
# is the durable home and wins over the payload, emitting a real bool.
|
|
5084
|
+
vars_json_secret = runner.write_runner_files(
|
|
5085
|
+
{**base_payload, "deploymentRunnerInVpc": False},
|
|
5086
|
+
{"deploymentRunnerInVpc": "true"},
|
|
5087
|
+
)
|
|
5088
|
+
assert vars_json_secret["deployment_runner_in_vpc"] is True
|
|
5089
|
+
|
|
5090
|
+
|
|
5091
|
+
def test_write_runner_files_threads_api_lambdas_in_vpc_and_db_exposure(
|
|
5092
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
5093
|
+
) -> None:
|
|
5094
|
+
"""THINK-1082: the api-Lambdas-in-VPC flag and the two database-exposure
|
|
5095
|
+
inputs ride the same wiring points as deploymentRunnerInVpc (vars_json,
|
|
5096
|
+
tfvars, generated-root variable + module argument) and are ship-inert:
|
|
5097
|
+
Lambdas out of the VPC, public endpoint on, world-open 5432 seeded."""
|
|
5098
|
+
runner = load_runner()
|
|
5099
|
+
tf_dir = _cognito_email_runner_env(runner, tmp_path, monkeypatch)
|
|
5100
|
+
base_payload = {
|
|
5101
|
+
"stage": "tei-e2e",
|
|
5102
|
+
"awsRegion": "us-east-1",
|
|
5103
|
+
"awsAccountId": "637423202447",
|
|
5104
|
+
"dbPassword": "db-secret",
|
|
5105
|
+
"apiAuthSecret": "api-secret",
|
|
5106
|
+
}
|
|
5107
|
+
|
|
5108
|
+
vars_json_default = runner.write_runner_files(dict(base_payload), {})
|
|
5109
|
+
assert vars_json_default["api_lambdas_in_vpc"] is False
|
|
5110
|
+
assert vars_json_default["db_publicly_accessible"] is True
|
|
5111
|
+
assert vars_json_default["db_public_ingress_cidrs"] == ["0.0.0.0/0"]
|
|
5112
|
+
main_tf = (tf_dir / "main.tf").read_text(encoding="utf-8")
|
|
5113
|
+
for name in ("api_lambdas_in_vpc", "db_publicly_accessible", "db_public_ingress_cidrs"):
|
|
5114
|
+
assert f'variable "{name}"' in main_tf
|
|
5115
|
+
assert f"{name:<35}= var.{name}" in main_tf
|
|
5116
|
+
|
|
5117
|
+
# Payload lane: the closed posture. An explicit [] must survive as []
|
|
5118
|
+
# (not fall back to the world-open default) — that IS the port close.
|
|
5119
|
+
vars_json_closed = runner.write_runner_files(
|
|
5120
|
+
{
|
|
5121
|
+
**base_payload,
|
|
5122
|
+
"apiLambdasInVpc": True,
|
|
5123
|
+
"dbPubliclyAccessible": False,
|
|
5124
|
+
"dbPublicIngressCidrs": [],
|
|
5125
|
+
},
|
|
5126
|
+
{},
|
|
5127
|
+
)
|
|
5128
|
+
assert vars_json_closed["api_lambdas_in_vpc"] is True
|
|
5129
|
+
assert vars_json_closed["db_publicly_accessible"] is False
|
|
5130
|
+
assert vars_json_closed["db_public_ingress_cidrs"] == []
|
|
5131
|
+
tfvars = json.loads((tf_dir / "terraform.auto.tfvars.json").read_text(encoding="utf-8"))
|
|
5132
|
+
assert tfvars["api_lambdas_in_vpc"] is True
|
|
5133
|
+
assert tfvars["db_publicly_accessible"] is False
|
|
5134
|
+
assert tfvars["db_public_ingress_cidrs"] == []
|
|
5135
|
+
|
|
5136
|
+
# Secrets Manager JSON often carries strings; the runner-secrets document
|
|
5137
|
+
# is the durable home and wins over the payload, emitting real types.
|
|
5138
|
+
vars_json_secret = runner.write_runner_files(
|
|
5139
|
+
{**base_payload, "apiLambdasInVpc": False, "dbPublicIngressCidrs": ["0.0.0.0/0"]},
|
|
5140
|
+
{
|
|
5141
|
+
"apiLambdasInVpc": "true",
|
|
5142
|
+
"dbPubliclyAccessible": "false",
|
|
5143
|
+
"dbPublicIngressCidrs": "10.0.0.0/8, 192.168.0.0/16",
|
|
5144
|
+
},
|
|
5145
|
+
)
|
|
5146
|
+
assert vars_json_secret["api_lambdas_in_vpc"] is True
|
|
5147
|
+
assert vars_json_secret["db_publicly_accessible"] is False
|
|
5148
|
+
assert vars_json_secret["db_public_ingress_cidrs"] == ["10.0.0.0/8", "192.168.0.0/16"]
|
|
5149
|
+
|
|
5150
|
+
# An explicit empty string in the secret also means "no rule".
|
|
5151
|
+
vars_json_empty = runner.write_runner_files(dict(base_payload), {"dbPublicIngressCidrs": ""})
|
|
5152
|
+
assert vars_json_empty["db_public_ingress_cidrs"] == []
|
|
5153
|
+
|
|
5154
|
+
|
|
5155
|
+
def test_psql_failures_redact_the_database_password(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
5156
|
+
"""A failed psql surfaces its argv through CalledProcessError, and
|
|
5157
|
+
CodeBuild prints that to CloudWatch. The password must never reach the
|
|
5158
|
+
log; everything else about the failure (type, returncode, output) is
|
|
5159
|
+
preserved for callers that already catch CalledProcessError."""
|
|
5160
|
+
runner = load_runner()
|
|
5161
|
+
password = "s3cr%40t%2Fpw"
|
|
5162
|
+
url = (
|
|
5163
|
+
f"postgresql://thinkwork_admin:{password}"
|
|
5164
|
+
"@db.cluster-abc.us-east-1.rds.amazonaws.com:5432/thinkwork?sslmode=require"
|
|
5165
|
+
)
|
|
5166
|
+
|
|
5167
|
+
def failing_output(args, **_kwargs):
|
|
5168
|
+
raise subprocess.CalledProcessError(2, args, output="", stderr="Connection timed out")
|
|
5169
|
+
|
|
5170
|
+
monkeypatch.setattr(runner, "output", failing_output)
|
|
5171
|
+
with pytest.raises(subprocess.CalledProcessError) as raised:
|
|
5172
|
+
runner.psql_output(url, "SELECT to_regclass('public.tenants')")
|
|
5173
|
+
assert password not in str(raised.value)
|
|
5174
|
+
assert password not in json.dumps(raised.value.cmd)
|
|
5175
|
+
assert "thinkwork_admin:***@db.cluster-abc" in raised.value.cmd[1]
|
|
5176
|
+
assert raised.value.returncode == 2
|
|
5177
|
+
assert raised.value.stderr == "Connection timed out"
|
|
5178
|
+
|
|
5179
|
+
def failing_run(args, **_kwargs):
|
|
5180
|
+
raise subprocess.CalledProcessError(1, args)
|
|
5181
|
+
|
|
5182
|
+
monkeypatch.setattr(runner, "run", failing_run)
|
|
5183
|
+
with pytest.raises(subprocess.CalledProcessError) as raised_file:
|
|
5184
|
+
runner.psql(url, file=Path("0001_init.sql"), variables={"stage": "tei-e2e"})
|
|
5185
|
+
assert password not in str(raised_file.value)
|
|
5186
|
+
assert "-f" in raised_file.value.cmd and "0001_init.sql" in raised_file.value.cmd
|
|
5187
|
+
|
|
5188
|
+
def failing_subprocess_run(args, **_kwargs):
|
|
5189
|
+
raise subprocess.CalledProcessError(3, args)
|
|
5190
|
+
|
|
5191
|
+
monkeypatch.setattr(runner.subprocess, "run", failing_subprocess_run)
|
|
5192
|
+
with pytest.raises(subprocess.CalledProcessError) as raised_sql:
|
|
5193
|
+
runner.psql(url, sql="SELECT 1")
|
|
5194
|
+
assert password not in str(raised_sql.value)
|
|
5195
|
+
assert raised_sql.value.returncode == 3
|
|
5196
|
+
|
|
5197
|
+
assert runner.redact_database_url(url) == (
|
|
5198
|
+
"postgresql://thinkwork_admin:***"
|
|
5199
|
+
"@db.cluster-abc.us-east-1.rds.amazonaws.com:5432/thinkwork?sslmode=require"
|
|
5200
|
+
)
|
|
5201
|
+
assert runner.redact_database_url("no url here") == "no url here"
|
|
5202
|
+
assert runner.redact_database_url(None) is None
|
|
5203
|
+
|
|
5204
|
+
|
|
5205
|
+
################################################################################
|
|
5206
|
+
# Database access over the RDS Data API (THINK-1082)
|
|
5207
|
+
################################################################################
|
|
5208
|
+
|
|
5209
|
+
DRIZZLE_DIR = Path(__file__).resolve().parents[4] / "packages/database-pg/drizzle"
|
|
5210
|
+
|
|
5211
|
+
|
|
5212
|
+
class FakeRdsDataClient:
|
|
5213
|
+
"""boto3-shaped rds-data stand-in: records every call in order, answers
|
|
5214
|
+
scalar probes from a tiny in-memory ledger, and can fail one statement."""
|
|
5215
|
+
|
|
5216
|
+
def __init__(self, fail_on: str | None = None, ledger: set[str] | None = None) -> None:
|
|
5217
|
+
self.calls: list[tuple[str, str | None, str]] = []
|
|
5218
|
+
self.fail_on = fail_on
|
|
5219
|
+
self.ledger = set(ledger or set())
|
|
5220
|
+
self.transactions = 0
|
|
5221
|
+
|
|
5222
|
+
def execute_statement(self, **params):
|
|
5223
|
+
sql = params["sql"]
|
|
5224
|
+
self.calls.append(("execute", params.get("transactionId"), sql))
|
|
5225
|
+
if self.fail_on and self.fail_on in sql:
|
|
5226
|
+
raise RuntimeError(f"ERROR: boom near {self.fail_on}; SQLState: 42601")
|
|
5227
|
+
if "to_regclass('public.tenants')" in sql:
|
|
5228
|
+
return {"records": [[{"stringValue": "tenants"}]]}
|
|
5229
|
+
if "to_regclass('public.platform_schema_migrations')" in sql:
|
|
5230
|
+
return {"records": [[{"stringValue": "platform_schema_migrations"}]]}
|
|
5231
|
+
if "SELECT name FROM public.platform_schema_migrations" in sql:
|
|
5232
|
+
return {"records": [[{"stringValue": name}] for name in sorted(self.ledger)]}
|
|
5233
|
+
if "INSERT INTO public.platform_schema_migrations" in sql:
|
|
5234
|
+
self.ledger.update(re.findall(r"\('([^']+)', '[^']+'\)", sql))
|
|
5235
|
+
return {"records": []}
|
|
5236
|
+
|
|
5237
|
+
def begin_transaction(self, **params):
|
|
5238
|
+
self.transactions += 1
|
|
5239
|
+
transaction_id = f"tx{self.transactions}"
|
|
5240
|
+
self.calls.append(("begin", transaction_id, ""))
|
|
5241
|
+
return {"transactionId": transaction_id}
|
|
5242
|
+
|
|
5243
|
+
def commit_transaction(self, **params):
|
|
5244
|
+
self.calls.append(("commit", params["transactionId"], ""))
|
|
5245
|
+
return {}
|
|
5246
|
+
|
|
5247
|
+
def rollback_transaction(self, **params):
|
|
5248
|
+
self.calls.append(("rollback", params["transactionId"], ""))
|
|
5249
|
+
return {}
|
|
5250
|
+
|
|
5251
|
+
def executed(self) -> list[str]:
|
|
5252
|
+
return [sql for kind, _tx, sql in self.calls if kind == "execute"]
|
|
5253
|
+
|
|
5254
|
+
|
|
5255
|
+
def data_api_db(runner, client: FakeRdsDataClient):
|
|
5256
|
+
return runner.DataApiDatabase(
|
|
5257
|
+
"arn:aws:rds:us-east-1:637423202447:cluster:thinkwork-tei-e2e-db",
|
|
5258
|
+
"arn:aws:secretsmanager:us-east-1:637423202447:secret:db-abc",
|
|
5259
|
+
"thinkwork",
|
|
5260
|
+
client=client,
|
|
5261
|
+
)
|
|
5262
|
+
|
|
5263
|
+
|
|
5264
|
+
def test_split_sql_script_honours_quotes_dollar_bodies_comments_and_breakpoints() -> None:
|
|
5265
|
+
runner = load_runner()
|
|
5266
|
+
script = """
|
|
5267
|
+
-- leading comment; with a semicolon
|
|
5268
|
+
CREATE TABLE a (id int);
|
|
5269
|
+
--> statement-breakpoint
|
|
5270
|
+
CREATE FUNCTION f() RETURNS void AS $$
|
|
5271
|
+
BEGIN
|
|
5272
|
+
PERFORM 1; PERFORM 2;
|
|
5273
|
+
END;
|
|
5274
|
+
$$ LANGUAGE plpgsql
|
|
5275
|
+
--> statement-breakpoint
|
|
5276
|
+
DO $body$ BEGIN RAISE NOTICE 'x; y'; END $body$;
|
|
5277
|
+
INSERT INTO a VALUES ('semi;colon'), (E'esc\\'aped; still'); /* block; comment */
|
|
5278
|
+
SELECT "quoted;identifier" FROM a;
|
|
5279
|
+
-- trailing comment only
|
|
5280
|
+
"""
|
|
5281
|
+
items = runner.split_sql_script(script)
|
|
5282
|
+
assert [kind for kind, _ in items] == ["sql"] * 5
|
|
5283
|
+
statements = [text for _, text in items]
|
|
5284
|
+
assert statements[0].endswith("CREATE TABLE a (id int)")
|
|
5285
|
+
assert (
|
|
5286
|
+
statements[1].startswith("CREATE FUNCTION f()") and "PERFORM 1; PERFORM 2;" in statements[1]
|
|
5287
|
+
)
|
|
5288
|
+
assert statements[2] == "DO $body$ BEGIN RAISE NOTICE 'x; y'; END $body$"
|
|
5289
|
+
assert statements[3].startswith("INSERT INTO a VALUES ('semi;colon')")
|
|
5290
|
+
assert "E'esc\\'aped; still'" in statements[3]
|
|
5291
|
+
assert statements[4].startswith("/* block; comment */")
|
|
5292
|
+
assert statements[4].endswith('SELECT "quoted;identifier" FROM a')
|
|
5293
|
+
# the drizzle marker alone is a boundary even without a semicolon
|
|
5294
|
+
assert runner.split_sql_script("SELECT 1\n--> statement-breakpoint\nSELECT 2;") == [
|
|
5295
|
+
("sql", "SELECT 1"),
|
|
5296
|
+
("sql", "SELECT 2"),
|
|
5297
|
+
]
|
|
5298
|
+
|
|
5299
|
+
|
|
5300
|
+
def test_split_sql_script_lifts_meta_command_lines_in_order() -> None:
|
|
5301
|
+
runner = load_runner()
|
|
5302
|
+
script = "\\set ON_ERROR_STOP on\nSELECT 1;\n\\echo 'done; really'\nSELECT 2;\n"
|
|
5303
|
+
assert runner.split_sql_script(script) == [
|
|
5304
|
+
("meta", "\\set ON_ERROR_STOP on"),
|
|
5305
|
+
("sql", "SELECT 1"),
|
|
5306
|
+
("meta", "\\echo 'done; really'"),
|
|
5307
|
+
("sql", "SELECT 2"),
|
|
5308
|
+
]
|
|
5309
|
+
# a backslash inside a dollar-quoted body is not a meta-command
|
|
5310
|
+
body = "DO $$ BEGIN\n\\x := 1;\nEND $$;"
|
|
5311
|
+
assert runner.split_sql_script(body) == [("sql", "DO $$ BEGIN\n\\x := 1;\nEND $$")]
|
|
5312
|
+
|
|
5313
|
+
|
|
5314
|
+
def test_substitute_psql_variables_matches_psql_interpolation() -> None:
|
|
5315
|
+
runner = load_runner()
|
|
5316
|
+
variables = {"stage": "tei-e2e", "who": "o'brien"}
|
|
5317
|
+
sql = (
|
|
5318
|
+
"SELECT :'stage', :\"stage\", :stage, :'who', 1::text, ':stage untouched', "
|
|
5319
|
+
"\"also :'stage'\" -- and :'stage' in a comment\n, :'undefined'"
|
|
5320
|
+
)
|
|
5321
|
+
assert runner.substitute_psql_variables(sql, variables) == (
|
|
5322
|
+
"SELECT 'tei-e2e', \"tei-e2e\", tei-e2e, 'o''brien', 1::text, ':stage untouched', "
|
|
5323
|
+
"\"also :'stage'\" -- and :'stage' in a comment\n, :'undefined'"
|
|
5324
|
+
)
|
|
5325
|
+
assert runner.substitute_psql_variables("SELECT :'stage'", None) == "SELECT :'stage'"
|
|
5326
|
+
|
|
5327
|
+
|
|
5328
|
+
def test_data_api_plans_the_real_meta_command_migrations() -> None:
|
|
5329
|
+
"""0031, 0036 and 0037 are the migration files that use psql
|
|
5330
|
+
meta-commands; every form they use is interpreted, read from disk."""
|
|
5331
|
+
runner = load_runner()
|
|
5332
|
+
db = data_api_db(runner, FakeRdsDataClient())
|
|
5333
|
+
|
|
5334
|
+
# 0036 / 0037: `\set ON_ERROR_STOP on` only.
|
|
5335
|
+
for name in ("0036_user_scoped_memory_wiki.sql", "0037_user_profile_operating_model.sql"):
|
|
5336
|
+
text = (DRIZZLE_DIR / name).read_text(encoding="utf-8")
|
|
5337
|
+
assert "\\set ON_ERROR_STOP on" in text
|
|
5338
|
+
planned = db._plan(text, name, {"stage": "tei-e2e"})
|
|
5339
|
+
assert planned and not any(statement.startswith("\\") for statement in planned)
|
|
5340
|
+
|
|
5341
|
+
# 0031: `\if :{?stage}` / `\else` / `\set stage dev` / `\endif`, then
|
|
5342
|
+
# `:'stage'` inside the S3 snapshot call.
|
|
5343
|
+
text = (DRIZZLE_DIR / "0031_thread_cleanup_drops.sql").read_text(encoding="utf-8")
|
|
5344
|
+
with_stage = db._plan(text, "0031_thread_cleanup_drops.sql", {"stage": "tei-e2e"})
|
|
5345
|
+
assert any("'thinkwork-' || 'tei-e2e' || '-backups'" in statement for statement in with_stage)
|
|
5346
|
+
without_stage = db._plan(text, "0031_thread_cleanup_drops.sql", {})
|
|
5347
|
+
assert any("'thinkwork-' || 'dev' || '-backups'" in statement for statement in without_stage)
|
|
5348
|
+
|
|
5349
|
+
# Anything this lane does not implement is refused by name, before any
|
|
5350
|
+
# statement runs.
|
|
5351
|
+
with pytest.raises(runner.DatabaseCommandError) as refused:
|
|
5352
|
+
db._plan("SELECT 1;\n\\copy a FROM 'x.csv'\n", "0999_bad.sql", {})
|
|
5353
|
+
assert "unsupported psql meta-command in Data API mode: \\copy a FROM 'x.csv'" in str(
|
|
5354
|
+
refused.value
|
|
5355
|
+
)
|
|
5356
|
+
assert "0999_bad.sql" in str(refused.value)
|
|
5357
|
+
with pytest.raises(runner.DatabaseCommandError):
|
|
5358
|
+
db._plan("\\if 1 = 1\nSELECT 1;\n\\endif\n", "0998_bad.sql", {})
|
|
5359
|
+
|
|
5360
|
+
|
|
5361
|
+
def test_data_api_quit_ends_the_script_like_psql() -> None:
|
|
5362
|
+
"""0076 quits (exit 0 under psql) when computer_id is not passed — the
|
|
5363
|
+
runner's sweep passes only `stage`, so the file is a recorded no-op."""
|
|
5364
|
+
runner = load_runner()
|
|
5365
|
+
client = FakeRdsDataClient()
|
|
5366
|
+
db = data_api_db(runner, client)
|
|
5367
|
+
db.execute(
|
|
5368
|
+
file=DRIZZLE_DIR / "0076_scheduled_jobs_marco_backfill.sql", variables={"stage": "x"}
|
|
5369
|
+
)
|
|
5370
|
+
assert client.calls == []
|
|
5371
|
+
|
|
5372
|
+
|
|
5373
|
+
def test_data_api_scalar_renders_like_psql_tA() -> None:
|
|
5374
|
+
runner = load_runner()
|
|
5375
|
+
client = FakeRdsDataClient()
|
|
5376
|
+
db = data_api_db(runner, client)
|
|
5377
|
+
client.execute_statement = lambda **params: { # type: ignore[method-assign]
|
|
5378
|
+
"records": [
|
|
5379
|
+
[{"longValue": 3}, {"booleanValue": True}, {"isNull": True}, {"stringValue": "a"}],
|
|
5380
|
+
[
|
|
5381
|
+
{"doubleValue": 1.5},
|
|
5382
|
+
{"booleanValue": False},
|
|
5383
|
+
{"stringValue": ""},
|
|
5384
|
+
{"stringValue": "b"},
|
|
5385
|
+
],
|
|
5386
|
+
]
|
|
5387
|
+
}
|
|
5388
|
+
assert db.scalar("SELECT ...") == "3|t||a\n1.5|f||b"
|
|
5389
|
+
client.execute_statement = lambda **params: {"records": []} # type: ignore[method-assign]
|
|
5390
|
+
assert db.scalar("SELECT to_regclass('nothing')::text") == ""
|
|
5391
|
+
assert runner.psql_output(db, "SELECT 1") == ""
|
|
5392
|
+
|
|
5393
|
+
|
|
5394
|
+
def test_data_api_stops_at_the_first_error_and_rolls_back() -> None:
|
|
5395
|
+
runner = load_runner()
|
|
5396
|
+
client = FakeRdsDataClient(fail_on="SECOND")
|
|
5397
|
+
db = data_api_db(runner, client)
|
|
5398
|
+
with pytest.raises(subprocess.CalledProcessError) as raised:
|
|
5399
|
+
runner.psql(db, sql="SELECT 'first'; SELECT 'SECOND'; SELECT 'third';")
|
|
5400
|
+
assert isinstance(raised.value, runner.DatabaseCommandError)
|
|
5401
|
+
assert raised.value.returncode == 1
|
|
5402
|
+
assert "statement 2 (SELECT 'SECOND'): ERROR: boom" in str(raised.value)
|
|
5403
|
+
assert "inline sql" in raised.value.cmd
|
|
5404
|
+
assert [kind for kind, _tx, _sql in client.calls] == ["begin", "execute", "execute", "rollback"]
|
|
5405
|
+
assert not any("third" in sql for sql in client.executed())
|
|
5406
|
+
|
|
5407
|
+
|
|
5408
|
+
def test_data_api_wraps_scripts_in_one_transaction_and_maps_explicit_ones() -> None:
|
|
5409
|
+
"""Every script runs inside a Data API transaction so its statements
|
|
5410
|
+
serialise on one connection (a statement that outlives its 45 s call can
|
|
5411
|
+
never race the next). A script's own BEGIN/COMMIT map onto it, and
|
|
5412
|
+
CREATE INDEX CONCURRENTLY — which refuses a transaction block — commits
|
|
5413
|
+
the open one and runs standalone."""
|
|
5414
|
+
runner = load_runner()
|
|
5415
|
+
client = FakeRdsDataClient()
|
|
5416
|
+
db = data_api_db(runner, client)
|
|
5417
|
+
db.execute(
|
|
5418
|
+
sql=(
|
|
5419
|
+
"BEGIN;\nSET LOCAL lock_timeout = '5s';\nUPDATE a SET b = 1;\nCOMMIT;\n"
|
|
5420
|
+
"CREATE INDEX CONCURRENTLY IF NOT EXISTS i ON a (b);\n"
|
|
5421
|
+
"ALTER TABLE a VALIDATE CONSTRAINT c;\n"
|
|
5422
|
+
)
|
|
5423
|
+
)
|
|
5424
|
+
assert [(kind, tx) for kind, tx, _sql in client.calls] == [
|
|
5425
|
+
("begin", "tx1"),
|
|
5426
|
+
("execute", "tx1"),
|
|
5427
|
+
("execute", "tx1"),
|
|
5428
|
+
("commit", "tx1"),
|
|
5429
|
+
("execute", None),
|
|
5430
|
+
("begin", "tx2"),
|
|
5431
|
+
("execute", "tx2"),
|
|
5432
|
+
("commit", "tx2"),
|
|
5433
|
+
]
|
|
5434
|
+
ddl_call = client.calls[1]
|
|
5435
|
+
assert ddl_call[2].startswith("SET LOCAL")
|
|
5436
|
+
|
|
5437
|
+
|
|
5438
|
+
def test_data_api_ships_oversized_statements_through_a_temp_table() -> None:
|
|
5439
|
+
"""0230_analyst_rls carries an 85 KB DO block — over the Data API's
|
|
5440
|
+
64 KB per-statement limit. It is delivered in pieces to a temp table on
|
|
5441
|
+
the transaction's connection, reassembled and run there with EXECUTE."""
|
|
5442
|
+
runner = load_runner()
|
|
5443
|
+
client = FakeRdsDataClient()
|
|
5444
|
+
db = data_api_db(runner, client)
|
|
5445
|
+
db.execute(file=DRIZZLE_DIR / "0230_analyst_rls.sql", variables={"stage": "tei-e2e"})
|
|
5446
|
+
executed = client.executed()
|
|
5447
|
+
assert all(len(sql.encode("utf-8")) <= runner.RDS_DATA_API_MAX_SQL_BYTES for sql in executed)
|
|
5448
|
+
inserts = [
|
|
5449
|
+
sql for sql in executed if sql.startswith("INSERT INTO pg_temp.runner_oversized_statement")
|
|
5450
|
+
]
|
|
5451
|
+
assert len(inserts) == 2
|
|
5452
|
+
assembled = "".join(
|
|
5453
|
+
re.search(r"\$chunk\$(.*)\$chunk\$\)$", sql, re.S).group(1) for sql in inserts
|
|
5454
|
+
)
|
|
5455
|
+
assert assembled.lstrip().startswith("--") and "END $$" in assembled
|
|
5456
|
+
assert any(sql.startswith("DO $runner$ BEGIN EXECUTE") for sql in executed)
|
|
5457
|
+
assert executed[-1] == "DROP TABLE pg_temp.runner_oversized_statement"
|
|
5458
|
+
assert len({tx for kind, tx, _ in client.calls if kind == "execute"}) == 1 # one transaction
|
|
5459
|
+
assert [kind for kind, _tx, _sql in client.calls][-1] == "commit"
|
|
5460
|
+
|
|
5461
|
+
|
|
5462
|
+
def test_write_runner_files_threads_db_access_as_a_runner_only_flag(
|
|
5463
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
5464
|
+
) -> None:
|
|
5465
|
+
"""THINK-1082: dbAccess rides the runner-secrets / payload precedence
|
|
5466
|
+
like apiLambdasInVpc, defaults to psql, and is runner-only: it never
|
|
5467
|
+
lands in terraform.auto.tfvars.json or the generated root."""
|
|
5468
|
+
runner = load_runner()
|
|
5469
|
+
tf_dir = _cognito_email_runner_env(runner, tmp_path, monkeypatch)
|
|
5470
|
+
base_payload = {
|
|
5471
|
+
"stage": "tei-e2e",
|
|
5472
|
+
"awsRegion": "us-east-1",
|
|
5473
|
+
"awsAccountId": "637423202447",
|
|
5474
|
+
"dbPassword": "db-secret",
|
|
5475
|
+
"apiAuthSecret": "api-secret",
|
|
5476
|
+
}
|
|
5477
|
+
vars_json_default = runner.write_runner_files(dict(base_payload), {})
|
|
5478
|
+
assert vars_json_default["db_access"] == "psql"
|
|
5479
|
+
tfvars = json.loads((tf_dir / "terraform.auto.tfvars.json").read_text(encoding="utf-8"))
|
|
5480
|
+
assert "db_access" not in tfvars
|
|
5481
|
+
main_tf = (tf_dir / "main.tf").read_text(encoding="utf-8")
|
|
5482
|
+
assert "db_access" not in main_tf
|
|
5483
|
+
assert 'output "db_cluster_arn" { value = module.thinkwork.db_cluster_arn }' in main_tf
|
|
5484
|
+
|
|
5485
|
+
vars_json_payload = runner.write_runner_files({**base_payload, "dbAccess": "data-api"}, {})
|
|
5486
|
+
assert vars_json_payload["db_access"] == "data-api"
|
|
5487
|
+
vars_json_secret = runner.write_runner_files(
|
|
5488
|
+
{**base_payload, "dbAccess": "psql"}, {"dbAccess": "Data-API"}
|
|
5489
|
+
)
|
|
5490
|
+
assert vars_json_secret["db_access"] == "data-api"
|
|
5491
|
+
with pytest.raises(RuntimeError, match="dbAccess must be one of psql, data-api"):
|
|
5492
|
+
runner.write_runner_files({**base_payload, "dbAccess": "tunnel"}, {})
|
|
5493
|
+
|
|
5494
|
+
assert runner.set_database_access_mode(vars_json_payload) == "data-api"
|
|
5495
|
+
assert runner.DATABASE_ACCESS_MODE == "data-api"
|
|
5496
|
+
assert runner.set_database_access_mode({}) == "psql"
|
|
5497
|
+
|
|
5498
|
+
|
|
5499
|
+
def test_push_database_schema_over_the_data_api_applies_and_records_pending_files(
|
|
5500
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
5501
|
+
) -> None:
|
|
5502
|
+
"""With dbAccess=data-api the whole schema step — probes, ledger, the
|
|
5503
|
+
pending files and their ledger rows — runs through the fake rds-data
|
|
5504
|
+
client, in order, with each file recorded only after its statements."""
|
|
5505
|
+
runner = load_runner()
|
|
5506
|
+
client = FakeRdsDataClient(ledger={"0001_init.sql", "0155_tenant_model_catalog.sql"})
|
|
5507
|
+
source_dir = tmp_path / "source"
|
|
5508
|
+
write_drizzle_files(
|
|
5509
|
+
source_dir,
|
|
5510
|
+
["0001_init.sql", "0002_add_a.sql", "0003_add_b.sql", "0155_tenant_model_catalog.sql"],
|
|
5511
|
+
)
|
|
5512
|
+
drizzle = source_dir / "packages/database-pg/drizzle"
|
|
5513
|
+
(drizzle / "0002_add_a.sql").write_text(
|
|
5514
|
+
"\\set ON_ERROR_STOP on\nBEGIN;\nCREATE TABLE a (id int);\n"
|
|
5515
|
+
"SELECT set_config('thinkwork.stage', :'stage', true);\nCOMMIT;\n",
|
|
5516
|
+
encoding="utf-8",
|
|
5517
|
+
)
|
|
5518
|
+
(drizzle / "0003_add_b.sql").write_text(
|
|
5519
|
+
"CREATE TABLE b (id int);\n--> statement-breakpoint\nCREATE INDEX b_id ON b (id);\n",
|
|
5520
|
+
encoding="utf-8",
|
|
5521
|
+
)
|
|
5522
|
+
outputs_path = tmp_path / "outputs.json"
|
|
5523
|
+
outputs_path.write_text(
|
|
5524
|
+
json.dumps(
|
|
5525
|
+
{
|
|
5526
|
+
"db_cluster_arn": {
|
|
5527
|
+
"value": "arn:aws:rds:us-east-1:637423202447:cluster:thinkwork-tei-e2e-db"
|
|
5528
|
+
},
|
|
5529
|
+
"db_cluster_endpoint": {
|
|
5530
|
+
"value": "thinkwork-tei-e2e-db.cluster-x.rds.amazonaws.com"
|
|
5531
|
+
},
|
|
5532
|
+
"db_secret_arn": {"value": "arn:aws:secretsmanager:us-east-1:1:secret:db"},
|
|
5533
|
+
"database_name": {"value": "thinkwork"},
|
|
5534
|
+
}
|
|
5535
|
+
),
|
|
5536
|
+
encoding="utf-8",
|
|
5537
|
+
)
|
|
5538
|
+
monkeypatch.setattr(runner, "SOURCE", source_dir)
|
|
5539
|
+
monkeypatch.setenv("THINKWORK_TERRAFORM_MODULE_SOURCE", "thinkwork-ai/thinkwork/aws")
|
|
5540
|
+
monkeypatch.setattr(runner, "checkout_source", lambda *_args: None)
|
|
5541
|
+
monkeypatch.setattr(runner, "rds_data_client", lambda _region: client)
|
|
5542
|
+
monkeypatch.setattr(
|
|
5543
|
+
runner,
|
|
5544
|
+
"seed_platform_bootstrap_defaults",
|
|
5545
|
+
lambda _db: client.calls.append(("seed", None, "")),
|
|
5546
|
+
)
|
|
5547
|
+
runner.set_database_access_mode({"db_access": "data-api"})
|
|
5548
|
+
|
|
5549
|
+
runner.push_database_schema(outputs_path, {"stage": "tei-e2e"})
|
|
5550
|
+
|
|
5551
|
+
executed = client.executed()
|
|
5552
|
+
assert executed[0] == "SELECT to_regclass('public.tenants')::text"
|
|
5553
|
+
assert "CREATE TABLE IF NOT EXISTS public.platform_schema_migrations" in executed[2]
|
|
5554
|
+
order = [
|
|
5555
|
+
next(i for i, sql in enumerate(executed) if sql == "CREATE TABLE a (id int)"),
|
|
5556
|
+
next(
|
|
5557
|
+
i for i, sql in enumerate(executed) if "set_config('thinkwork.stage', 'tei-e2e'" in sql
|
|
5558
|
+
),
|
|
5559
|
+
next(i for i, sql in enumerate(executed) if "('0002_add_a.sql', 'runner')" in sql),
|
|
5560
|
+
next(i for i, sql in enumerate(executed) if sql == "CREATE TABLE b (id int)"),
|
|
5561
|
+
next(i for i, sql in enumerate(executed) if sql == "CREATE INDEX b_id ON b (id)"),
|
|
5562
|
+
next(i for i, sql in enumerate(executed) if "('0003_add_b.sql', 'runner')" in sql),
|
|
5563
|
+
]
|
|
5564
|
+
assert order == sorted(order)
|
|
5565
|
+
assert not any("('0001_init.sql'" in sql for sql in executed)
|
|
5566
|
+
assert client.ledger == {
|
|
5567
|
+
"0001_init.sql",
|
|
5568
|
+
"0002_add_a.sql",
|
|
5569
|
+
"0003_add_b.sql",
|
|
5570
|
+
"0155_tenant_model_catalog.sql",
|
|
5571
|
+
}
|
|
5572
|
+
kinds = [kind for kind, _tx, _sql in client.calls]
|
|
5573
|
+
assert kinds.count("begin") == kinds.count("commit") and "rollback" not in kinds
|
|
5574
|
+
assert kinds.index("seed") > order[-1]
|
|
5575
|
+
|
|
5576
|
+
# A failing file is never recorded: the error surfaces as the
|
|
5577
|
+
# CalledProcessError callers already handle, after a rollback.
|
|
5578
|
+
failing = FakeRdsDataClient(
|
|
5579
|
+
ledger={"0001_init.sql", "0002_add_a.sql", "0155_tenant_model_catalog.sql"},
|
|
5580
|
+
fail_on="CREATE INDEX b_id",
|
|
5581
|
+
)
|
|
5582
|
+
monkeypatch.setattr(runner, "rds_data_client", lambda _region: failing)
|
|
5583
|
+
with pytest.raises(subprocess.CalledProcessError) as raised:
|
|
5584
|
+
runner.push_database_schema(outputs_path, {"stage": "tei-e2e"})
|
|
5585
|
+
assert "0003_add_b.sql" in raised.value.cmd
|
|
5586
|
+
assert failing.ledger == {"0001_init.sql", "0002_add_a.sql", "0155_tenant_model_catalog.sql"}
|
|
5587
|
+
assert [kind for kind, _tx, _sql in failing.calls][-1] == "rollback"
|
|
5588
|
+
|
|
5589
|
+
|
|
5590
|
+
def test_data_api_database_from_outputs_derives_the_cluster_arn_when_missing(
|
|
5591
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
5592
|
+
) -> None:
|
|
5593
|
+
runner = load_runner()
|
|
5594
|
+
monkeypatch.setattr(runner, "output", lambda _args, **_kw: "637423202447")
|
|
5595
|
+
db = runner.DataApiDatabase.from_outputs(
|
|
5596
|
+
{
|
|
5597
|
+
"db_cluster_endpoint": {
|
|
5598
|
+
"value": "thinkwork-tei-e2e-db.cluster-abc.us-east-1.rds.amazonaws.com"
|
|
5599
|
+
},
|
|
5600
|
+
"db_secret_arn": {"value": "arn:aws:secretsmanager:us-east-1:637423202447:secret:db"},
|
|
5601
|
+
}
|
|
5602
|
+
)
|
|
5603
|
+
assert db.cluster_arn == "arn:aws:rds:us-east-1:637423202447:cluster:thinkwork-tei-e2e-db"
|
|
5604
|
+
assert db.database_name == "thinkwork"
|
|
5605
|
+
assert "secret" not in repr(db).lower() or "arn:aws:secretsmanager" not in repr(db)
|
|
5606
|
+
runner.set_database_access_mode({"db_access": "data-api"})
|
|
5607
|
+
handle = runner.database_url_from_outputs(
|
|
5608
|
+
{
|
|
5609
|
+
"db_cluster_arn": {"value": db.cluster_arn},
|
|
5610
|
+
"db_secret_arn": {"value": db.secret_arn},
|
|
5611
|
+
}
|
|
5612
|
+
)
|
|
5613
|
+
assert isinstance(handle, runner.DataApiDatabase)
|