thinkwork-cli 0.13.2 → 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-JYZXYQJR.js → api-client-4P4JZOBX.js} +1 -1
- package/dist/{chunk-Z2F6RJFR.js → chunk-4OMDWYZQ.js} +4 -5
- package/dist/{chunk-ILZKG5WV.js → chunk-AD2NIWB5.js} +434 -69
- package/dist/chunk-MWQ7RO76.js +137 -0
- package/dist/{chunk-DVPBI5HM.js → chunk-OLUQ4U2Z.js} +26 -26
- package/dist/{chunk-QOERT44A.js → chunk-YQCYD6NE.js} +57 -44
- package/dist/cli.js +4400 -16431
- 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-CRV46D5B.js → dist-es-2OTNTL4N.js} +4 -4
- package/dist/{dist-es-ENBUC3JP.js → dist-es-2XVAH4BQ.js} +2 -2
- package/dist/{dist-es-DCY4IC3Y.js → dist-es-3DPDUJAH.js} +2 -2
- package/dist/{dist-es-G7PTG75M.js → dist-es-BNZTBBVC.js} +1 -1
- package/dist/{dist-es-GKDBE7E6.js → dist-es-CD2RBDAC.js} +1 -1
- package/dist/{dist-es-R7FGL2IB.js → dist-es-I4FW57PS.js} +1 -1
- package/dist/{dist-es-USORBXZ5.js → dist-es-KVFWJNDY.js} +4 -4
- package/dist/{dist-es-HY3535OI.js → dist-es-PS73DWNL.js} +2 -2
- package/dist/{dist-es-LDE437AM.js → dist-es-S3NZQFGT.js} +1 -1
- package/dist/{dist-es-G54LBMRN.js → dist-es-SGCA4P32.js} +1 -1
- package/dist/{dist-es-Q57SZXZ7.js → dist-es-WBM6J4RA.js} +2 -2
- package/dist/{dist-es-P26FWFLT.js → dist-es-ZGVADYIF.js} +8 -8
- package/dist/drizzle/0227_analyst_reader_role.sql +17 -26
- package/dist/drizzle/0230_analyst_rls.sql +28 -45
- package/dist/drizzle/0261_native_auth_control_plane.sql +7 -3
- 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/{loadSso-DJVB7BXE.js → loadSso-ETCXTR4I.js} +2 -2
- package/dist/{loadSso-JAMNA3WY.js → loadSso-UBD7KUHE.js} +2 -2
- package/dist/plugins/README.md +2 -2
- package/dist/plugins/catalog/package.json +0 -3
- package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +6 -3
- package/dist/plugins/catalog/src/__tests__/catalog.test.ts +21 -21
- package/dist/plugins/catalog/src/__tests__/contracts.test.ts +102 -25
- package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +7 -6
- package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -15
- package/dist/plugins/catalog/src/contracts.ts +15 -0
- package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -18
- 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-UGDWTGS4.js → signin-5QZNRSOO.js} +2 -2
- package/dist/{sso-oidc-Q4VXANXY.js → sso-oidc-XK673P54.js} +2 -2
- package/dist/{sts-CWLSE3GH.js → sts-OXI2UALF.js} +6 -134
- package/dist/{sts-DNZLNVOV.js → sts-SQW4NBK3.js} +6 -134
- package/dist/terraform/examples/greenfield/main.tf +233 -175
- package/dist/terraform/examples/greenfield/terraform.tfvars.example +12 -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 +106 -26
- package/dist/terraform/modules/app/agentcore-identity/outputs.tf +6 -6
- package/dist/terraform/modules/app/agentcore-identity/scripts/bootstrap_twenty_oauth_client.sh +216 -99
- package/dist/terraform/modules/app/agentcore-identity/scripts/delete_identity.sh +6 -9
- 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 +22 -7
- package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_identity.sh +17 -46
- package/dist/terraform/modules/app/agentcore-identity/scripts/reconcile_twenty_provider.mjs +65 -3
- 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 +6 -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/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 +2641 -124
- package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +2034 -154
- 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 +411 -723
- package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +262 -198
- package/dist/terraform/modules/app/lambda-api/main.tf +51 -13
- package/dist/terraform/modules/app/lambda-api/mcp-oauth.tf +4 -181
- 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 -45
- package/dist/terraform/modules/app/lambda-api/turn-assertion.tf +45 -0
- package/dist/terraform/modules/app/lambda-api/variables.tf +219 -126
- 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 +81 -8
- package/dist/terraform/modules/foundation/cognito/outputs.tf +5 -0
- package/dist/terraform/modules/foundation/cognito/variables.tf +24 -12
- package/dist/terraform/modules/thinkwork/README.md +5 -1
- package/dist/terraform/modules/thinkwork/main.tf +376 -374
- package/dist/terraform/modules/thinkwork/outputs.tf +12 -158
- package/dist/terraform/modules/thinkwork/variables.tf +246 -89
- 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/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 +1 -1
- package/dist/drizzle/0261_managed_harness_multiplayer.sql +0 -313
- package/dist/drizzle/0262_parallel_harness_threads.sql +0 -9
- package/dist/drizzle/0264_harness_tool_execution_ledger.sql +0 -113
- 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/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-gateway/main.tf +0 -305
- package/dist/terraform/modules/app/agentcore-gateway/outputs.tf +0 -44
- package/dist/terraform/modules/app/agentcore-gateway/scripts/delete_gateway.sh +0 -93
- package/dist/terraform/modules/app/agentcore-gateway/scripts/read_gateway.sh +0 -33
- package/dist/terraform/modules/app/agentcore-gateway/scripts/reconcile_gateway.sh +0 -543
- package/dist/terraform/modules/app/agentcore-gateway/variables.tf +0 -70
- package/dist/terraform/modules/app/agentcore-harness/main.tf +0 -330
- package/dist/terraform/modules/app/agentcore-harness/outputs.tf +0 -72
- package/dist/terraform/modules/app/agentcore-harness/scripts/delete_harness.sh +0 -5
- package/dist/terraform/modules/app/agentcore-harness/scripts/harness-lifecycle.mjs +0 -430
- package/dist/terraform/modules/app/agentcore-harness/scripts/harness-readback.mjs +0 -180
- package/dist/terraform/modules/app/agentcore-harness/scripts/harness-tool-contract.mjs +0 -193
- package/dist/terraform/modules/app/agentcore-harness/scripts/prune_harness.sh +0 -5
- package/dist/terraform/modules/app/agentcore-harness/scripts/read_harness.sh +0 -5
- package/dist/terraform/modules/app/agentcore-harness/scripts/reconcile_harness.sh +0 -5
- package/dist/terraform/modules/app/agentcore-harness/variables.tf +0 -96
- 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/app/lambda-api/agentcore-harness-alarms.tf +0 -88
- package/dist/terraform/modules/app/lambda-api/agentcore-identity-alarms.tf +0 -91
- 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
|
@@ -142,6 +142,269 @@ def test_customer_update_accepts_customer_ecr_agentcore_pi_source_image() -> Non
|
|
|
142
142
|
)
|
|
143
143
|
|
|
144
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
|
+
|
|
145
408
|
def test_native_auth_reconciliation_builds_route_specific_secret_free_manifest(
|
|
146
409
|
monkeypatch: pytest.MonkeyPatch,
|
|
147
410
|
) -> None:
|
|
@@ -226,6 +489,54 @@ def test_native_auth_reconciliation_builds_route_specific_secret_free_manifest(
|
|
|
226
489
|
assert replay_state == state
|
|
227
490
|
|
|
228
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
|
+
|
|
229
540
|
def tenant_entra_operation(action: str, revision: int = 1) -> dict:
|
|
230
541
|
tenant_id = "00000000-0000-4000-8000-000000000123"
|
|
231
542
|
payload = {
|
|
@@ -539,6 +850,214 @@ def test_sync_release_artifacts_stages_artifacts_from_platform_bundle(
|
|
|
539
850
|
assert {artifact["source"] for artifact in runner.RELEASE_EVIDENCE["artifacts"]} == {"bundle"}
|
|
540
851
|
|
|
541
852
|
|
|
853
|
+
def test_packaged_agentcore_control_runtime_is_exact_and_runnable(
|
|
854
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
855
|
+
) -> None:
|
|
856
|
+
runner = load_runner()
|
|
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
|
+
)
|
|
870
|
+
|
|
871
|
+
monkeypatch.setattr(runner, "RELEASE", release_dir)
|
|
872
|
+
monkeypatch.setattr(runner, "RELEASE_EVIDENCE", {})
|
|
873
|
+
monkeypatch.delenv("THINKWORK_AGENTCORE_CONTROL_RUNTIME_DIR", raising=False)
|
|
874
|
+
|
|
875
|
+
evidence = runner.prepare_agentcore_control_runtime()
|
|
876
|
+
|
|
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"])
|
|
892
|
+
|
|
893
|
+
|
|
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()
|
|
897
|
+
|
|
898
|
+
assert runner.validate_agentcore_node_runtime(version).startswith("v")
|
|
899
|
+
|
|
900
|
+
|
|
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,
|
|
912
|
+
) -> None:
|
|
913
|
+
runner = load_runner()
|
|
914
|
+
|
|
915
|
+
assert runner.requires_agentcore_control_runtime(
|
|
916
|
+
action,
|
|
917
|
+
{"enableAgentCoreHarness": False},
|
|
918
|
+
None,
|
|
919
|
+
)
|
|
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
|
+
|
|
542
1061
|
def test_ensure_release_manifest_available_accepts_manifest_file_digest(
|
|
543
1062
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
544
1063
|
) -> None:
|
|
@@ -576,13 +1095,13 @@ def test_sync_release_artifacts_can_materialize_only_web_static_bundle(
|
|
|
576
1095
|
bundle_path = tmp_path / "platform-artifacts.tar.gz"
|
|
577
1096
|
lambda_bytes = b"lambda-zip"
|
|
578
1097
|
web_bytes = b"web-tarball"
|
|
579
|
-
|
|
1098
|
+
extra_bytes = b"extra-tarball"
|
|
580
1099
|
write_tar(
|
|
581
1100
|
bundle_path,
|
|
582
1101
|
{
|
|
583
1102
|
"lambdas/graphql-http.zip": lambda_bytes,
|
|
584
1103
|
"static/web.tar.gz": web_bytes,
|
|
585
|
-
"static/
|
|
1104
|
+
"static/extra.tar.gz": extra_bytes,
|
|
586
1105
|
},
|
|
587
1106
|
)
|
|
588
1107
|
artifacts = [
|
|
@@ -605,13 +1124,13 @@ def test_sync_release_artifacts_can_materialize_only_web_static_bundle(
|
|
|
605
1124
|
"sizeBytes": len(web_bytes),
|
|
606
1125
|
},
|
|
607
1126
|
{
|
|
608
|
-
"name": "
|
|
1127
|
+
"name": "extra",
|
|
609
1128
|
"type": "static-site",
|
|
610
|
-
"fileName": "
|
|
611
|
-
"relativePath": "static/
|
|
1129
|
+
"fileName": "extra.tar.gz",
|
|
1130
|
+
"relativePath": "static/extra.tar.gz",
|
|
612
1131
|
"url": None,
|
|
613
|
-
"sha256": digest(
|
|
614
|
-
"sizeBytes": len(
|
|
1132
|
+
"sha256": digest(extra_bytes),
|
|
1133
|
+
"sizeBytes": len(extra_bytes),
|
|
615
1134
|
},
|
|
616
1135
|
]
|
|
617
1136
|
manifest_sha = write_manifest(
|
|
@@ -629,10 +1148,12 @@ def test_sync_release_artifacts_can_materialize_only_web_static_bundle(
|
|
|
629
1148
|
monkeypatch.setenv("THINKWORK_RELEASE_ARTIFACT_BUCKET", "thinkwork-artifacts")
|
|
630
1149
|
monkeypatch.setattr(runner, "run", lambda args, **_kwargs: calls.append(args))
|
|
631
1150
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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)
|
|
636
1157
|
|
|
637
1158
|
assert static_files == {"web": release_dir / "static/web.tar.gz"}
|
|
638
1159
|
assert static_files["web"].read_bytes() == web_bytes
|
|
@@ -1467,6 +1988,13 @@ def test_write_runner_files_persists_selected_release_to_controller_module(
|
|
|
1467
1988
|
assert "deployment_release_manifest_signature_url" in main_tf
|
|
1468
1989
|
assert "deployment_release_manifest_trusted_keys_json" in main_tf
|
|
1469
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
|
|
1470
1998
|
|
|
1471
1999
|
|
|
1472
2000
|
def _cognito_email_runner_env(runner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
@@ -1554,17 +2082,11 @@ def test_write_runner_files_threads_cognito_email_vars_from_payload(
|
|
|
1554
2082
|
assert f"= var.{name}" in main_tf
|
|
1555
2083
|
|
|
1556
2084
|
|
|
1557
|
-
def
|
|
2085
|
+
def test_write_runner_files_threads_agentcore_harness_config_from_preserved_payload(
|
|
1558
2086
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1559
2087
|
) -> None:
|
|
1560
2088
|
runner = load_runner()
|
|
1561
2089
|
tf_dir = _cognito_email_runner_env(runner, tmp_path, monkeypatch)
|
|
1562
|
-
monkeypatch.setenv(
|
|
1563
|
-
"THINKWORK_TERRAFORM_MODULE_SOURCE",
|
|
1564
|
-
"git::https://github.com/thinkwork-ai/thinkwork.git"
|
|
1565
|
-
"//terraform/modules/thinkwork?ref=c66a3aa7f3a5606c66b920b40b39af57a7cc06d0",
|
|
1566
|
-
)
|
|
1567
|
-
monkeypatch.setenv("THINKWORK_TERRAFORM_MODULE_VERSION", "0.1.0-canary.150")
|
|
1568
2090
|
|
|
1569
2091
|
vars_json = runner.write_runner_files(
|
|
1570
2092
|
{
|
|
@@ -1573,25 +2095,69 @@ def test_write_runner_files_repins_stale_thinkwork_git_module_source_to_release(
|
|
|
1573
2095
|
"awsAccountId": "637423202447",
|
|
1574
2096
|
"dbPassword": "db-secret",
|
|
1575
2097
|
"apiAuthSecret": "api-secret",
|
|
2098
|
+
"preservedConfig": {
|
|
2099
|
+
"enableAgentCoreHarness": True,
|
|
2100
|
+
"agentCoreHarnessTenantSlug": "tei",
|
|
2101
|
+
"agentCoreHarnessOwnerAllowlist": "owner-a,owner-b",
|
|
2102
|
+
},
|
|
1576
2103
|
},
|
|
1577
2104
|
{},
|
|
1578
2105
|
)
|
|
1579
2106
|
|
|
1580
|
-
|
|
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"
|
|
1581
2110
|
|
|
1582
|
-
|
|
1583
|
-
assert "
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
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
|
|
1588
2121
|
|
|
1589
2122
|
|
|
1590
|
-
def
|
|
2123
|
+
def test_write_runner_files_repins_stale_thinkwork_git_module_source_to_release(
|
|
1591
2124
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1592
2125
|
) -> None:
|
|
1593
2126
|
runner = load_runner()
|
|
1594
|
-
_cognito_email_runner_env(runner, tmp_path, monkeypatch)
|
|
2127
|
+
tf_dir = _cognito_email_runner_env(runner, tmp_path, monkeypatch)
|
|
2128
|
+
monkeypatch.setenv(
|
|
2129
|
+
"THINKWORK_TERRAFORM_MODULE_SOURCE",
|
|
2130
|
+
"git::https://github.com/thinkwork-ai/thinkwork.git"
|
|
2131
|
+
"//terraform/modules/thinkwork?ref=c66a3aa7f3a5606c66b920b40b39af57a7cc06d0",
|
|
2132
|
+
)
|
|
2133
|
+
monkeypatch.setenv("THINKWORK_TERRAFORM_MODULE_VERSION", "0.1.0-canary.150")
|
|
2134
|
+
|
|
2135
|
+
vars_json = runner.write_runner_files(
|
|
2136
|
+
{
|
|
2137
|
+
"stage": "tei-e2e",
|
|
2138
|
+
"awsRegion": "us-east-1",
|
|
2139
|
+
"awsAccountId": "637423202447",
|
|
2140
|
+
"dbPassword": "db-secret",
|
|
2141
|
+
"apiAuthSecret": "api-secret",
|
|
2142
|
+
},
|
|
2143
|
+
{},
|
|
2144
|
+
)
|
|
2145
|
+
|
|
2146
|
+
main_tf = (tf_dir / "main.tf").read_text(encoding="utf-8")
|
|
2147
|
+
|
|
2148
|
+
assert "ref=abc123" in main_tf
|
|
2149
|
+
assert "ref=c66a3aa7f3a5606c66b920b40b39af57a7cc06d0" not in main_tf
|
|
2150
|
+
assert vars_json["deployment_terraform_module_source"].endswith(
|
|
2151
|
+
"//terraform/modules/thinkwork?ref=abc123"
|
|
2152
|
+
)
|
|
2153
|
+
assert vars_json["deployment_terraform_module_version"] == ""
|
|
2154
|
+
|
|
2155
|
+
|
|
2156
|
+
def test_write_runner_files_cognito_email_vars_prefer_runner_secrets_and_default_empty(
|
|
2157
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
2158
|
+
) -> None:
|
|
2159
|
+
runner = load_runner()
|
|
2160
|
+
_cognito_email_runner_env(runner, tmp_path, monkeypatch)
|
|
1595
2161
|
secret_arn = "arn:aws:ses:us-east-1:637423202447:identity/secret.example.com"
|
|
1596
2162
|
|
|
1597
2163
|
vars_json = runner.write_runner_files(
|
|
@@ -1649,6 +2215,54 @@ def test_write_runner_files_cognito_email_vars_prefer_runner_secrets_and_default
|
|
|
1649
2215
|
assert vars_json_retired["finalize_auth_retirement"] is True
|
|
1650
2216
|
|
|
1651
2217
|
|
|
2218
|
+
def test_write_runner_files_threads_additional_admin_callback_urls(
|
|
2219
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
2220
|
+
) -> None:
|
|
2221
|
+
runner = load_runner()
|
|
2222
|
+
_cognito_email_runner_env(runner, tmp_path, monkeypatch)
|
|
2223
|
+
|
|
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
|
|
2250
|
+
|
|
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"] == []
|
|
2264
|
+
|
|
2265
|
+
|
|
1652
2266
|
def test_write_runner_files_defaults_existing_preauth_stack_to_coexistence(
|
|
1653
2267
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1654
2268
|
) -> None:
|
|
@@ -1733,49 +2347,13 @@ def test_main_reconciles_native_auth_before_destructive_schema() -> None:
|
|
|
1733
2347
|
source = Path(__file__).with_name("runner.py").read_text(encoding="utf-8")
|
|
1734
2348
|
post_apply = source[source.index("def main():") :]
|
|
1735
2349
|
reconciliation = post_apply.index(
|
|
1736
|
-
|
|
2350
|
+
'CONTROLLER_EVIDENCE["authReconciliation"] = reconcile_native_auth_metadata'
|
|
1737
2351
|
)
|
|
1738
2352
|
schema = post_apply.index("push_database_schema(outputs_path, vars_json)", reconciliation)
|
|
1739
2353
|
|
|
1740
2354
|
assert reconciliation < schema
|
|
1741
2355
|
|
|
1742
2356
|
|
|
1743
|
-
def test_write_runner_files_wires_analyst_lambda_vpc_egress(
|
|
1744
|
-
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1745
|
-
) -> None:
|
|
1746
|
-
"""analyst_lambda_vpc_egress needs all three wiring points (vars_json,
|
|
1747
|
-
root variable, module argument) and reads runner-secrets first — the
|
|
1748
|
-
durable per-environment home for the flag — with a payload fallback.
|
|
1749
|
-
Secrets Manager JSON strings ("true") must land as real booleans."""
|
|
1750
|
-
runner = load_runner()
|
|
1751
|
-
tf_dir = _cognito_email_runner_env(runner, tmp_path, monkeypatch)
|
|
1752
|
-
base_payload = {
|
|
1753
|
-
"stage": "tei-e2e",
|
|
1754
|
-
"awsRegion": "us-east-1",
|
|
1755
|
-
"awsAccountId": "637423202447",
|
|
1756
|
-
"dbPassword": "db-secret",
|
|
1757
|
-
"apiAuthSecret": "api-secret",
|
|
1758
|
-
}
|
|
1759
|
-
|
|
1760
|
-
assert runner.write_runner_files(base_payload, {})["analyst_lambda_vpc_egress"] is False
|
|
1761
|
-
assert (
|
|
1762
|
-
runner.write_runner_files(base_payload, {"analystLambdaVpcEgress": "true"})[
|
|
1763
|
-
"analyst_lambda_vpc_egress"
|
|
1764
|
-
]
|
|
1765
|
-
is True
|
|
1766
|
-
)
|
|
1767
|
-
assert (
|
|
1768
|
-
runner.write_runner_files({**base_payload, "analystLambdaVpcEgress": True}, {})[
|
|
1769
|
-
"analyst_lambda_vpc_egress"
|
|
1770
|
-
]
|
|
1771
|
-
is True
|
|
1772
|
-
)
|
|
1773
|
-
|
|
1774
|
-
main_tf = (tf_dir / "main.tf").read_text(encoding="utf-8")
|
|
1775
|
-
assert 'variable "analyst_lambda_vpc_egress"' in main_tf
|
|
1776
|
-
assert "= var.analyst_lambda_vpc_egress" in main_tf
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
2357
|
def test_write_runner_files_threads_customer_domain_vars_from_payload(
|
|
1780
2358
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
1781
2359
|
) -> None:
|
|
@@ -3492,140 +4070,877 @@ def test_write_controller_release_selection_to_ssm_skips_empty_git_module_versio
|
|
|
3492
4070
|
assert "/thinkwork/dev/deployment/terraform-module-version" not in written_names
|
|
3493
4071
|
|
|
3494
4072
|
|
|
3495
|
-
def
|
|
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:
|
|
3496
4107
|
runner = load_runner()
|
|
4108
|
+
payload = _approval_payload("plan", plan=True, apply=False)
|
|
3497
4109
|
|
|
3498
|
-
|
|
3499
|
-
{
|
|
3500
|
-
"format_version": "1.2",
|
|
3501
|
-
"terraform_version": "1.8.0",
|
|
3502
|
-
"resource_changes": [
|
|
3503
|
-
{"change": {"actions": ["create"]}},
|
|
3504
|
-
{"change": {"actions": ["update"]}},
|
|
3505
|
-
{"change": {"actions": ["delete", "create"]}},
|
|
3506
|
-
{"change": {"actions": ["create"]}},
|
|
3507
|
-
],
|
|
3508
|
-
}
|
|
3509
|
-
)
|
|
4110
|
+
assert runner.validate_terraform_execution_phase(payload, "plan") == "plan"
|
|
3510
4111
|
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
"
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
},
|
|
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",
|
|
3520
4120
|
}
|
|
3521
4121
|
|
|
4122
|
+
assert runner.validate_terraform_execution_phase(payload, "plan") == "plan"
|
|
3522
4123
|
|
|
3523
|
-
|
|
3524
|
-
|
|
4124
|
+
|
|
4125
|
+
def test_explicit_plan_execution_never_invokes_terraform_apply(
|
|
4126
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
3525
4127
|
) -> None:
|
|
3526
4128
|
runner = load_runner()
|
|
3527
|
-
source_dir = tmp_path / "source"
|
|
3528
|
-
script = source_dir / "terraform/modules/app/deployment-control-plane/runner.py"
|
|
3529
|
-
script.parent.mkdir(parents=True)
|
|
3530
|
-
script.write_text("# release runner\n", encoding="utf-8")
|
|
3531
|
-
|
|
3532
|
-
monkeypatch.setattr(runner, "SOURCE", source_dir)
|
|
3533
|
-
monkeypatch.setenv(
|
|
3534
|
-
"THINKWORK_RUNNER_SCRIPT_S3_URI",
|
|
3535
|
-
"s3://evidence-bucket/runner/thinkwork-runner.py",
|
|
3536
|
-
)
|
|
3537
4129
|
calls: list[list[str]] = []
|
|
3538
|
-
monkeypatch.setattr(
|
|
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
|
+
)
|
|
3539
4140
|
|
|
3540
|
-
runner.
|
|
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
|
+
)
|
|
3541
4148
|
|
|
3542
|
-
assert
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
"s3",
|
|
3546
|
-
"cp",
|
|
3547
|
-
str(script),
|
|
3548
|
-
"s3://evidence-bucket/runner/thinkwork-runner.py",
|
|
3549
|
-
]
|
|
3550
|
-
]
|
|
4149
|
+
assert result.returncode == 0
|
|
4150
|
+
assert calls == [["terraform", "plan", "-out=tfplan", "-no-color"]]
|
|
4151
|
+
assert runner.TERRAFORM_EVIDENCE["plan"]["status"] == "awaiting-approval"
|
|
3551
4152
|
|
|
3552
4153
|
|
|
3553
|
-
def
|
|
3554
|
-
|
|
4154
|
+
def test_legacy_execution_still_applies_its_successful_plan(
|
|
4155
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
3555
4156
|
) -> None:
|
|
3556
4157
|
runner = load_runner()
|
|
3557
|
-
monkeypatch.setattr(runner, "SOURCE", tmp_path / "missing-source")
|
|
3558
|
-
monkeypatch.setenv(
|
|
3559
|
-
"THINKWORK_RUNNER_SCRIPT_S3_URI",
|
|
3560
|
-
"s3://evidence-bucket/runner/thinkwork-runner.py",
|
|
3561
|
-
)
|
|
3562
4158
|
calls: list[list[str]] = []
|
|
3563
|
-
monkeypatch.setattr(
|
|
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
|
+
)
|
|
3564
4169
|
|
|
3565
|
-
runner.
|
|
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
|
+
)
|
|
3566
4177
|
|
|
3567
|
-
assert calls == [
|
|
4178
|
+
assert calls == [
|
|
4179
|
+
["terraform", "plan", "-out=tfplan", "-no-color"],
|
|
4180
|
+
["terraform", "apply", "-auto-approve", "-no-color", "tfplan"],
|
|
4181
|
+
]
|
|
3568
4182
|
|
|
3569
4183
|
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
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)
|
|
3573
4188
|
|
|
4189
|
+
assert runner.validate_terraform_execution_phase(payload, action) == "legacy"
|
|
3574
4190
|
|
|
3575
|
-
|
|
4191
|
+
|
|
4192
|
+
def test_legacy_and_approved_apply_reconcile_native_auth_schema() -> None:
|
|
3576
4193
|
runner = load_runner()
|
|
3577
|
-
|
|
3578
|
-
|
|
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
|
|
3579
4207
|
)
|
|
3580
|
-
assert slug == "acme"
|
|
3581
4208
|
|
|
3582
4209
|
|
|
3583
|
-
def
|
|
4210
|
+
def test_apply_phase_requires_separate_approved_plan() -> None:
|
|
3584
4211
|
runner = load_runner()
|
|
3585
|
-
|
|
3586
|
-
assert slug == "acme"
|
|
4212
|
+
payload = _approval_payload("apply", plan=False, apply=True)
|
|
3587
4213
|
|
|
4214
|
+
with pytest.raises(RuntimeError, match="requires approvedPlan"):
|
|
4215
|
+
runner.validate_terraform_execution_phase(payload, "apply")
|
|
3588
4216
|
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
{},
|
|
3594
|
-
{"tenantSlug": "other"},
|
|
3595
|
-
{"stage": "acme", "customer_domain": "acme.thinkwork.ai"},
|
|
3596
|
-
)
|
|
4217
|
+
payload["approvedPlan"] = {
|
|
4218
|
+
"descriptor": {"s3Uri": "s3://evidence/descriptor.json", "sha256": "a" * 64}
|
|
4219
|
+
}
|
|
4220
|
+
assert runner.validate_terraform_execution_phase(payload, "apply") == "apply"
|
|
3597
4221
|
|
|
3598
4222
|
|
|
3599
|
-
def
|
|
4223
|
+
def test_plan_and_apply_share_only_immutable_deployment_config_digest() -> None:
|
|
3600
4224
|
runner = load_runner()
|
|
3601
|
-
|
|
3602
|
-
|
|
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
|
+
}
|
|
3603
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)
|
|
3604
4233
|
|
|
3605
|
-
|
|
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:
|
|
3606
4241
|
runner = load_runner()
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
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"]),
|
|
3610
4315
|
)
|
|
3611
|
-
assert runner.first_admin_email({"platform_operator_emails": ""}) == ""
|
|
3612
|
-
assert runner.first_admin_email({"platform_operator_emails": "not-an-email"}) == ""
|
|
3613
|
-
assert runner.first_admin_email({"platform_operator_emails": "a'b@acme.com"}) == ""
|
|
3614
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
|
|
3615
4326
|
|
|
3616
|
-
def
|
|
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:
|
|
3617
4351
|
runner = load_runner()
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
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")
|
|
3622
4362
|
|
|
3623
4363
|
|
|
3624
|
-
def
|
|
4364
|
+
def test_tei_v380_saved_plan_apply_refuses_any_delete_action(
|
|
3625
4365
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
3626
4366
|
) -> None:
|
|
3627
4367
|
runner = load_runner()
|
|
3628
|
-
|
|
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
|
|
4726
|
+
|
|
4727
|
+
|
|
4728
|
+
def test_tei_v380_force_unlock_is_retry_safe_after_exact_unlock(
|
|
4729
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
4730
|
+
) -> None:
|
|
4731
|
+
runner = load_runner()
|
|
4732
|
+
calls: list[list[str]] = []
|
|
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)
|
|
4742
|
+
monkeypatch.setattr(runner, "run", lambda args, **_kwargs: calls.append(args))
|
|
4743
|
+
|
|
4744
|
+
proof = runner.force_unlock_tei_v380_state()
|
|
4745
|
+
|
|
4746
|
+
assert proof["alreadyUnlocked"] is True
|
|
4747
|
+
assert proof["before"] == proof["after"]
|
|
4748
|
+
assert calls == []
|
|
4749
|
+
|
|
4750
|
+
|
|
4751
|
+
def test_tei_v380_recovery_imports_require_exact_evidence_and_current_state(
|
|
4752
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
4753
|
+
) -> None:
|
|
4754
|
+
runner = load_runner()
|
|
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
|
+
}
|
|
4782
|
+
|
|
4783
|
+
def fake_download(_uri, destination, _sha, _label):
|
|
4784
|
+
destination.write_text(json.dumps(evidence), encoding="utf-8")
|
|
4785
|
+
return "a" * 64
|
|
4786
|
+
|
|
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 {},
|
|
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
|
|
4799
|
+
|
|
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)
|
|
4808
|
+
|
|
4809
|
+
|
|
4810
|
+
def test_terraform_plan_summary_counts_resource_actions() -> None:
|
|
4811
|
+
runner = load_runner()
|
|
4812
|
+
|
|
4813
|
+
summary = runner.terraform_plan_summary(
|
|
4814
|
+
{
|
|
4815
|
+
"format_version": "1.2",
|
|
4816
|
+
"terraform_version": "1.8.0",
|
|
4817
|
+
"resource_changes": [
|
|
4818
|
+
{"change": {"actions": ["create"]}},
|
|
4819
|
+
{"change": {"actions": ["update"]}},
|
|
4820
|
+
{"change": {"actions": ["delete", "create"]}},
|
|
4821
|
+
{"change": {"actions": ["create"]}},
|
|
4822
|
+
],
|
|
4823
|
+
}
|
|
4824
|
+
)
|
|
4825
|
+
|
|
4826
|
+
assert summary == {
|
|
4827
|
+
"formatVersion": "1.2",
|
|
4828
|
+
"terraformVersion": "1.8.0",
|
|
4829
|
+
"resourceChangeCount": 4,
|
|
4830
|
+
"resourceChangesByAction": {
|
|
4831
|
+
"create": 2,
|
|
4832
|
+
"update": 1,
|
|
4833
|
+
"delete,create": 1,
|
|
4834
|
+
},
|
|
4835
|
+
}
|
|
4836
|
+
|
|
4837
|
+
|
|
4838
|
+
def test_self_update_runner_script_copies_release_runner_to_s3(
|
|
4839
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
4840
|
+
) -> None:
|
|
4841
|
+
runner = load_runner()
|
|
4842
|
+
source_dir = tmp_path / "source"
|
|
4843
|
+
script = source_dir / "terraform/modules/app/deployment-control-plane/runner.py"
|
|
4844
|
+
script.parent.mkdir(parents=True)
|
|
4845
|
+
script.write_text("# release runner\n", encoding="utf-8")
|
|
4846
|
+
|
|
4847
|
+
monkeypatch.setattr(runner, "SOURCE", source_dir)
|
|
4848
|
+
monkeypatch.setenv(
|
|
4849
|
+
"THINKWORK_RUNNER_SCRIPT_S3_URI",
|
|
4850
|
+
"s3://evidence-bucket/runner/thinkwork-runner.py",
|
|
4851
|
+
)
|
|
4852
|
+
calls: list[list[str]] = []
|
|
4853
|
+
monkeypatch.setattr(runner, "run", lambda args, **_kw: calls.append(args))
|
|
4854
|
+
|
|
4855
|
+
runner.self_update_runner_script()
|
|
4856
|
+
|
|
4857
|
+
assert calls == [
|
|
4858
|
+
[
|
|
4859
|
+
"aws",
|
|
4860
|
+
"s3",
|
|
4861
|
+
"cp",
|
|
4862
|
+
str(script),
|
|
4863
|
+
"s3://evidence-bucket/runner/thinkwork-runner.py",
|
|
4864
|
+
]
|
|
4865
|
+
]
|
|
4866
|
+
|
|
4867
|
+
|
|
4868
|
+
def test_self_update_runner_script_skips_when_source_missing(
|
|
4869
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
4870
|
+
) -> None:
|
|
4871
|
+
runner = load_runner()
|
|
4872
|
+
monkeypatch.setattr(runner, "SOURCE", tmp_path / "missing-source")
|
|
4873
|
+
monkeypatch.setenv(
|
|
4874
|
+
"THINKWORK_RUNNER_SCRIPT_S3_URI",
|
|
4875
|
+
"s3://evidence-bucket/runner/thinkwork-runner.py",
|
|
4876
|
+
)
|
|
4877
|
+
calls: list[list[str]] = []
|
|
4878
|
+
monkeypatch.setattr(runner, "run", lambda args, **_kw: calls.append(args))
|
|
4879
|
+
|
|
4880
|
+
runner.self_update_runner_script()
|
|
4881
|
+
|
|
4882
|
+
assert calls == []
|
|
4883
|
+
|
|
4884
|
+
|
|
4885
|
+
# ---------------------------------------------------------------------------
|
|
4886
|
+
# First-admin bootstrap (first-run provisioning)
|
|
4887
|
+
# ---------------------------------------------------------------------------
|
|
4888
|
+
|
|
4889
|
+
|
|
4890
|
+
def test_first_admin_tenant_slug_prefers_customer_domain_label() -> None:
|
|
4891
|
+
runner = load_runner()
|
|
4892
|
+
slug = runner.first_admin_tenant_slug(
|
|
4893
|
+
{}, {}, {"stage": "acme-prod", "customer_domain": "acme.thinkwork.ai"}
|
|
4894
|
+
)
|
|
4895
|
+
assert slug == "acme"
|
|
4896
|
+
|
|
4897
|
+
|
|
4898
|
+
def test_first_admin_tenant_slug_falls_back_to_stage() -> None:
|
|
4899
|
+
runner = load_runner()
|
|
4900
|
+
slug = runner.first_admin_tenant_slug({}, {}, {"stage": "acme", "customer_domain": ""})
|
|
4901
|
+
assert slug == "acme"
|
|
4902
|
+
|
|
4903
|
+
|
|
4904
|
+
def test_first_admin_tenant_slug_rejects_domain_mismatch() -> None:
|
|
4905
|
+
runner = load_runner()
|
|
4906
|
+
with pytest.raises(RuntimeError, match="KTD8"):
|
|
4907
|
+
runner.first_admin_tenant_slug(
|
|
4908
|
+
{},
|
|
4909
|
+
{"tenantSlug": "other"},
|
|
4910
|
+
{"stage": "acme", "customer_domain": "acme.thinkwork.ai"},
|
|
4911
|
+
)
|
|
4912
|
+
|
|
4913
|
+
|
|
4914
|
+
def test_first_admin_tenant_slug_rejects_invalid_slug() -> None:
|
|
4915
|
+
runner = load_runner()
|
|
4916
|
+
with pytest.raises(RuntimeError, match="slug pattern"):
|
|
4917
|
+
runner.first_admin_tenant_slug({}, {}, {"stage": "Bad_Stage!", "customer_domain": ""})
|
|
4918
|
+
|
|
4919
|
+
|
|
4920
|
+
def test_first_admin_email_takes_first_valid_entry() -> None:
|
|
4921
|
+
runner = load_runner()
|
|
4922
|
+
assert (
|
|
4923
|
+
runner.first_admin_email({"platform_operator_emails": "ops@acme.com, two@acme.com"})
|
|
4924
|
+
== "ops@acme.com"
|
|
4925
|
+
)
|
|
4926
|
+
assert runner.first_admin_email({"platform_operator_emails": ""}) == ""
|
|
4927
|
+
assert runner.first_admin_email({"platform_operator_emails": "not-an-email"}) == ""
|
|
4928
|
+
assert runner.first_admin_email({"platform_operator_emails": "a'b@acme.com"}) == ""
|
|
4929
|
+
|
|
4930
|
+
|
|
4931
|
+
def test_ensure_first_admin_skips_without_admin_email(tmp_path: Path) -> None:
|
|
4932
|
+
runner = load_runner()
|
|
4933
|
+
outputs = tmp_path / "outputs.json"
|
|
4934
|
+
outputs.write_text("{}", encoding="utf-8")
|
|
4935
|
+
runner.ensure_first_admin(outputs, {"platform_operator_emails": "", "stage": "acme"}, {}, {})
|
|
4936
|
+
assert runner.FIRST_ADMIN_EVIDENCE["status"] == "skipped"
|
|
4937
|
+
|
|
4938
|
+
|
|
4939
|
+
def test_ensure_first_admin_skips_established_env_without_slug(
|
|
4940
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
4941
|
+
) -> None:
|
|
4942
|
+
runner = load_runner()
|
|
4943
|
+
outputs = tmp_path / "outputs.json"
|
|
3629
4944
|
outputs.write_text(json.dumps({"user_pool_id": {"value": "us-east-1_POOL"}}), encoding="utf-8")
|
|
3630
4945
|
monkeypatch.setattr(runner, "database_url_from_outputs", lambda _o: "postgres://x")
|
|
3631
4946
|
|
|
@@ -3731,3 +5046,568 @@ def test_ensure_first_admin_failure_is_nonfatal(
|
|
|
3731
5046
|
)
|
|
3732
5047
|
assert runner.FIRST_ADMIN_EVIDENCE["status"] == "failed"
|
|
3733
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)
|