thinkwork-cli 0.12.11 → 0.12.15
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 +42 -38
- package/dist/{api-client-ASJWIG3Z.js → api-client-4VT3FWOH.js} +1 -1
- package/dist/{chunk-JEHV35EU.js → chunk-H7AB42ES.js} +340 -30
- package/dist/cli.js +22157 -9229
- package/dist/commands/enterprise/templates/deploy-repo/.github/workflows/deploy.yml +17 -6
- package/dist/commands/enterprise/templates/deploy-repo/docs/runbook.md +81 -9
- package/dist/commands/enterprise/templates/deploy-repo/scripts/apply-release.mjs +183 -21
- package/dist/commands/enterprise/templates/deploy-repo/scripts/smoke.mjs +1 -2
- package/dist/commands/enterprise/templates/deploy-repo/terraform/main.tf +286 -0
- package/dist/drizzle/0000_petite_marvel_apes.sql +1188 -0
- package/dist/drizzle/0001_milky_surge.sql +103 -0
- package/dist/drizzle/0002_aberrant_mattie_franklin.sql +41 -0
- package/dist/drizzle/0003_exotic_ogun.sql +3 -0
- package/dist/drizzle/0004_glamorous_magneto.sql +2 -0
- package/dist/drizzle/0005_ancient_nocturne.sql +2 -0
- package/dist/drizzle/0006_new_colonel_america.sql +2 -0
- package/dist/drizzle/0007_unique_external_task_id.sql +31 -0
- package/dist/drizzle/0008_external_task_id_column.sql +43 -0
- package/dist/drizzle/0009_evaluations.sql +64 -0
- package/dist/drizzle/0010_eval_agent_template.sql +2 -0
- package/dist/drizzle/0011_eval_run_template.sql +2 -0
- package/dist/drizzle/0012_eval_seed_unique.sql +16 -0
- package/dist/drizzle/0013_motionless_white_queen.sql +140 -0
- package/dist/drizzle/0014_wiki_aggregation.sql +11 -0
- package/dist/drizzle/0015_pg_trgm_alias_title_indexes.sql +9 -0
- package/dist/drizzle/0016_wiki_schema_drops.sql +24 -0
- package/dist/drizzle/0017_wiki_places.sql +46 -0
- package/dist/drizzle/0018_agent_workspace_overlay.sql +26 -0
- package/dist/drizzle/0018_cool_firebird.sql +24 -0
- package/dist/drizzle/0018_skill_runs.sql +84 -0
- package/dist/drizzle/0019_condemned_dragon_man.sql +95 -0
- package/dist/drizzle/0019_webhook_skill_runs.sql +58 -0
- package/dist/drizzle/0020_crazy_the_anarchist.sql +9 -0
- package/dist/drizzle/0020_mutation_idempotency.sql +64 -0
- package/dist/drizzle/0021_crm_work_links.sql +76 -0
- package/dist/drizzle/0021_skill_runs_completion_hmac.sql +33 -0
- package/dist/drizzle/0022_add_billing_tables.sql +70 -0
- package/dist/drizzle/0022_seed_thinkwork_admin_permissions.sql +160 -0
- package/dist/drizzle/0022_stripe_billing_indexes.sql +48 -0
- package/dist/drizzle/0023_tenants_deactivation.sql +27 -0
- package/dist/drizzle/0024_tenant_mcp_admin_keys.sql +63 -0
- package/dist/drizzle/0025_v1_agent_architecture.sql +182 -0
- package/dist/drizzle/0026_thread_turns_add_kind.sql +28 -0
- package/dist/drizzle/0027_capability_catalog_and_manifests.sql +219 -0
- package/dist/drizzle/0028_aws_s3_extension.sql +26 -0
- package/dist/drizzle/0029_collapse_execution_types.sql +42 -0
- package/dist/drizzle/0030_retire_composition_primitives.sql +48 -0
- package/dist/drizzle/0031_thread_cleanup_drops.sql +131 -0
- package/dist/drizzle/0032_add_browser_automation_template_config.sql +24 -0
- package/dist/drizzle/0033_agent_operation_leases.sql +32 -0
- package/dist/drizzle/0034_agent_workspace_events.sql +116 -0
- package/dist/drizzle/0035_workspace_review_decisions.sql +19 -0
- package/dist/drizzle/0036_user_scoped_memory_wiki.sql +152 -0
- package/dist/drizzle/0037_user_profile_operating_model.sql +34 -0
- package/dist/drizzle/0042_agent_runtime_selector.sql +41 -0
- package/dist/drizzle/0043_wiki_search_tsv_tokenization.sql +48 -0
- package/dist/drizzle/0044_add_web_search_template_config.sql +7 -0
- package/dist/drizzle/0045_add_send_email_template_config.sql +7 -0
- package/dist/drizzle/0046_tenant_mcp_context_tools.sql +26 -0
- package/dist/drizzle/0047_add_context_engine_template_config.sql +7 -0
- package/dist/drizzle/0048_tenant_context_provider_settings.sql +22 -0
- package/dist/drizzle/0050_brain_v0_entity_subtype.sql +29 -0
- package/dist/drizzle/0051_brain_v0_tenant_entity_pages.sql +170 -0
- package/dist/drizzle/0052_brain_v0_external_refs.sql +41 -0
- package/dist/drizzle/0053_brain_v0_unresolved_mentions_subtype.sql +48 -0
- package/dist/drizzle/0054_wiki_compile_jobs_input.sql +28 -0
- package/dist/drizzle/0055_routines_stepfunctions_substrate.sql +202 -0
- package/dist/drizzle/0056_routine_step_events_dedup_idx.sql +25 -0
- package/dist/drizzle/0057_archive_legacy_python_routines.sql +36 -0
- package/dist/drizzle/0058_routines_visibility_and_owning_agent.sql +61 -0
- package/dist/drizzle/0061_routine_execution_asl_version_id.sql +29 -0
- package/dist/drizzle/0062_migrate_pi_to_flue.sql +110 -0
- package/dist/drizzle/0063_threads_session_data.sql +23 -0
- package/dist/drizzle/0064_tenant_credentials.sql +65 -0
- package/dist/drizzle/0067_thinkwork_computers_phase_one.sql +174 -0
- package/dist/drizzle/0068_drop_system_workflows_and_activation.sql +115 -0
- package/dist/drizzle/0069_compliance_schema.sql +364 -0
- package/dist/drizzle/0070_compliance_aurora_roles.sql +171 -0
- package/dist/drizzle/0072_threads_computer_ownership.sql +24 -0
- package/dist/drizzle/0073_compliance_tenant_anchor_state.sql +105 -0
- package/dist/drizzle/0074_compliance_event_hash_index.sql +49 -0
- package/dist/drizzle/0075_scheduled_jobs_computer_ownership.sql +27 -0
- package/dist/drizzle/0076_scheduled_jobs_marco_backfill.sql +69 -0
- package/dist/drizzle/0077_computers_primary_agent_id.sql +38 -0
- package/dist/drizzle/0078_tenant_customize_catalog.sql +46 -0
- package/dist/drizzle/0079_seed_tenant_customize_catalog.sql +53 -0
- package/dist/drizzle/0081_routines_catalog_slug.sql +42 -0
- package/dist/drizzle/0082_messages_parts_jsonb.sql +52 -0
- package/dist/drizzle/0083_computer_runbooks.sql +103 -0
- package/dist/drizzle/0084_artifacts_favorited_at.sql +32 -0
- package/dist/drizzle/0085_seed_thinkwork_computer_default_template.sql +46 -0
- package/dist/drizzle/0086_update_default_template_models.sql +19 -0
- package/dist/drizzle/0087_retire_oss_connectors.sql +77 -0
- package/dist/drizzle/0088_compliance_event_types_finance_pilot.sql +37 -0
- package/dist/drizzle/0089_remove_maniflow_eval_seeds.sql +81 -0
- package/dist/drizzle/0089_wiki_schema_extraction.sql +315 -0
- package/dist/drizzle/0090_brain_schema_extraction.sql +274 -0
- package/dist/drizzle/0091_drop_wiki_brain_compat_views.sql +118 -0
- package/dist/drizzle/0092_finish_oss_connector_retirement.sql +97 -0
- package/dist/drizzle/0093_eval_runs_scheduled_job_id.sql +23 -0
- package/dist/drizzle/0094_slack_workspace_app.sql +140 -0
- package/dist/drizzle/0095_eval_runs_computer_id.sql +22 -0
- package/dist/drizzle/0096_true_redteam_eval_seed_cleanup.sql +67 -0
- package/dist/drizzle/0097_shared_computers.sql +191 -0
- package/dist/drizzle/0098_business_ontology.sql +587 -0
- package/dist/drizzle/0099_thread_idle_learning.sql +145 -0
- package/dist/drizzle/0100_ontology_gated_brain_dynamic_subtypes.sql +7 -0
- package/dist/drizzle/0101_ontology_gated_unresolved_mentions_dynamic_subtypes.sql +7 -0
- package/dist/drizzle/0102_wiki_brain_owner_repair.sql +87 -0
- package/dist/drizzle/0103_brain_section_source_trigger_repair.sql +75 -0
- package/dist/drizzle/0104_expand_seed_ontology_for_user_memory.sql +166 -0
- package/dist/drizzle/0105_spaces_domain.sql +349 -0
- package/dist/drizzle/0106_expand_seed_ontology_for_triples.sql +173 -0
- package/dist/drizzle/0106_space_threads_participants.sql +197 -0
- package/dist/drizzle/0107_linked_task_mirror.sql +245 -0
- package/dist/drizzle/0108_message_mentions.sql +95 -0
- package/dist/drizzle/0109_space_owned_threads.sql +217 -0
- package/dist/drizzle/0110_thread_participant_read_state.sql +21 -0
- package/dist/drizzle/0111_agents_tenant_name_unique.sql +38 -0
- package/dist/drizzle/0112_recast_spaces_as_contextual_workrooms.sql +138 -0
- package/dist/drizzle/0113_agents_own_runtime_fields.sql +48 -0
- package/dist/drizzle/0114_migrate_templates_to_agents_and_spaces.sql +361 -0
- package/dist/drizzle/0115_remove_computer_eval_template_dependencies.sql +69 -0
- package/dist/drizzle/0116_backfill_thread_participants_access.sql +126 -0
- package/dist/drizzle/0117_space_access_mode.sql +40 -0
- package/dist/drizzle/0118_normalize_tenant_member_principal_type.sql +41 -0
- package/dist/drizzle/0119_space_knowledge_bases.sql +126 -0
- package/dist/drizzle/0120_space_automations.sql +58 -0
- package/dist/drizzle/0121_drop_computer_runbooks.sql +26 -0
- package/dist/drizzle/0122_space_email_triggers.sql +24 -0
- package/dist/drizzle/0123_single_platform_agent_and_overrides.sql +58 -0
- package/dist/drizzle/0124_thread_turns_finalized_at.sql +17 -0
- package/dist/drizzle/0125_drop_space_agent_assignments.sql +42 -0
- package/dist/drizzle/0126_migrate_flue_to_pi.sql +42 -0
- package/dist/drizzle/0127_runtime_type_observability.sql +28 -0
- package/dist/drizzle/0128_drop_eval_test_cases_agent_id.sql +37 -0
- package/dist/drizzle/0129_drop_code_factory_tables.sql +43 -0
- package/dist/drizzle/0129_eval_results_system_prompt.sql +30 -0
- package/dist/drizzle/0130_drop_teams_and_team_id_columns.sql +54 -0
- package/dist/drizzle/0131_drop_skill_catalog_and_tenant_skills.sql +33 -0
- package/dist/drizzle/0132_drop_computer_tables.sql +31 -0
- package/dist/drizzle/0133_drop_seed_view_tombstones.sql +79 -0
- package/dist/drizzle/0133_thread_turns_system_prompt.sql +22 -0
- package/dist/drizzle/0134_drop_dead_agent_runtime_tables.sql +53 -0
- package/dist/drizzle/0135_native_checklist_linked_tasks.sql +48 -0
- package/dist/drizzle/0136_goal_ledger.sql +55 -0
- package/dist/drizzle/0136_space_email_trigger_status.sql +66 -0
- package/dist/drizzle/0137_thread_participant_pins.sql +25 -0
- package/dist/drizzle/0138_users_cognito_sub.sql +30 -0
- package/dist/drizzle/0139_mobile_turn_client_id.sql +23 -0
- package/dist/drizzle/0140_workspace_folder_names.sql +36 -0
- package/dist/drizzle/0141_eval_runs_desktop_pi_provenance.sql +31 -0
- package/dist/drizzle/0142_pi_only_agent_runtime.sql +61 -0
- package/dist/drizzle/0143_add_web_extract_builtin_config.sql +28 -0
- package/dist/drizzle/0144_skill_catalog_index.sql +40 -0
- package/dist/drizzle/0145_knowledge_graph_thread_ingest.sql +512 -0
- package/dist/drizzle/0146_knowledge_graph_source_scope.sql +413 -0
- package/dist/drizzle/0147_knowledge_graph_scope_trigger_qualification.sql +211 -0
- package/dist/drizzle/0148_user_cost_attribution.sql +171 -0
- package/dist/drizzle/0149_managed_mcp_servers.sql +64 -0
- package/dist/drizzle/0149_user_model_approvals.sql +89 -0
- package/dist/drizzle/0150_bootstrap_first_admin_claim.sql +11 -0
- package/dist/drizzle/0151_managed_deployments.sql +91 -0
- package/dist/drizzle/0152_agent_profiles.sql +81 -0
- package/dist/drizzle/0153_message_mentions_agent_profile.sql +9 -0
- package/dist/drizzle/0154_customer_deployment_sessions.sql +61 -0
- package/dist/drizzle/0155_tenant_model_catalog.sql +152 -0
- package/dist/drizzle/0156_bootstrap_credential_leases.sql +52 -0
- package/dist/drizzle/0157_knowledge_graph_observations_source.sql +79 -0
- package/dist/drizzle/0158_pending_user_questions.sql +80 -0
- package/dist/drizzle/0158_wiki_tenant_scope.sql +38 -0
- package/dist/drizzle/0159_agent_profiles_space_local.sql +49 -0
- package/dist/drizzle/0159_application_plugins.sql +193 -0
- package/dist/drizzle/0159_eval_scoring_version.sql +46 -0
- package/dist/drizzle/0160_compliance_event_types_plugins.sql +37 -0
- package/dist/drizzle/0160_eval_datasets.sql +75 -0
- package/dist/drizzle/0160_eval_results_thread_turn.sql +35 -0
- package/dist/drizzle/0161_eval_run_pinning.sql +54 -0
- package/dist/drizzle/0161_tenant_mcp_servers_plugin_source.sql +62 -0
- package/dist/drizzle/0162_eval_result_override.sql +49 -0
- package/dist/drizzle/0163_eval_replay_tool_allowlist.sql +50 -0
- package/dist/drizzle/0164_eval_replay_tool_override_mode.sql +52 -0
- package/dist/drizzle/0165_plugin_premium_entitlements.sql +153 -0
- package/dist/drizzle/0166_company_brain_substrate_contract.sql +386 -0
- package/dist/drizzle/0167_company_brain_artifact_manifest_runtime.sql +85 -0
- package/dist/drizzle/0168_eval_skill_gate.sql +44 -0
- package/dist/drizzle/0169_release_update_jobs.sql +67 -0
- package/dist/drizzle/0170_email_channel_plugin.sql +291 -0
- package/dist/drizzle/0171_linked_task_external_providers.sql +30 -0
- package/dist/drizzle/0172_email_channel_sendgrid_provider.sql +8 -0
- package/dist/drizzle/0173_auth_provider_resources.sql +83 -0
- package/dist/drizzle/0174_workos_auth_bridges.sql +37 -0
- package/dist/drizzle/0175_workos_auth_sessions.sql +38 -0
- package/dist/drizzle/0176_n8n_agent_step_runs.sql +107 -0
- package/dist/drizzle/0177_workflow_control_plane.sql +281 -0
- package/dist/drizzle/0178_workflow_backfill_existing_routines.sql +525 -0
- package/dist/drizzle/0179_tenant_goal_budget_settings.sql +21 -0
- package/dist/drizzle/0180_skill_drafts.sql +89 -0
- package/dist/drizzle/0181_skill_draft_publish_status.sql +21 -0
- package/dist/drizzle/0182_agent_loops.sql +260 -0
- package/dist/drizzle/0183_okf_artifact_manifests.sql +52 -0
- package/dist/drizzle/0184_skill_catalog_trust_cache.sql +35 -0
- package/dist/drizzle/0185_agent_loop_space.sql +16 -0
- package/dist/drizzle/0186_tenant_mcp_runtime_metadata.sql +10 -0
- package/dist/drizzle/0187_native_work_items.sql +220 -0
- package/dist/drizzle/0188_company_etl_plugin_rename.sql +99 -0
- package/dist/drizzle/0189_trace_cost_substrate.sql +247 -0
- package/dist/drizzle/0190_billing_export_reconciliation.sql +101 -0
- package/dist/drizzle/0191_open_engine_work_item_queue.sql +66 -0
- package/dist/drizzle/0192_work_item_labels.sql +44 -0
- package/dist/drizzle/0193_work_item_documents.sql +31 -0
- package/dist/drizzle/0194_memory_retain_attempts.sql +103 -0
- package/dist/drizzle/0194_work_item_comments.sql +35 -0
- package/dist/drizzle/0195_plugin_app_overlays.sql +45 -0
- package/dist/drizzle/0196_pi_extensions.sql +118 -0
- package/dist/drizzle/0197_eval_profiles.sql +150 -0
- package/dist/drizzle/0198_eval_runs_unstable.sql +27 -0
- package/dist/drizzle/0199_eval_runs_cost_partial.sql +27 -0
- package/dist/drizzle/meta/_journal.json +160 -0
- package/dist/plugins/README.md +162 -0
- package/dist/plugins/catalog/package.json +40 -0
- package/dist/plugins/catalog/scripts/build-catalog.ts +120 -0
- package/dist/plugins/catalog/scripts/generate-plugin-registry.ts +144 -0
- package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +86 -0
- package/dist/plugins/catalog/src/__tests__/catalog.test.ts +308 -0
- package/dist/plugins/catalog/src/__tests__/contracts.test.ts +861 -0
- package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +124 -0
- package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +120 -0
- package/dist/plugins/catalog/src/catalog.ts +434 -0
- package/dist/plugins/catalog/src/contracts.ts +1498 -0
- package/dist/plugins/catalog/src/index.ts +4 -0
- package/dist/plugins/catalog/src/plugin-package.ts +171 -0
- package/dist/plugins/catalog/src/registry/generated-first-party.ts +76 -0
- package/dist/plugins/catalog/src/registry/index.ts +1 -0
- package/dist/plugins/catalog/tsconfig.json +8 -0
- package/dist/plugins/company-brain/README.md +37 -0
- package/dist/plugins/company-brain/package.json +33 -0
- package/dist/plugins/company-brain/runtime/cognee/Dockerfile +11 -0
- package/dist/plugins/company-brain/smoke/cognee-managed-app-smoke.mjs +458 -0
- package/dist/plugins/company-brain/smoke/cognee-memory-cutover-smoke.mjs +539 -0
- package/dist/plugins/company-brain/smoke/company-brain-context-engine-smoke.mjs +312 -0
- package/dist/plugins/company-brain/smoke/company-brain-operations-smoke.mjs +449 -0
- package/dist/plugins/company-brain/smoke/company-brain-plugin-smoke.mjs +655 -0
- package/dist/plugins/company-brain/smoke/hindsight-memory-isolation-smoke.mjs +474 -0
- package/dist/plugins/company-brain/src/api/cognee-client.ts +896 -0
- package/dist/plugins/company-brain/src/api/cognee-cluster-identity.ts +49 -0
- package/dist/plugins/company-brain/src/api/cognee-memory-scope.ts +84 -0
- package/dist/plugins/company-brain/src/api/context-engine-provider.ts +888 -0
- package/dist/plugins/company-brain/src/api/migration.ts +665 -0
- package/dist/plugins/company-brain/src/deployment/cognee-managed-app.ts +396 -0
- package/dist/plugins/company-brain/src/index.ts +62 -0
- package/dist/plugins/company-brain/src/manifest.ts +112 -0
- package/dist/plugins/company-brain/terraform/cognee/README.md +263 -0
- package/dist/plugins/company-brain/terraform/cognee/main.tf +766 -0
- package/dist/plugins/company-brain/terraform/cognee/outputs.tf +144 -0
- package/dist/plugins/company-brain/terraform/cognee/variables.tf +402 -0
- package/dist/plugins/company-brain/test/api/cognee-client.test.ts +562 -0
- package/dist/plugins/company-brain/test/api/cognee-memory-scope.test.ts +74 -0
- package/dist/plugins/company-brain/test/api/context-engine-provider.test.ts +447 -0
- package/dist/plugins/company-brain/test/api/migration.test.ts +465 -0
- package/dist/plugins/company-brain/test/manifest.test.ts +95 -0
- package/dist/plugins/company-brain/tsconfig.json +8 -0
- package/dist/plugins/company-data/README.md +49 -0
- package/dist/plugins/company-data/package.json +21 -0
- package/dist/plugins/company-data/src/index.ts +30 -0
- package/dist/plugins/company-data/src/manifest.ts +26 -0
- package/dist/plugins/company-data/test/manifest.test.ts +116 -0
- package/dist/plugins/company-data/tsconfig.json +8 -0
- package/dist/plugins/company-etl/README.md +46 -0
- package/dist/plugins/company-etl/package.json +21 -0
- package/dist/plugins/company-etl/src/index.ts +29 -0
- package/dist/plugins/company-etl/src/manifest.ts +26 -0
- package/dist/plugins/company-etl/test/manifest.test.ts +108 -0
- package/dist/plugins/company-etl/tsconfig.json +8 -0
- package/dist/plugins/email-channel/README.md +41 -0
- package/dist/plugins/email-channel/package.json +22 -0
- package/dist/plugins/email-channel/src/index.ts +36 -0
- package/dist/plugins/email-channel/src/manifest.ts +44 -0
- package/dist/plugins/email-channel/src/provider-contract.ts +36 -0
- package/dist/plugins/email-channel/test/manifest.test.ts +119 -0
- package/dist/plugins/email-channel/tsconfig.json +8 -0
- package/dist/plugins/lastmile/README.md +50 -0
- package/dist/plugins/lastmile/package.json +23 -0
- package/dist/plugins/lastmile/smoke/lastmile-plugin-smoke.mjs +661 -0
- package/dist/plugins/lastmile/src/api/tasks-adapter.ts +572 -0
- package/dist/plugins/lastmile/src/discovery.fixture.ts +64 -0
- package/dist/plugins/lastmile/src/index.ts +46 -0
- package/dist/plugins/lastmile/src/manifest.ts +139 -0
- package/dist/plugins/lastmile/test/api/tasks-adapter.test.ts +266 -0
- package/dist/plugins/lastmile/test/discovery.test.ts +88 -0
- package/dist/plugins/lastmile/tsconfig.json +8 -0
- package/dist/plugins/n8n/README.md +201 -0
- package/dist/plugins/n8n/n8n-app/README.md +33 -0
- package/dist/plugins/n8n/n8n-app/package.json +23 -0
- package/dist/plugins/n8n/n8n-app/src/application-config.ts +45 -0
- package/dist/plugins/n8n/n8n-app/src/front-components/thinkwork-workflows.front-component.tsx +970 -0
- package/dist/plugins/n8n/n8n-app/src/lib/n8n-app-data.ts +190 -0
- package/dist/plugins/n8n/n8n-app/src/vite-env.d.ts +7 -0
- package/dist/plugins/n8n/n8n-app/tsconfig.json +15 -0
- package/dist/plugins/n8n/package.json +35 -0
- package/dist/plugins/n8n/runtime/Dockerfile +18 -0
- package/dist/plugins/n8n/runtime/n8n-task-runners.json +14 -0
- package/dist/plugins/n8n/runtime/n8n-task-runners.json.template +14 -0
- package/dist/plugins/n8n/runtime/package-lock.json +13 -0
- package/dist/plugins/n8n/runtime/package.json +7 -0
- package/dist/plugins/n8n/scripts/sync-thinkwork-app.mjs +268 -0
- package/dist/plugins/n8n/smoke/README.md +95 -0
- package/dist/plugins/n8n/smoke/n8n-agent-step-bridge-smoke.mjs +866 -0
- package/dist/plugins/n8n/smoke/n8n-integrated-app-smoke.mjs +262 -0
- package/dist/plugins/n8n/smoke/n8n-managed-app-smoke.mjs +409 -0
- package/dist/plugins/n8n/smoke/n8n-mcp-smoke.mjs +624 -0
- package/dist/plugins/n8n/src/deployment/image-build.ts +172 -0
- package/dist/plugins/n8n/src/deployment/managed-app.ts +429 -0
- package/dist/plugins/n8n/src/index.ts +79 -0
- package/dist/plugins/n8n/src/manifest.ts +403 -0
- package/dist/plugins/n8n/src/package-config.ts +186 -0
- package/dist/plugins/n8n/src/skills/n8n-workflow-operator/SKILL.md +76 -0
- package/dist/plugins/n8n/src/skills/n8n-workflow-operator/references/mcp-tooling.md +40 -0
- package/dist/plugins/n8n/src/skills/n8n-workflow-operator/references/validation-and-handoff.md +40 -0
- package/dist/plugins/n8n/src/skills/n8n-workflow-operator/references/workflow-authoring.md +45 -0
- package/dist/plugins/n8n/terraform/n8n/README.md +113 -0
- package/dist/plugins/n8n/terraform/n8n/main.tf +918 -0
- package/dist/plugins/n8n/terraform/n8n/outputs.tf +124 -0
- package/dist/plugins/n8n/terraform/n8n/scripts/sync-database.py +250 -0
- package/dist/plugins/n8n/terraform/n8n/scripts/test_sync_database.py +55 -0
- package/dist/plugins/n8n/terraform/n8n/variables.tf +354 -0
- package/dist/plugins/n8n/test/image-build.test.ts +118 -0
- package/dist/plugins/n8n/test/integrated-app-contract.test.ts +353 -0
- package/dist/plugins/n8n/test/manifest.test.ts +278 -0
- package/dist/plugins/n8n/test/package-config.test.ts +57 -0
- package/dist/plugins/n8n/tsconfig.json +8 -0
- package/dist/plugins/sendgrid/README.md +30 -0
- package/dist/plugins/sendgrid/package.json +22 -0
- package/dist/plugins/sendgrid/src/index.ts +36 -0
- package/dist/plugins/sendgrid/src/manifest.ts +37 -0
- package/dist/plugins/sendgrid/src/provider-contract.ts +14 -0
- package/dist/plugins/sendgrid/test/manifest.test.ts +80 -0
- package/dist/plugins/sendgrid/tsconfig.json +8 -0
- package/dist/plugins/twenty/README.md +78 -0
- package/dist/plugins/twenty/package.json +27 -0
- package/dist/plugins/twenty/scripts/__tests__/sync-thinkwork-app.test.mjs +67 -0
- package/dist/plugins/twenty/scripts/sync-thinkwork-app.mjs +185 -0
- package/dist/plugins/twenty/scripts/wire-thinkwork-workflow.mjs +658 -0
- package/dist/plugins/twenty/smoke/twenty-managed-app-smoke.mjs +359 -0
- package/dist/plugins/twenty/smoke/twenty-mcp-oauth-smoke.mjs +574 -0
- package/dist/plugins/twenty/src/api/cutover.ts +209 -0
- package/dist/plugins/twenty/src/deployment/managed-app.ts +253 -0
- package/dist/plugins/twenty/src/index.ts +55 -0
- package/dist/plugins/twenty/src/manifest.ts +259 -0
- package/dist/plugins/twenty/terraform/twenty/README.md +97 -0
- package/dist/plugins/twenty/terraform/twenty/main.tf +829 -0
- package/dist/plugins/twenty/terraform/twenty/outputs.tf +79 -0
- package/dist/plugins/twenty/terraform/twenty/variables.tf +268 -0
- package/dist/plugins/twenty/test/api/cutover.test.ts +183 -0
- package/dist/plugins/twenty/test/manifest.test.ts +463 -0
- package/dist/plugins/twenty/tsconfig.json +9 -0
- package/dist/plugins/twenty/twenty-app/.nvmrc +1 -0
- package/dist/plugins/twenty/twenty-app/README.md +148 -0
- package/dist/plugins/twenty/twenty-app/package.json +30 -0
- package/dist/plugins/twenty/twenty-app/src/application-config.ts +44 -0
- package/dist/plugins/twenty/twenty-app/src/constants/universal-identifiers.ts +18 -0
- package/dist/plugins/twenty/twenty-app/src/default-role.ts +24 -0
- package/dist/plugins/twenty/twenty-app/src/front-components/thinkwork-settings.front-component.tsx +299 -0
- package/dist/plugins/twenty/twenty-app/src/logic-functions/thinkwork-webhook.logic-function.ts +354 -0
- package/dist/plugins/twenty/twenty-app/tsconfig.json +14 -0
- package/dist/plugins/twenty/twenty-app/yarn.lock +4144 -0
- package/dist/plugins/workos-auth/package.json +22 -0
- package/dist/plugins/workos-auth/src/index.ts +30 -0
- package/dist/plugins/workos-auth/src/manifest.ts +40 -0
- package/dist/plugins/workos-auth/src/provider-contract.ts +39 -0
- package/dist/plugins/workos-auth/test/manifest.test.ts +94 -0
- package/dist/plugins/workos-auth/tsconfig.json +8 -0
- package/dist/scripts/bootstrap-workspace.sh +162 -0
- package/dist/scripts/post-deploy.sh +290 -0
- package/dist/terraform/examples/greenfield/main.tf +903 -75
- package/dist/terraform/examples/greenfield/terraform.tfvars.example +67 -8
- package/dist/terraform/modules/app/agentcore-code-interpreter/Dockerfile.sandbox-base +2 -2
- package/dist/terraform/modules/app/agentcore-code-interpreter/README.md +8 -9
- package/dist/terraform/modules/app/agentcore-code-interpreter/sandbox/sitecustomize.py +227 -0
- package/dist/terraform/modules/app/agentcore-code-interpreter/sandbox/test_sitecustomize.py +277 -0
- package/dist/terraform/modules/app/agentcore-memory/README.md +6 -6
- package/dist/terraform/modules/app/agentcore-memory/main.tf +1 -1
- package/dist/terraform/modules/app/agentcore-memory/scripts/create_or_find_memory.sh +40 -9
- package/dist/terraform/modules/app/agentcore-pi/README.md +61 -0
- package/dist/terraform/modules/app/agentcore-pi/main.tf +453 -0
- package/dist/terraform/modules/app/agentcore-pi/outputs.tf +38 -0
- package/dist/terraform/modules/app/agentcore-pi/variables.tf +145 -0
- package/dist/terraform/modules/app/agentcore-platform/main.tf +83 -0
- package/dist/terraform/modules/app/agentcore-runtime/main.tf +9 -11
- package/dist/terraform/modules/app/appsync-subscriptions/main.tf +5 -1
- package/dist/terraform/modules/app/customer-domain/main.tf +483 -0
- package/dist/terraform/modules/app/deployment-control-plane/README.md +47 -0
- package/dist/terraform/modules/app/deployment-control-plane/buildspec.yml +24 -0
- package/dist/terraform/modules/app/deployment-control-plane/main.tf +614 -0
- package/dist/terraform/modules/app/deployment-control-plane/outputs.tf +54 -0
- package/dist/terraform/modules/app/deployment-control-plane/runner.py +4720 -0
- package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +3026 -0
- package/dist/terraform/modules/app/deployment-control-plane/variables.tf +96 -0
- package/dist/terraform/modules/app/hindsight-memory/README.md +25 -23
- package/dist/terraform/modules/app/hindsight-memory/main.tf +26 -2
- package/dist/terraform/modules/app/lambda-api/eval-fanout.tf +17 -50
- package/dist/terraform/modules/app/lambda-api/handlers.tf +1290 -679
- package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +933 -0
- package/dist/terraform/modules/app/lambda-api/main.tf +14 -527
- package/dist/terraform/modules/app/lambda-api/mcp-oauth.tf +3 -19
- package/dist/terraform/modules/app/lambda-api/outputs.tf +50 -6
- package/dist/terraform/modules/app/lambda-api/runtime-config.tf +163 -0
- package/dist/terraform/modules/app/lambda-api/slack-app-secrets.tf +3 -1
- package/dist/terraform/modules/app/lambda-api/stripe-secrets.tf +3 -1
- package/dist/terraform/modules/app/lambda-api/variables.tf +240 -53
- package/dist/terraform/modules/app/lambda-api/workspace-events.tf +2 -25
- package/dist/terraform/modules/app/ses-email/main.tf +143 -30
- package/dist/terraform/modules/app/static-site/main.tf +4 -3
- package/dist/terraform/modules/app/www-dns/main.tf +45 -5
- package/dist/terraform/modules/app/www-dns/outputs.tf +10 -0
- package/dist/terraform/modules/app/www-dns/variables.tf +24 -0
- package/dist/terraform/modules/data/aurora-postgres/main.tf +15 -1
- package/dist/terraform/modules/data/compliance-exports-bucket/main.tf +1 -1
- package/dist/terraform/modules/data/compliance-exports-bucket/variables.tf +6 -0
- package/dist/terraform/modules/foundation/cognito/main.tf +167 -11
- package/dist/terraform/modules/foundation/cognito/outputs.tf +5 -0
- package/dist/terraform/modules/foundation/cognito/variables.tf +110 -0
- package/dist/terraform/modules/foundation/vpc/main.tf +45 -3
- package/dist/terraform/modules/foundation/vpc/outputs.tf +20 -0
- package/dist/terraform/modules/foundation/vpc/variables.tf +18 -0
- package/dist/terraform/modules/thinkwork/README.md +71 -0
- package/dist/terraform/modules/thinkwork/main.tf +1325 -174
- package/dist/terraform/modules/thinkwork/outputs.tf +457 -24
- package/dist/terraform/modules/thinkwork/variables.tf +1169 -93
- package/dist/terraform/schema.graphql +82 -9
- package/dist/workspace-defaults/files/AGENTS.md +344 -0
- package/dist/workspace-defaults/files/CONTEXT.md +8 -0
- package/dist/workspace-defaults/files/GUARDRAILS.md +51 -0
- package/dist/workspace-defaults/files/MEMORY_GUIDE.md +65 -0
- package/dist/workspace-defaults/files/ROUTER.md +24 -0
- package/dist/workspace-defaults/files/SPACE.md +62 -0
- package/dist/workspace-defaults/files/TOOLS.md +65 -0
- package/dist/workspace-defaults/files/USER.md +40 -0
- package/dist/workspace-defaults/files/mcp.json +3 -0
- package/dist/workspace-defaults/files/memory/contacts.md +13 -0
- package/dist/workspace-defaults/files/memory/lessons.md +13 -0
- package/dist/workspace-defaults/files/memory/preferences.md +12 -0
- package/dist/workspace-defaults/files/skills/.gitkeep +1 -0
- package/dist/workspace-defaults/files/skills/artifact-builder/SKILL.md +79 -0
- package/dist/workspace-defaults/files/skills/artifact-builder/references/crm-dashboard.md +166 -0
- package/dist/workspace-defaults/files/skills/automation-loop-designer/SKILL.md +120 -0
- package/dist/workspace-defaults/files/skills/skill-creator/LICENSE.txt +202 -0
- package/dist/workspace-defaults/files/skills/skill-creator/SKILL.md +485 -0
- package/dist/workspace-defaults/files/skills/skill-creator/UPSTREAM.json +124 -0
- package/dist/workspace-defaults/files/skills/skill-creator/agents/analyzer.md +274 -0
- package/dist/workspace-defaults/files/skills/skill-creator/agents/comparator.md +202 -0
- package/dist/workspace-defaults/files/skills/skill-creator/agents/grader.md +223 -0
- package/dist/workspace-defaults/files/skills/skill-creator/assets/eval_review.html +146 -0
- package/dist/workspace-defaults/files/skills/skill-creator/eval-viewer/generate_review.py +471 -0
- package/dist/workspace-defaults/files/skills/skill-creator/eval-viewer/viewer.html +1325 -0
- package/dist/workspace-defaults/files/skills/skill-creator/references/schemas.md +430 -0
- package/dist/workspace-defaults/files/skills/skill-creator/scripts/__init__.py +0 -0
- package/dist/workspace-defaults/files/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- package/dist/workspace-defaults/files/skills/skill-creator/scripts/generate_report.py +326 -0
- package/dist/workspace-defaults/files/skills/skill-creator/scripts/improve_description.py +247 -0
- package/dist/workspace-defaults/files/skills/skill-creator/scripts/package_skill.py +136 -0
- package/dist/workspace-defaults/files/skills/skill-creator/scripts/quick_validate.py +103 -0
- package/dist/workspace-defaults/files/skills/skill-creator/scripts/run_eval.py +310 -0
- package/dist/workspace-defaults/files/skills/skill-creator/scripts/run_loop.py +328 -0
- package/dist/workspace-defaults/files/skills/skill-creator/scripts/utils.py +47 -0
- package/package.json +1 -1
- package/dist/terraform/modules/app/agentcore-flue/README.md +0 -58
- package/dist/terraform/modules/app/agentcore-flue/main.tf +0 -323
- package/dist/terraform/modules/app/agentcore-flue/outputs.tf +0 -23
- package/dist/terraform/modules/app/agentcore-flue/variables.tf +0 -97
- package/dist/terraform/modules/app/computer-runtime/README.md +0 -15
- package/dist/terraform/modules/app/computer-runtime/main.tf +0 -406
- package/dist/terraform/modules/app/computer-runtime/outputs.tf +0 -75
- package/dist/terraform/modules/app/computer-runtime/variables.tf +0 -66
|
@@ -0,0 +1,4720 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
import subprocess
|
|
9
|
+
import tarfile
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from tempfile import TemporaryDirectory
|
|
15
|
+
|
|
16
|
+
WORK = Path("/tmp/thinkwork-platform-deploy")
|
|
17
|
+
RELEASE = WORK / "release"
|
|
18
|
+
SOURCE = WORK / "source"
|
|
19
|
+
TF = WORK / "terraform"
|
|
20
|
+
MANIFEST = RELEASE / "thinkwork-release.json"
|
|
21
|
+
CLOUDFLARE_PROVIDER_VERSION = "4.52.7"
|
|
22
|
+
CLOUDFLARE_PROVIDER_LINUX_AMD64_SHA256 = (
|
|
23
|
+
"904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13"
|
|
24
|
+
)
|
|
25
|
+
CLOUDFLARE_PROVIDER_LINUX_AMD64_URL = (
|
|
26
|
+
"https://github.com/cloudflare/terraform-provider-cloudflare/releases/download/"
|
|
27
|
+
f"v{CLOUDFLARE_PROVIDER_VERSION}/"
|
|
28
|
+
f"terraform-provider-cloudflare_{CLOUDFLARE_PROVIDER_VERSION}_linux_amd64.zip"
|
|
29
|
+
)
|
|
30
|
+
STARTED_AT = datetime.now(UTC).isoformat()
|
|
31
|
+
RELEASE_EVIDENCE = {}
|
|
32
|
+
CONTROLLER_EVIDENCE = {}
|
|
33
|
+
TERRAFORM_EVIDENCE = {}
|
|
34
|
+
FIRST_ADMIN_EVIDENCE = {}
|
|
35
|
+
MANAGED_APP_EVIDENCE = {}
|
|
36
|
+
RELEASE_MANIFEST_TRUST_POLICIES = {
|
|
37
|
+
"allow_unsigned_canary",
|
|
38
|
+
"require_signature",
|
|
39
|
+
}
|
|
40
|
+
# Migrations that intentionally re-run after seed data lands (idempotent;
|
|
41
|
+
# they backfill from seeded rows). Everything else is ledger-driven.
|
|
42
|
+
POST_SEED_MIGRATIONS = [
|
|
43
|
+
"0155_tenant_model_catalog.sql",
|
|
44
|
+
]
|
|
45
|
+
MIGRATION_MARKER_KINDS = [
|
|
46
|
+
("-- creates-column:", "column"),
|
|
47
|
+
("-- creates-constraint:", "constraint"),
|
|
48
|
+
("-- creates:", "object"),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run(args, **kwargs):
|
|
53
|
+
return subprocess.run(args, check=True, text=True, **kwargs)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def output(args, **kwargs):
|
|
57
|
+
return subprocess.check_output(args, text=True, **kwargs).strip()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def sha256_file(path):
|
|
61
|
+
digest = hashlib.sha256()
|
|
62
|
+
with open(path, "rb") as handle:
|
|
63
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
64
|
+
digest.update(chunk)
|
|
65
|
+
return digest.hexdigest()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def read_json_env(name, default):
|
|
69
|
+
value = os.environ.get(name)
|
|
70
|
+
if not value:
|
|
71
|
+
return default
|
|
72
|
+
return json.loads(value)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def download(url, destination):
|
|
76
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
with urllib.request.urlopen(url, timeout=120) as response:
|
|
78
|
+
destination.write_bytes(response.read())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def configure_terraform_provider_mirror():
|
|
82
|
+
system = platform.system().lower()
|
|
83
|
+
machine = platform.machine().lower()
|
|
84
|
+
if system != "linux" or machine not in {"x86_64", "amd64"}:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
mirror = WORK / "provider-mirror"
|
|
88
|
+
package = (
|
|
89
|
+
mirror
|
|
90
|
+
/ "registry.terraform.io"
|
|
91
|
+
/ "cloudflare"
|
|
92
|
+
/ "cloudflare"
|
|
93
|
+
/ f"terraform-provider-cloudflare_{CLOUDFLARE_PROVIDER_VERSION}_linux_amd64.zip"
|
|
94
|
+
)
|
|
95
|
+
if not package.exists() or sha256_file(package) != CLOUDFLARE_PROVIDER_LINUX_AMD64_SHA256:
|
|
96
|
+
download(CLOUDFLARE_PROVIDER_LINUX_AMD64_URL, package)
|
|
97
|
+
|
|
98
|
+
digest = sha256_file(package)
|
|
99
|
+
if digest != CLOUDFLARE_PROVIDER_LINUX_AMD64_SHA256:
|
|
100
|
+
raise RuntimeError(
|
|
101
|
+
"Cloudflare provider mirror digest mismatch: "
|
|
102
|
+
f"expected {CLOUDFLARE_PROVIDER_LINUX_AMD64_SHA256}, got {digest}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
terraformrc = WORK / "terraformrc"
|
|
106
|
+
terraformrc.write_text(
|
|
107
|
+
f"""
|
|
108
|
+
provider_installation {{
|
|
109
|
+
filesystem_mirror {{
|
|
110
|
+
path = "{mirror}"
|
|
111
|
+
include = ["registry.terraform.io/cloudflare/cloudflare"]
|
|
112
|
+
}}
|
|
113
|
+
direct {{
|
|
114
|
+
exclude = ["registry.terraform.io/cloudflare/cloudflare"]
|
|
115
|
+
}}
|
|
116
|
+
}}
|
|
117
|
+
""".lstrip(),
|
|
118
|
+
encoding="utf-8",
|
|
119
|
+
)
|
|
120
|
+
os.environ["TF_CLI_CONFIG_FILE"] = str(terraformrc)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def patch_downloaded_customer_domain_module():
|
|
124
|
+
module_path = (
|
|
125
|
+
TF
|
|
126
|
+
/ ".terraform"
|
|
127
|
+
/ "modules"
|
|
128
|
+
/ "thinkwork"
|
|
129
|
+
/ "terraform"
|
|
130
|
+
/ "modules"
|
|
131
|
+
/ "app"
|
|
132
|
+
/ "customer-domain"
|
|
133
|
+
/ "main.tf"
|
|
134
|
+
)
|
|
135
|
+
if not module_path.exists():
|
|
136
|
+
return
|
|
137
|
+
text = module_path.read_text()
|
|
138
|
+
if 'resource "aws_route53_record" "app_alias_a"' not in text:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
def ensure_allow_overwrite(resource_name, record_type, source):
|
|
142
|
+
marker = f'resource "aws_route53_record" "{resource_name}" {{'
|
|
143
|
+
start = source.find(marker)
|
|
144
|
+
if start == -1:
|
|
145
|
+
return source
|
|
146
|
+
next_resource = source.find('\nresource "', start + len(marker))
|
|
147
|
+
end = next_resource if next_resource != -1 else len(source)
|
|
148
|
+
block = source[start:end]
|
|
149
|
+
if "allow_overwrite" in block:
|
|
150
|
+
return source
|
|
151
|
+
insert_after = block.find(f'type = "{record_type}"')
|
|
152
|
+
if insert_after == -1:
|
|
153
|
+
return source
|
|
154
|
+
line_end = block.find("\n", insert_after)
|
|
155
|
+
if line_end == -1:
|
|
156
|
+
return source
|
|
157
|
+
patched_block = block[: line_end + 1] + " allow_overwrite = true\n" + block[line_end + 1 :]
|
|
158
|
+
return source[:start] + patched_block + source[end:]
|
|
159
|
+
|
|
160
|
+
patched = ensure_allow_overwrite("app_alias_a", "A", text)
|
|
161
|
+
patched = ensure_allow_overwrite("app_alias_aaaa", "AAAA", patched)
|
|
162
|
+
if patched != text:
|
|
163
|
+
module_path.write_text(patched)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def stable_json_bytes(value):
|
|
167
|
+
return json.dumps(
|
|
168
|
+
value,
|
|
169
|
+
sort_keys=True,
|
|
170
|
+
separators=(",", ":"),
|
|
171
|
+
ensure_ascii=False,
|
|
172
|
+
).encode("utf-8")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def release_manifest_sha256(manifest):
|
|
176
|
+
return hashlib.sha256(stable_json_bytes(manifest)).hexdigest()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def read_release_manifest(path=MANIFEST):
|
|
180
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def verify_release_manifest_digest(path, expected_sha256):
|
|
184
|
+
actual = sha256_file(path)
|
|
185
|
+
expected = (expected_sha256 or "").lower()
|
|
186
|
+
if expected and actual != expected:
|
|
187
|
+
raise RuntimeError(f"Release manifest digest mismatch: expected {expected}, got {actual}")
|
|
188
|
+
manifest = read_release_manifest(path)
|
|
189
|
+
return manifest
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def release_manifest_trust_policy():
|
|
193
|
+
policy = os.environ.get(
|
|
194
|
+
"THINKWORK_RELEASE_MANIFEST_TRUST_POLICY",
|
|
195
|
+
"allow_unsigned_canary",
|
|
196
|
+
).strip()
|
|
197
|
+
if not policy:
|
|
198
|
+
policy = "allow_unsigned_canary"
|
|
199
|
+
if policy not in RELEASE_MANIFEST_TRUST_POLICIES:
|
|
200
|
+
raise RuntimeError(
|
|
201
|
+
"Unsupported release manifest trust policy "
|
|
202
|
+
f"{policy!r}; expected one of {sorted(RELEASE_MANIFEST_TRUST_POLICIES)}"
|
|
203
|
+
)
|
|
204
|
+
return policy
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def is_canary_release(manifest):
|
|
208
|
+
version = str(
|
|
209
|
+
manifest.get("release", {}).get("version")
|
|
210
|
+
or os.environ.get("THINKWORK_RELEASE_VERSION")
|
|
211
|
+
or ""
|
|
212
|
+
)
|
|
213
|
+
return "-canary" in version
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def default_signature_url(manifest_url):
|
|
217
|
+
if manifest_url.endswith("/thinkwork-release.json"):
|
|
218
|
+
return manifest_url[: -len("thinkwork-release.json")] + "thinkwork-release.sig.json"
|
|
219
|
+
if manifest_url.endswith("thinkwork-release.json"):
|
|
220
|
+
return manifest_url[: -len("thinkwork-release.json")] + "thinkwork-release.sig.json"
|
|
221
|
+
return ""
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def trusted_release_keys():
|
|
225
|
+
raw = os.environ.get("THINKWORK_RELEASE_MANIFEST_TRUSTED_KEYS_JSON", "[]")
|
|
226
|
+
try:
|
|
227
|
+
keys = json.loads(raw)
|
|
228
|
+
except json.JSONDecodeError as exc:
|
|
229
|
+
raise RuntimeError("THINKWORK_RELEASE_MANIFEST_TRUSTED_KEYS_JSON must be JSON") from exc
|
|
230
|
+
if not isinstance(keys, list):
|
|
231
|
+
raise RuntimeError("THINKWORK_RELEASE_MANIFEST_TRUSTED_KEYS_JSON must be a JSON array")
|
|
232
|
+
return keys
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def require_string(value, path):
|
|
236
|
+
if not isinstance(value, str) or not value:
|
|
237
|
+
raise RuntimeError(f"{path} is required")
|
|
238
|
+
return value
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def assert_time_window(now, not_before, expires_at, label):
|
|
242
|
+
start = datetime.fromisoformat(
|
|
243
|
+
require_string(not_before, f"{label}.notBefore").replace("Z", "+00:00")
|
|
244
|
+
)
|
|
245
|
+
end = datetime.fromisoformat(
|
|
246
|
+
require_string(expires_at, f"{label}.expiresAt").replace("Z", "+00:00")
|
|
247
|
+
)
|
|
248
|
+
if now < start:
|
|
249
|
+
raise RuntimeError(f"{label} is not valid before {not_before}")
|
|
250
|
+
if now > end:
|
|
251
|
+
raise RuntimeError(f"{label} expired at {expires_at}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def verify_signature_bytes(public_key_pem, signed_bytes, signature_bytes):
|
|
255
|
+
with TemporaryDirectory() as temp_dir:
|
|
256
|
+
temp = Path(temp_dir)
|
|
257
|
+
key_path = temp / "trusted-release-key.pem"
|
|
258
|
+
payload_path = temp / "manifest.canonical.json"
|
|
259
|
+
signature_path = temp / "thinkwork-release.sig"
|
|
260
|
+
key_path.write_text(public_key_pem, encoding="utf-8")
|
|
261
|
+
payload_path.write_bytes(signed_bytes)
|
|
262
|
+
signature_path.write_bytes(signature_bytes)
|
|
263
|
+
result = subprocess.run(
|
|
264
|
+
[
|
|
265
|
+
"openssl",
|
|
266
|
+
"pkeyutl",
|
|
267
|
+
"-verify",
|
|
268
|
+
"-rawin",
|
|
269
|
+
"-pubin",
|
|
270
|
+
"-inkey",
|
|
271
|
+
str(key_path),
|
|
272
|
+
"-sigfile",
|
|
273
|
+
str(signature_path),
|
|
274
|
+
"-in",
|
|
275
|
+
str(payload_path),
|
|
276
|
+
],
|
|
277
|
+
capture_output=True,
|
|
278
|
+
text=True,
|
|
279
|
+
)
|
|
280
|
+
if result.returncode != 0:
|
|
281
|
+
raise RuntimeError(f"Release manifest signature is invalid: {result.stderr.strip()}")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def verify_release_manifest_signature(manifest, manifest_sha256, signature_url):
|
|
285
|
+
signature_path = RELEASE / "thinkwork-release.sig.json"
|
|
286
|
+
download(signature_url, signature_path)
|
|
287
|
+
signature = json.loads(signature_path.read_text(encoding="utf-8"))
|
|
288
|
+
if signature.get("schemaVersion") != 1:
|
|
289
|
+
raise RuntimeError("Release manifest signature schemaVersion must be 1")
|
|
290
|
+
if signature.get("algorithm") != "ed25519":
|
|
291
|
+
raise RuntimeError("Release manifest signature algorithm must be ed25519")
|
|
292
|
+
key_id = require_string(signature.get("keyId"), "signature.keyId")
|
|
293
|
+
if signature.get("manifestSha256") != manifest_sha256:
|
|
294
|
+
raise RuntimeError(
|
|
295
|
+
"Release manifest signature digest mismatch: "
|
|
296
|
+
f"expected {signature.get('manifestSha256')}, got {manifest_sha256}"
|
|
297
|
+
)
|
|
298
|
+
signing = manifest.get("signing") or {}
|
|
299
|
+
accepted_key_ids = signing.get("acceptedKeyIds") or []
|
|
300
|
+
revoked_key_ids = set(signing.get("revokedKeyIds") or [])
|
|
301
|
+
if key_id in revoked_key_ids:
|
|
302
|
+
raise RuntimeError(f"Release manifest signing key is revoked: {key_id}")
|
|
303
|
+
if key_id not in accepted_key_ids:
|
|
304
|
+
raise RuntimeError(f"Release manifest does not accept signing key: {key_id}")
|
|
305
|
+
now = datetime.now(UTC)
|
|
306
|
+
assert_time_window(now, signature.get("notBefore"), signature.get("expiresAt"), "signature")
|
|
307
|
+
trusted_key = next((key for key in trusted_release_keys() if key.get("keyId") == key_id), None)
|
|
308
|
+
if not trusted_key:
|
|
309
|
+
raise RuntimeError(f"Release manifest signing key is not trusted: {key_id}")
|
|
310
|
+
if trusted_key.get("notBefore") or trusted_key.get("expiresAt"):
|
|
311
|
+
assert_time_window(
|
|
312
|
+
now,
|
|
313
|
+
trusted_key.get("notBefore", "1970-01-01T00:00:00.000Z"),
|
|
314
|
+
trusted_key.get("expiresAt", "9999-12-31T23:59:59.999Z"),
|
|
315
|
+
f"trusted key {key_id}",
|
|
316
|
+
)
|
|
317
|
+
verify_signature_bytes(
|
|
318
|
+
require_string(trusted_key.get("publicKeyPem"), f"trusted key {key_id}.publicKeyPem"),
|
|
319
|
+
stable_json_bytes(manifest),
|
|
320
|
+
base64.b64decode(require_string(signature.get("signature"), "signature.signature")),
|
|
321
|
+
)
|
|
322
|
+
return {
|
|
323
|
+
"signatureVerified": True,
|
|
324
|
+
"keyId": key_id,
|
|
325
|
+
"signatureUrl": signature_url,
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def enforce_release_manifest_trust(manifest, manifest_digest, manifest_url):
|
|
330
|
+
policy = release_manifest_trust_policy()
|
|
331
|
+
configured_signature_url = os.environ.get("THINKWORK_RELEASE_MANIFEST_SIGNATURE_URL", "")
|
|
332
|
+
signature_url = configured_signature_url or default_signature_url(manifest_url)
|
|
333
|
+
evidence = {
|
|
334
|
+
"policy": policy,
|
|
335
|
+
"signatureRequired": policy == "require_signature",
|
|
336
|
+
"signatureVerified": False,
|
|
337
|
+
"unsignedCanaryAllowed": False,
|
|
338
|
+
}
|
|
339
|
+
if policy == "require_signature":
|
|
340
|
+
if not signature_url:
|
|
341
|
+
raise RuntimeError("Release manifest signature URL is required by trust policy")
|
|
342
|
+
evidence.update(verify_release_manifest_signature(manifest, manifest_digest, signature_url))
|
|
343
|
+
return evidence
|
|
344
|
+
|
|
345
|
+
if configured_signature_url:
|
|
346
|
+
evidence.update(
|
|
347
|
+
verify_release_manifest_signature(manifest, manifest_digest, configured_signature_url)
|
|
348
|
+
)
|
|
349
|
+
return evidence
|
|
350
|
+
|
|
351
|
+
if not is_canary_release(manifest):
|
|
352
|
+
raise RuntimeError(
|
|
353
|
+
"Unsigned release manifest is only allowed for canary releases; "
|
|
354
|
+
"set THINKWORK_RELEASE_MANIFEST_TRUST_POLICY=require_signature for customer-safe runs"
|
|
355
|
+
)
|
|
356
|
+
evidence["unsignedCanaryAllowed"] = True
|
|
357
|
+
return evidence
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def safe_join(base, relative_path):
|
|
361
|
+
relative = Path(relative_path)
|
|
362
|
+
if relative.is_absolute():
|
|
363
|
+
raise RuntimeError(f"Archive member path must be relative: {relative_path}")
|
|
364
|
+
resolved_base = base.resolve()
|
|
365
|
+
resolved = (base / relative).resolve()
|
|
366
|
+
if resolved != resolved_base and resolved_base not in resolved.parents:
|
|
367
|
+
raise RuntimeError(f"Archive member escapes destination: {relative_path}")
|
|
368
|
+
return resolved
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def safe_extract_tar_file(archive_path, destination):
|
|
372
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
373
|
+
with tarfile.open(archive_path, "r:*") as tar:
|
|
374
|
+
members = tar.getmembers()
|
|
375
|
+
for member in members:
|
|
376
|
+
if member.issym() or member.islnk():
|
|
377
|
+
raise RuntimeError(f"Archive member links are not allowed: {member.name}")
|
|
378
|
+
if not (member.isfile() or member.isdir()):
|
|
379
|
+
raise RuntimeError(f"Archive member type is not allowed: {member.name}")
|
|
380
|
+
safe_join(destination, member.name)
|
|
381
|
+
tar.extractall(destination, members=members)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def release_artifacts_by_name(manifest):
|
|
385
|
+
return {
|
|
386
|
+
artifact.get("name"): artifact
|
|
387
|
+
for artifact in manifest.get("artifacts", [])
|
|
388
|
+
if isinstance(artifact.get("name"), str)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def artifact_bundle_url(bundle):
|
|
393
|
+
url = bundle.get("url")
|
|
394
|
+
if not url:
|
|
395
|
+
raise RuntimeError(f"Release artifact bundle {bundle.get('name')} is missing url")
|
|
396
|
+
return url
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def bundle_extract_dir(bundle):
|
|
400
|
+
name = str(bundle.get("name") or "platform")
|
|
401
|
+
safe_name = "".join(ch if ch.isalnum() or ch in "._=-" else "_" for ch in name)
|
|
402
|
+
return RELEASE / "bundles" / safe_name
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def download_and_extract_artifact_bundles(manifest):
|
|
406
|
+
artifacts = release_artifacts_by_name(manifest)
|
|
407
|
+
bundled_paths = {}
|
|
408
|
+
bundle_evidence = []
|
|
409
|
+
|
|
410
|
+
for bundle in manifest.get("artifactBundles", []) or []:
|
|
411
|
+
bundle_name = bundle.get("name")
|
|
412
|
+
bundle_path = safe_join(
|
|
413
|
+
RELEASE,
|
|
414
|
+
str(bundle.get("relativePath") or bundle.get("fileName")),
|
|
415
|
+
)
|
|
416
|
+
download(artifact_bundle_url(bundle), bundle_path)
|
|
417
|
+
digest = sha256_file(bundle_path)
|
|
418
|
+
if digest != bundle.get("sha256"):
|
|
419
|
+
raise RuntimeError(f"Artifact bundle digest mismatch for {bundle_name}")
|
|
420
|
+
|
|
421
|
+
extract_dir = bundle_extract_dir(bundle)
|
|
422
|
+
safe_extract_tar_file(bundle_path, extract_dir)
|
|
423
|
+
|
|
424
|
+
contained = []
|
|
425
|
+
for artifact_name in bundle.get("contains", []):
|
|
426
|
+
artifact = artifacts.get(artifact_name)
|
|
427
|
+
if not artifact:
|
|
428
|
+
raise RuntimeError(
|
|
429
|
+
f"Release artifact bundle {bundle_name} references unknown artifact {artifact_name}"
|
|
430
|
+
)
|
|
431
|
+
artifact_path = safe_join(extract_dir, artifact["relativePath"])
|
|
432
|
+
if not artifact_path.is_file():
|
|
433
|
+
raise RuntimeError(
|
|
434
|
+
f"Release artifact {artifact_name} is missing from bundle {bundle_name}"
|
|
435
|
+
)
|
|
436
|
+
bundled_paths[artifact_name] = artifact_path
|
|
437
|
+
contained.append(artifact_name)
|
|
438
|
+
|
|
439
|
+
bundle_evidence.append(
|
|
440
|
+
{
|
|
441
|
+
"name": bundle_name,
|
|
442
|
+
"fileName": bundle.get("fileName"),
|
|
443
|
+
"sha256": digest,
|
|
444
|
+
"contains": contained,
|
|
445
|
+
}
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
return bundled_paths, bundle_evidence
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def materialize_release_artifact(artifact, bundled_paths):
|
|
452
|
+
destination = safe_join(RELEASE, artifact["relativePath"])
|
|
453
|
+
url = artifact.get("url")
|
|
454
|
+
if url:
|
|
455
|
+
download(url, destination)
|
|
456
|
+
source = "url"
|
|
457
|
+
else:
|
|
458
|
+
bundled_path = bundled_paths.get(artifact.get("name"))
|
|
459
|
+
if not bundled_path:
|
|
460
|
+
raise RuntimeError(
|
|
461
|
+
f"Release artifact {artifact.get('name')} is missing url and is not available in an artifact bundle"
|
|
462
|
+
)
|
|
463
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
464
|
+
if destination.resolve() != bundled_path.resolve():
|
|
465
|
+
destination.write_bytes(bundled_path.read_bytes())
|
|
466
|
+
source = "bundle"
|
|
467
|
+
|
|
468
|
+
digest = sha256_file(destination)
|
|
469
|
+
if digest != artifact.get("sha256"):
|
|
470
|
+
raise RuntimeError(f"Artifact digest mismatch for {artifact.get('name')}")
|
|
471
|
+
return destination, digest, source
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def evidence_s3_uri(name):
|
|
475
|
+
prefix = os.environ.get("THINKWORK_EVIDENCE_PREFIX")
|
|
476
|
+
bucket = os.environ.get("THINKWORK_EVIDENCE_BUCKET")
|
|
477
|
+
if not prefix or not bucket:
|
|
478
|
+
return ""
|
|
479
|
+
return f"s3://{bucket}/{prefix}/{name}"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def upload_evidence_artifact(path, name=None):
|
|
483
|
+
artifact_name = name or Path(path).name
|
|
484
|
+
uri = evidence_s3_uri(artifact_name)
|
|
485
|
+
if uri:
|
|
486
|
+
run(["aws", "s3", "cp", str(path), uri])
|
|
487
|
+
return uri
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def write_json_evidence_artifact(name, payload):
|
|
491
|
+
path = Path(name)
|
|
492
|
+
path.write_text(
|
|
493
|
+
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
|
494
|
+
encoding="utf-8",
|
|
495
|
+
)
|
|
496
|
+
return {
|
|
497
|
+
"fileName": name,
|
|
498
|
+
"sha256": sha256_file(path),
|
|
499
|
+
"s3Uri": upload_evidence_artifact(path, name),
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def redacted_tfvars(vars_json):
|
|
504
|
+
redacted = dict(vars_json)
|
|
505
|
+
for key in [
|
|
506
|
+
"api_auth_secret",
|
|
507
|
+
"db_password",
|
|
508
|
+
"google_oauth_client_secret",
|
|
509
|
+
]:
|
|
510
|
+
if key in redacted:
|
|
511
|
+
redacted[key] = "[redacted]"
|
|
512
|
+
return redacted
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def controller_input_summary(payload):
|
|
516
|
+
release = payload.get("release")
|
|
517
|
+
if not isinstance(release, dict):
|
|
518
|
+
release = {
|
|
519
|
+
"version": payload.get("releaseVersion") or os.environ.get("THINKWORK_RELEASE_VERSION"),
|
|
520
|
+
"manifestUrl": payload.get("releaseManifestUrl")
|
|
521
|
+
or os.environ.get("THINKWORK_RELEASE_MANIFEST_URL"),
|
|
522
|
+
"manifestSha256": payload.get("releaseManifestSha256")
|
|
523
|
+
or os.environ.get("THINKWORK_RELEASE_MANIFEST_SHA256"),
|
|
524
|
+
}
|
|
525
|
+
return {
|
|
526
|
+
"schemaVersion": payload.get("schemaVersion"),
|
|
527
|
+
"contract": payload.get("contract"),
|
|
528
|
+
"phase": payload.get("phase"),
|
|
529
|
+
"action": payload.get("action"),
|
|
530
|
+
"sessionId": payload.get("sessionId"),
|
|
531
|
+
"customer": {
|
|
532
|
+
"name": payload.get("customerName"),
|
|
533
|
+
"environmentName": payload.get("environmentName"),
|
|
534
|
+
"awsAccountId": payload.get("awsAccountId"),
|
|
535
|
+
"awsRegion": payload.get("awsRegion"),
|
|
536
|
+
"availabilityZones": payload.get("availabilityZones"),
|
|
537
|
+
},
|
|
538
|
+
"evidence": payload.get("evidence")
|
|
539
|
+
or {
|
|
540
|
+
"bucket": payload.get("evidenceBucket") or os.environ.get("THINKWORK_EVIDENCE_BUCKET"),
|
|
541
|
+
"prefix": os.environ.get("THINKWORK_EVIDENCE_PREFIX"),
|
|
542
|
+
},
|
|
543
|
+
"features": payload.get("features")
|
|
544
|
+
or {
|
|
545
|
+
"baseInstall": {
|
|
546
|
+
"cognee": False,
|
|
547
|
+
"slack": False,
|
|
548
|
+
"stripe": False,
|
|
549
|
+
"twenty": False,
|
|
550
|
+
},
|
|
551
|
+
"optionalApps": [],
|
|
552
|
+
},
|
|
553
|
+
"operation": payload.get("operation"),
|
|
554
|
+
"release": release,
|
|
555
|
+
"terraform": payload.get("terraform"),
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def controller_identity(payload):
|
|
560
|
+
return {
|
|
561
|
+
"stateMachineArn": os.environ.get("THINKWORK_DEPLOYMENT_STATE_MACHINE_ARN")
|
|
562
|
+
or payload.get("stateMachineArn"),
|
|
563
|
+
"stateMachineName": os.environ.get("THINKWORK_DEPLOYMENT_STATE_MACHINE_NAME"),
|
|
564
|
+
"codebuildProjectName": os.environ.get("THINKWORK_DEPLOYMENT_RUNNER_PROJECT_NAME"),
|
|
565
|
+
"codebuildProjectArn": os.environ.get("THINKWORK_DEPLOYMENT_RUNNER_PROJECT_ARN"),
|
|
566
|
+
"evidenceBucketName": os.environ.get("THINKWORK_EVIDENCE_BUCKET"),
|
|
567
|
+
"ssmPrefix": os.environ.get("THINKWORK_SSM_PREFIX"),
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def release_selection(payload):
|
|
572
|
+
release = payload.get("release")
|
|
573
|
+
if isinstance(release, dict):
|
|
574
|
+
return {
|
|
575
|
+
"version": release.get("version")
|
|
576
|
+
or payload.get("releaseVersion")
|
|
577
|
+
or os.environ.get("THINKWORK_RELEASE_VERSION"),
|
|
578
|
+
"manifestUrl": release.get("manifestUrl")
|
|
579
|
+
or payload.get("releaseManifestUrl")
|
|
580
|
+
or os.environ.get("THINKWORK_RELEASE_MANIFEST_URL"),
|
|
581
|
+
"manifestSha256": release.get("manifestSha256")
|
|
582
|
+
or payload.get("releaseManifestSha256")
|
|
583
|
+
or os.environ.get("THINKWORK_RELEASE_MANIFEST_SHA256"),
|
|
584
|
+
"manifestSignatureUrl": release.get("manifestSignatureUrl")
|
|
585
|
+
or payload.get("releaseManifestSignatureUrl")
|
|
586
|
+
or os.environ.get("THINKWORK_RELEASE_MANIFEST_SIGNATURE_URL"),
|
|
587
|
+
"manifestTrustPolicy": release.get("manifestTrustPolicy")
|
|
588
|
+
or payload.get("releaseManifestTrustPolicy")
|
|
589
|
+
or os.environ.get("THINKWORK_RELEASE_MANIFEST_TRUST_POLICY"),
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
"version": payload.get("releaseVersion") or os.environ.get("THINKWORK_RELEASE_VERSION"),
|
|
593
|
+
"manifestUrl": payload.get("releaseManifestUrl")
|
|
594
|
+
or os.environ.get("THINKWORK_RELEASE_MANIFEST_URL"),
|
|
595
|
+
"manifestSha256": payload.get("releaseManifestSha256")
|
|
596
|
+
or os.environ.get("THINKWORK_RELEASE_MANIFEST_SHA256"),
|
|
597
|
+
"manifestSignatureUrl": payload.get("releaseManifestSignatureUrl")
|
|
598
|
+
or os.environ.get("THINKWORK_RELEASE_MANIFEST_SIGNATURE_URL"),
|
|
599
|
+
"manifestTrustPolicy": payload.get("releaseManifestTrustPolicy")
|
|
600
|
+
or os.environ.get("THINKWORK_RELEASE_MANIFEST_TRUST_POLICY"),
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def apply_release_selection(payload):
|
|
605
|
+
selected = release_selection(payload)
|
|
606
|
+
env_names = {
|
|
607
|
+
"version": "THINKWORK_RELEASE_VERSION",
|
|
608
|
+
"manifestUrl": "THINKWORK_RELEASE_MANIFEST_URL",
|
|
609
|
+
"manifestSha256": "THINKWORK_RELEASE_MANIFEST_SHA256",
|
|
610
|
+
"manifestSignatureUrl": "THINKWORK_RELEASE_MANIFEST_SIGNATURE_URL",
|
|
611
|
+
"manifestTrustPolicy": "THINKWORK_RELEASE_MANIFEST_TRUST_POLICY",
|
|
612
|
+
}
|
|
613
|
+
for key, env_name in env_names.items():
|
|
614
|
+
value = selected.get(key)
|
|
615
|
+
if isinstance(value, str) and value:
|
|
616
|
+
os.environ[env_name] = value
|
|
617
|
+
return selected
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def write_controller_status_evidence(payload):
|
|
621
|
+
proof = {
|
|
622
|
+
"schemaVersion": 1,
|
|
623
|
+
"contract": "thinkwork.deployment.controller.status.v1",
|
|
624
|
+
"status": "ready",
|
|
625
|
+
"action": "status",
|
|
626
|
+
"sessionId": payload.get("sessionId") or os.environ.get("THINKWORK_DEPLOYMENT_SESSION_ID"),
|
|
627
|
+
"checkedAt": datetime.now(UTC).isoformat(),
|
|
628
|
+
"controller": controller_identity(payload),
|
|
629
|
+
"release": release_selection(payload),
|
|
630
|
+
}
|
|
631
|
+
return {
|
|
632
|
+
"proof": proof,
|
|
633
|
+
"artifact": write_json_evidence_artifact("controller-status.json", proof),
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def terraform_plan_summary(plan_json):
|
|
638
|
+
resource_changes = plan_json.get("resource_changes", [])
|
|
639
|
+
by_action = {}
|
|
640
|
+
for change in resource_changes:
|
|
641
|
+
actions = change.get("change", {}).get("actions", [])
|
|
642
|
+
action_key = ",".join(actions) if actions else "unknown"
|
|
643
|
+
by_action[action_key] = by_action.get(action_key, 0) + 1
|
|
644
|
+
return {
|
|
645
|
+
"formatVersion": plan_json.get("format_version"),
|
|
646
|
+
"terraformVersion": plan_json.get("terraform_version"),
|
|
647
|
+
"resourceChangeCount": len(resource_changes),
|
|
648
|
+
"resourceChangesByAction": by_action,
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def managed_app_terraform_target_args(payload):
|
|
653
|
+
app_key = payload.get("appKey")
|
|
654
|
+
if app_key == "n8n":
|
|
655
|
+
return [
|
|
656
|
+
"-target=module.thinkwork.terraform_data.n8n_configuration_guardrails",
|
|
657
|
+
"-target=module.thinkwork.module.n8n",
|
|
658
|
+
"-target=aws_acm_certificate.n8n",
|
|
659
|
+
"-target=cloudflare_record.n8n_acm_validation",
|
|
660
|
+
"-target=aws_acm_certificate_validation.n8n",
|
|
661
|
+
"-target=cloudflare_record.n8n",
|
|
662
|
+
]
|
|
663
|
+
return []
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def truthy(value):
|
|
667
|
+
if isinstance(value, bool):
|
|
668
|
+
return value
|
|
669
|
+
if isinstance(value, str):
|
|
670
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
671
|
+
return False
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def managed_app_state_isolation_enabled(payload):
|
|
675
|
+
features = payload.get("features")
|
|
676
|
+
if isinstance(features, dict) and truthy(features.get("managedAppStateIsolation")):
|
|
677
|
+
return True
|
|
678
|
+
contract = payload.get("operationContract")
|
|
679
|
+
if isinstance(contract, dict) and truthy(contract.get("managedAppStateIsolation")):
|
|
680
|
+
return True
|
|
681
|
+
return truthy(os.environ.get("THINKWORK_MANAGED_APP_STATE_ISOLATION"))
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def managed_app_backend_app_key(payload):
|
|
685
|
+
app_key = str(payload.get("appKey") or "").strip().lower()
|
|
686
|
+
if not app_key:
|
|
687
|
+
raise RuntimeError("Managed app state isolation requires appKey.")
|
|
688
|
+
if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", app_key):
|
|
689
|
+
raise RuntimeError(f"Invalid managed app key for Terraform state: {app_key!r}")
|
|
690
|
+
return app_key
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def terraform_backend_key(stage, payload):
|
|
694
|
+
if is_managed_app_operation(payload) and managed_app_state_isolation_enabled(payload):
|
|
695
|
+
app_key = managed_app_backend_app_key(payload)
|
|
696
|
+
return f"thinkwork/{stage}/managed-apps/{app_key}/terraform.tfstate"
|
|
697
|
+
return f"thinkwork/{stage}/terraform.tfstate"
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def terraform_workspace_name(stage, payload):
|
|
701
|
+
if is_managed_app_operation(payload) and managed_app_state_isolation_enabled(payload):
|
|
702
|
+
# Per-app backend keys already include the stage. Staying on the
|
|
703
|
+
# default workspace keeps the S3 object key exact and gives each app a
|
|
704
|
+
# separate lock row instead of env:/<stage>/... workspace indirection.
|
|
705
|
+
return "default"
|
|
706
|
+
return stage
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def refresh_outputs_after_targeted_apply(payload):
|
|
710
|
+
if not is_managed_app_operation(payload):
|
|
711
|
+
return {"status": "skipped", "reason": "not-managed-app"}
|
|
712
|
+
print("[runner] refreshing Terraform outputs after targeted managed-app apply")
|
|
713
|
+
command = [
|
|
714
|
+
"terraform",
|
|
715
|
+
"apply",
|
|
716
|
+
"-refresh-only",
|
|
717
|
+
"-auto-approve",
|
|
718
|
+
"-no-color",
|
|
719
|
+
]
|
|
720
|
+
try:
|
|
721
|
+
run(command, cwd=TF)
|
|
722
|
+
except subprocess.CalledProcessError as error:
|
|
723
|
+
detail = {
|
|
724
|
+
"status": "failed",
|
|
725
|
+
"command": [str(part) for part in command],
|
|
726
|
+
"exitCode": error.returncode,
|
|
727
|
+
"nonFatal": True,
|
|
728
|
+
}
|
|
729
|
+
TERRAFORM_EVIDENCE["outputRefresh"] = detail
|
|
730
|
+
print(
|
|
731
|
+
f"[runner] managed-app output refresh failed after targeted apply (non-fatal): {error}"
|
|
732
|
+
)
|
|
733
|
+
return detail
|
|
734
|
+
detail = {"status": "succeeded", "command": [str(part) for part in command]}
|
|
735
|
+
TERRAFORM_EVIDENCE["outputRefresh"] = detail
|
|
736
|
+
return detail
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def write_outputs_after_apply(payload, vars_json, outputs_path):
|
|
740
|
+
refresh_outputs_after_targeted_apply(payload)
|
|
741
|
+
try:
|
|
742
|
+
outputs_path.write_text(output(["terraform", "output", "-json"], cwd=TF), encoding="utf-8")
|
|
743
|
+
source = "terraform-output"
|
|
744
|
+
except Exception as error:
|
|
745
|
+
if not is_managed_app_operation(payload):
|
|
746
|
+
raise
|
|
747
|
+
outputs = current_terraform_outputs(str(vars_json.get("stage") or ""))
|
|
748
|
+
if not outputs:
|
|
749
|
+
TERRAFORM_EVIDENCE["outputs"] = {
|
|
750
|
+
"status": "unavailable",
|
|
751
|
+
"source": "state",
|
|
752
|
+
"error": str(error),
|
|
753
|
+
}
|
|
754
|
+
print(
|
|
755
|
+
"[runner] managed-app Terraform outputs unavailable after targeted apply "
|
|
756
|
+
f"(non-fatal): {error}"
|
|
757
|
+
)
|
|
758
|
+
return
|
|
759
|
+
outputs_path.write_text(
|
|
760
|
+
json.dumps(outputs, indent=2, sort_keys=True) + "\n",
|
|
761
|
+
encoding="utf-8",
|
|
762
|
+
)
|
|
763
|
+
TERRAFORM_EVIDENCE["outputReadFallback"] = {
|
|
764
|
+
"status": "succeeded",
|
|
765
|
+
"source": "state",
|
|
766
|
+
"reason": str(error),
|
|
767
|
+
}
|
|
768
|
+
source = "state"
|
|
769
|
+
TERRAFORM_EVIDENCE["outputs"] = {
|
|
770
|
+
"fileName": "terraform-outputs.json",
|
|
771
|
+
"sha256": sha256_file(outputs_path),
|
|
772
|
+
"s3Uri": upload_evidence_artifact(outputs_path, "terraform-outputs.json"),
|
|
773
|
+
"source": source,
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def is_managed_app_operation(payload):
|
|
778
|
+
contract = payload.get("operationContract")
|
|
779
|
+
if isinstance(contract, dict) and contract.get("kind") == "managed_app":
|
|
780
|
+
return True
|
|
781
|
+
return bool(payload.get("appKey"))
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def is_web_only_operation(payload, action=None):
|
|
785
|
+
if action == "web":
|
|
786
|
+
return True
|
|
787
|
+
operation = payload.get("operation")
|
|
788
|
+
if isinstance(operation, dict):
|
|
789
|
+
kind = str(operation.get("kind") or "").strip().lower()
|
|
790
|
+
if kind in {"web", "web-only", "web_only", "static-web"}:
|
|
791
|
+
return True
|
|
792
|
+
component = str(payload.get("component") or "").strip().lower()
|
|
793
|
+
return component == "web" or truthy(payload.get("webOnly"))
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def configure_managed_app_evidence_prefix(payload):
|
|
797
|
+
if not is_managed_app_operation(payload):
|
|
798
|
+
return
|
|
799
|
+
os.environ["THINKWORK_MANAGED_APP_OPERATION"] = "true"
|
|
800
|
+
evidence = payload.get("evidence")
|
|
801
|
+
if isinstance(evidence, dict):
|
|
802
|
+
prefix = evidence.get("prefix")
|
|
803
|
+
if isinstance(prefix, str) and prefix:
|
|
804
|
+
os.environ["THINKWORK_EVIDENCE_PREFIX"] = prefix
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def validate_managed_app_plan_scope(payload, plan_json):
|
|
808
|
+
app_key = payload.get("appKey")
|
|
809
|
+
if app_key != "n8n":
|
|
810
|
+
return
|
|
811
|
+
allowed_prefixes = [
|
|
812
|
+
"module.thinkwork.module.n8n",
|
|
813
|
+
"module.thinkwork.terraform_data.n8n_configuration_guardrails",
|
|
814
|
+
"module.thinkwork.terraform_data.n8n_runtime_state_guardrails",
|
|
815
|
+
"aws_acm_certificate.n8n",
|
|
816
|
+
"cloudflare_record.n8n_acm_validation",
|
|
817
|
+
"aws_acm_certificate_validation.n8n",
|
|
818
|
+
"cloudflare_record.n8n",
|
|
819
|
+
]
|
|
820
|
+
unsafe_changes = []
|
|
821
|
+
for change in plan_json.get("resource_changes", []):
|
|
822
|
+
actions = change.get("change", {}).get("actions", [])
|
|
823
|
+
if not actions or actions == ["no-op"] or actions == ["read"]:
|
|
824
|
+
continue
|
|
825
|
+
address = change.get("address", "")
|
|
826
|
+
if any(address.startswith(prefix) for prefix in allowed_prefixes):
|
|
827
|
+
continue
|
|
828
|
+
unsafe_changes.append(
|
|
829
|
+
{
|
|
830
|
+
"address": address,
|
|
831
|
+
"actions": actions,
|
|
832
|
+
}
|
|
833
|
+
)
|
|
834
|
+
if unsafe_changes:
|
|
835
|
+
preview = ", ".join(
|
|
836
|
+
f"{item['address']}:{'/'.join(item['actions'])}" for item in unsafe_changes[:10]
|
|
837
|
+
)
|
|
838
|
+
raise RuntimeError(
|
|
839
|
+
f"Managed app plan for {app_key} contains non-{app_key} changes; "
|
|
840
|
+
"refusing to continue. "
|
|
841
|
+
f"Examples: {preview}"
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def destructive_plan_actions(actions):
|
|
846
|
+
return "delete" in (actions or [])
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def allow_customer_domain_removal(payload):
|
|
850
|
+
return safe_get_bool({}, payload or {}, "allowCustomerDomainRemoval", default=False)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def validate_environment_plan_scope(payload, plan_json):
|
|
854
|
+
if is_managed_app_operation(payload):
|
|
855
|
+
return
|
|
856
|
+
if allow_customer_domain_removal(payload):
|
|
857
|
+
return
|
|
858
|
+
|
|
859
|
+
unsafe_changes = []
|
|
860
|
+
for change in plan_json.get("resource_changes", []):
|
|
861
|
+
actions = change.get("change", {}).get("actions", [])
|
|
862
|
+
if not actions or actions == ["no-op"] or actions == ["read"]:
|
|
863
|
+
continue
|
|
864
|
+
address = change.get("address", "")
|
|
865
|
+
before = change.get("change", {}).get("before") or {}
|
|
866
|
+
after = change.get("change", {}).get("after") or {}
|
|
867
|
+
|
|
868
|
+
if address.startswith(
|
|
869
|
+
"module.thinkwork.module.customer_domain."
|
|
870
|
+
) and destructive_plan_actions(actions):
|
|
871
|
+
unsafe_changes.append({"address": address, "actions": actions})
|
|
872
|
+
continue
|
|
873
|
+
|
|
874
|
+
if (
|
|
875
|
+
address.startswith("module.thinkwork.module.computer_site.aws_cloudfront_distribution.")
|
|
876
|
+
and "update" in actions
|
|
877
|
+
and customer_domain_cloudfront_alias_removed(before, after)
|
|
878
|
+
):
|
|
879
|
+
unsafe_changes.append({"address": address, "actions": actions})
|
|
880
|
+
|
|
881
|
+
if unsafe_changes:
|
|
882
|
+
preview = ", ".join(
|
|
883
|
+
f"{item['address']}:{'/'.join(item['actions'])}" for item in unsafe_changes[:10]
|
|
884
|
+
)
|
|
885
|
+
raise RuntimeError(
|
|
886
|
+
"Terraform plan would remove customer-domain web resources; refusing to continue. "
|
|
887
|
+
"Preserve customerDomain/customerDomainDelegated in the controller input or runner "
|
|
888
|
+
"secret. Set allowCustomerDomainRemoval=true only for an intentional, reviewed "
|
|
889
|
+
f"domain-retirement operation. Examples: {preview}"
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def customer_domain_cloudfront_alias_removed(before, after):
|
|
894
|
+
before_aliases = set(cloudfront_alias_items(before.get("aliases")))
|
|
895
|
+
after_aliases = set(cloudfront_alias_items(after.get("aliases")))
|
|
896
|
+
if before_aliases and not after_aliases:
|
|
897
|
+
return True
|
|
898
|
+
return bool(before_aliases - after_aliases)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def cloudfront_alias_items(value):
|
|
902
|
+
if isinstance(value, list):
|
|
903
|
+
return [str(item) for item in value if item]
|
|
904
|
+
if isinstance(value, dict):
|
|
905
|
+
items = value.get("items") or value.get("Items")
|
|
906
|
+
if isinstance(items, list):
|
|
907
|
+
return [str(item) for item in items if item]
|
|
908
|
+
return []
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def write_terraform_plan_evidence(payload=None):
|
|
912
|
+
plan_path = Path("terraform-plan.json")
|
|
913
|
+
with plan_path.open("w", encoding="utf-8") as handle:
|
|
914
|
+
run(["terraform", "show", "-json", "tfplan"], cwd=TF, stdout=handle)
|
|
915
|
+
plan_json = json.loads(plan_path.read_text(encoding="utf-8"))
|
|
916
|
+
validate_managed_app_plan_scope(payload or {}, plan_json)
|
|
917
|
+
validate_environment_plan_scope(payload or {}, plan_json)
|
|
918
|
+
artifact = {
|
|
919
|
+
"fileName": plan_path.name,
|
|
920
|
+
"sha256": sha256_file(plan_path),
|
|
921
|
+
"s3Uri": upload_evidence_artifact(plan_path),
|
|
922
|
+
}
|
|
923
|
+
return {
|
|
924
|
+
"artifact": artifact,
|
|
925
|
+
"summary": terraform_plan_summary(plan_json),
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def secret_payload(payload):
|
|
930
|
+
arn = payload.get("runnerSecretArn") or payload.get("deploymentSecretsSecretArn")
|
|
931
|
+
if not arn:
|
|
932
|
+
return {}
|
|
933
|
+
body = output(
|
|
934
|
+
[
|
|
935
|
+
"aws",
|
|
936
|
+
"secretsmanager",
|
|
937
|
+
"get-secret-value",
|
|
938
|
+
"--secret-id",
|
|
939
|
+
arn,
|
|
940
|
+
"--query",
|
|
941
|
+
"SecretString",
|
|
942
|
+
"--output",
|
|
943
|
+
"text",
|
|
944
|
+
]
|
|
945
|
+
)
|
|
946
|
+
return json.loads(body or "{}")
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def safe_get(mapping, *names, default=""):
|
|
950
|
+
for name in names:
|
|
951
|
+
value = mapping.get(name)
|
|
952
|
+
if isinstance(value, str) and value:
|
|
953
|
+
return value
|
|
954
|
+
return default
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def safe_get_bool(runner_secrets, payload, name, default=False):
|
|
958
|
+
"""Boolean analogue of the safe_get(runner_secrets, default=safe_get(payload))
|
|
959
|
+
precedence. Controller payloads carry real JSON booleans, but Secrets
|
|
960
|
+
Manager JSON values frequently arrive as strings ("true"). Generated-root
|
|
961
|
+
variables typed `bool` reject strings, so boolean wiring points must
|
|
962
|
+
round-trip through this helper and always emit real booleans.
|
|
963
|
+
"""
|
|
964
|
+
for source in (runner_secrets, payload):
|
|
965
|
+
value = source.get(name)
|
|
966
|
+
if isinstance(value, bool):
|
|
967
|
+
return value
|
|
968
|
+
if isinstance(value, str) and value:
|
|
969
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
970
|
+
return default
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def current_terraform_state(stage):
|
|
974
|
+
bucket = os.environ.get("THINKWORK_TERRAFORM_STATE_BUCKET")
|
|
975
|
+
if not bucket:
|
|
976
|
+
return {}
|
|
977
|
+
keys = [
|
|
978
|
+
f"env:/{stage}/thinkwork/{stage}/terraform.tfstate",
|
|
979
|
+
f"thinkwork/{stage}/terraform.tfstate",
|
|
980
|
+
]
|
|
981
|
+
for key in keys:
|
|
982
|
+
try:
|
|
983
|
+
body = output(["aws", "s3", "cp", f"s3://{bucket}/{key}", "-"])
|
|
984
|
+
state = json.loads(body)
|
|
985
|
+
if isinstance(state, dict):
|
|
986
|
+
return state
|
|
987
|
+
except Exception:
|
|
988
|
+
continue
|
|
989
|
+
return {}
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def current_terraform_outputs(stage):
|
|
993
|
+
outputs = current_terraform_state(stage).get("outputs")
|
|
994
|
+
return outputs if isinstance(outputs, dict) else {}
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def cloudflare_api_token(stage):
|
|
998
|
+
existing = os.environ.get("CLOUDFLARE_API_TOKEN")
|
|
999
|
+
if existing:
|
|
1000
|
+
return existing
|
|
1001
|
+
parameter_name = f"/thinkwork/{stage}/cloudflare-namespace-token"
|
|
1002
|
+
try:
|
|
1003
|
+
token = output(
|
|
1004
|
+
[
|
|
1005
|
+
"aws",
|
|
1006
|
+
"ssm",
|
|
1007
|
+
"get-parameter",
|
|
1008
|
+
"--name",
|
|
1009
|
+
parameter_name,
|
|
1010
|
+
"--with-decryption",
|
|
1011
|
+
"--query",
|
|
1012
|
+
"Parameter.Value",
|
|
1013
|
+
"--output",
|
|
1014
|
+
"text",
|
|
1015
|
+
]
|
|
1016
|
+
)
|
|
1017
|
+
except Exception:
|
|
1018
|
+
return ""
|
|
1019
|
+
if token:
|
|
1020
|
+
os.environ["CLOUDFLARE_API_TOKEN"] = token
|
|
1021
|
+
return token
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def configure_cloudflare_provider_auth(stage):
|
|
1025
|
+
cloudflare_api_token(stage)
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def cloudflare_zone_id_for_hostname(stage, hostname):
|
|
1029
|
+
configured = os.environ.get("THINKWORK_CLOUDFLARE_ZONE_ID", "")
|
|
1030
|
+
if configured:
|
|
1031
|
+
return configured
|
|
1032
|
+
hostname = (hostname or "").strip().lower().rstrip(".")
|
|
1033
|
+
if not hostname:
|
|
1034
|
+
return ""
|
|
1035
|
+
token = cloudflare_api_token(stage)
|
|
1036
|
+
if not token:
|
|
1037
|
+
return ""
|
|
1038
|
+
request = urllib.request.Request(
|
|
1039
|
+
"https://api.cloudflare.com/client/v4/zones?per_page=50",
|
|
1040
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
1041
|
+
)
|
|
1042
|
+
try:
|
|
1043
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
1044
|
+
body = json.loads(response.read().decode("utf-8"))
|
|
1045
|
+
except Exception:
|
|
1046
|
+
return ""
|
|
1047
|
+
zones = body.get("result")
|
|
1048
|
+
if not isinstance(zones, list):
|
|
1049
|
+
return ""
|
|
1050
|
+
matches = []
|
|
1051
|
+
for zone in zones:
|
|
1052
|
+
if not isinstance(zone, dict):
|
|
1053
|
+
continue
|
|
1054
|
+
name = str(zone.get("name") or "").strip().lower().rstrip(".")
|
|
1055
|
+
zone_id = str(zone.get("id") or "").strip()
|
|
1056
|
+
if not name or not zone_id:
|
|
1057
|
+
continue
|
|
1058
|
+
if hostname == name or hostname.endswith(f".{name}"):
|
|
1059
|
+
matches.append((len(name), zone_id))
|
|
1060
|
+
if not matches:
|
|
1061
|
+
return ""
|
|
1062
|
+
return sorted(matches, reverse=True)[0][1]
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def state_output(outputs, name, default=None):
|
|
1066
|
+
value = outputs.get(name)
|
|
1067
|
+
if isinstance(value, dict) and "value" in value:
|
|
1068
|
+
return value["value"]
|
|
1069
|
+
return default
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def string_state_output(outputs, name):
|
|
1073
|
+
value = state_output(outputs, name, "")
|
|
1074
|
+
return value.strip() if isinstance(value, str) else ""
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def bool_state_output(outputs, name):
|
|
1078
|
+
return bool(state_output(outputs, name, False))
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def has_state_output(outputs, name):
|
|
1082
|
+
value = outputs.get(name)
|
|
1083
|
+
return isinstance(value, dict) and "value" in value
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def enforce_customer_domain_preservation(current_outputs, vars_json, payload, runner_secrets):
|
|
1087
|
+
previous_domain = string_state_output(current_outputs, "customer_domain")
|
|
1088
|
+
if not previous_domain:
|
|
1089
|
+
return
|
|
1090
|
+
|
|
1091
|
+
allow_removal = safe_get_bool(
|
|
1092
|
+
runner_secrets,
|
|
1093
|
+
payload,
|
|
1094
|
+
"allowCustomerDomainRemoval",
|
|
1095
|
+
default=False,
|
|
1096
|
+
)
|
|
1097
|
+
next_domain = str(vars_json.get("customer_domain") or "").strip()
|
|
1098
|
+
if not next_domain:
|
|
1099
|
+
if allow_removal:
|
|
1100
|
+
return
|
|
1101
|
+
vars_json["customer_domain"] = previous_domain
|
|
1102
|
+
vars_json["customer_domain_delegated"] = (
|
|
1103
|
+
bool_state_output(current_outputs, "customer_domain_delegated")
|
|
1104
|
+
if has_state_output(current_outputs, "customer_domain_delegated")
|
|
1105
|
+
else True
|
|
1106
|
+
)
|
|
1107
|
+
vars_json["customer_domain_legacy_retired"] = bool_state_output(
|
|
1108
|
+
current_outputs, "customer_domain_legacy_retired"
|
|
1109
|
+
)
|
|
1110
|
+
return
|
|
1111
|
+
|
|
1112
|
+
if next_domain != previous_domain and not allow_removal:
|
|
1113
|
+
raise RuntimeError(
|
|
1114
|
+
"Refusing to change customer_domain during a controller update. "
|
|
1115
|
+
f"Current Terraform state has {previous_domain!r}, but this run "
|
|
1116
|
+
f"generated {next_domain!r}. Domain changes require an explicit, "
|
|
1117
|
+
"reviewed domain migration with allowCustomerDomainRemoval=true."
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
previous_delegated = (
|
|
1121
|
+
bool_state_output(current_outputs, "customer_domain_delegated")
|
|
1122
|
+
if has_state_output(current_outputs, "customer_domain_delegated")
|
|
1123
|
+
else True
|
|
1124
|
+
)
|
|
1125
|
+
next_delegated = bool(vars_json.get("customer_domain_delegated", False))
|
|
1126
|
+
if previous_delegated and not next_delegated and not allow_removal:
|
|
1127
|
+
raise RuntimeError(
|
|
1128
|
+
"Refusing to turn off customer_domain_delegated during a controller "
|
|
1129
|
+
f"update for {previous_domain!r}. Disabling delegation would remove "
|
|
1130
|
+
"customer-domain CloudFront aliases and Cognito callbacks. Use an "
|
|
1131
|
+
"explicit reviewed domain-retirement operation if this is intended."
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def state_terraform_data_input(state, name):
|
|
1136
|
+
for resource in state.get("resources", []) or []:
|
|
1137
|
+
if resource.get("type") != "terraform_data" or resource.get("name") != name:
|
|
1138
|
+
continue
|
|
1139
|
+
for instance in resource.get("instances", []) or []:
|
|
1140
|
+
attributes = instance.get("attributes") or {}
|
|
1141
|
+
terraform_input = attributes.get("input")
|
|
1142
|
+
if isinstance(terraform_input, dict):
|
|
1143
|
+
value = terraform_input.get("value")
|
|
1144
|
+
if isinstance(value, dict):
|
|
1145
|
+
return value
|
|
1146
|
+
return terraform_input
|
|
1147
|
+
return {}
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def state_cloudflare_zone_id(state):
|
|
1151
|
+
for resource in state.get("resources", []) or []:
|
|
1152
|
+
if resource.get("type") != "cloudflare_record":
|
|
1153
|
+
continue
|
|
1154
|
+
for instance in resource.get("instances", []) or []:
|
|
1155
|
+
attributes = instance.get("attributes") or {}
|
|
1156
|
+
zone_id = attributes.get("zone_id")
|
|
1157
|
+
if isinstance(zone_id, str) and zone_id:
|
|
1158
|
+
return zone_id
|
|
1159
|
+
return os.environ.get("THINKWORK_CLOUDFLARE_ZONE_ID", "")
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def url_hostname(url):
|
|
1163
|
+
try:
|
|
1164
|
+
return urllib.parse.urlparse(url).hostname or ""
|
|
1165
|
+
except Exception:
|
|
1166
|
+
return ""
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def config_value(desired_config, manifest_images, key, env_name, image_names=None, default=""):
|
|
1170
|
+
value = desired_config.get(key)
|
|
1171
|
+
if isinstance(value, str) and value:
|
|
1172
|
+
return value
|
|
1173
|
+
for image_name in image_names or []:
|
|
1174
|
+
image = manifest_images.get(image_name)
|
|
1175
|
+
if isinstance(image, str) and image:
|
|
1176
|
+
return image
|
|
1177
|
+
return os.environ.get(env_name, default)
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def n8n_binary_data_mode(value):
|
|
1181
|
+
if isinstance(value, str) and value.strip() == "database":
|
|
1182
|
+
return "default"
|
|
1183
|
+
return value
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def config_bool(desired_config, key, env_name, default=False):
|
|
1187
|
+
value = desired_config.get(key)
|
|
1188
|
+
if isinstance(value, bool):
|
|
1189
|
+
return value
|
|
1190
|
+
if isinstance(value, str) and value:
|
|
1191
|
+
return truthy(value)
|
|
1192
|
+
env_value = os.environ.get(env_name)
|
|
1193
|
+
if env_value:
|
|
1194
|
+
return truthy(env_value)
|
|
1195
|
+
return default
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def config_int(desired_config, key, env_name, default):
|
|
1199
|
+
value = desired_config.get(key)
|
|
1200
|
+
if isinstance(value, int):
|
|
1201
|
+
return value
|
|
1202
|
+
if isinstance(value, float) and value.is_integer():
|
|
1203
|
+
return int(value)
|
|
1204
|
+
if isinstance(value, str) and value:
|
|
1205
|
+
return int(value)
|
|
1206
|
+
env_value = os.environ.get(env_name)
|
|
1207
|
+
if env_value:
|
|
1208
|
+
return int(env_value)
|
|
1209
|
+
return default
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def config_string_list(desired_config, key, env_name, default=None):
|
|
1213
|
+
value = desired_config.get(key)
|
|
1214
|
+
if isinstance(value, list):
|
|
1215
|
+
return [str(item).strip() for item in value if str(item).strip()]
|
|
1216
|
+
if isinstance(value, str) and value.strip():
|
|
1217
|
+
return [item.strip() for item in value.split(",") if item.strip()]
|
|
1218
|
+
env_value = os.environ.get(env_name)
|
|
1219
|
+
if env_value:
|
|
1220
|
+
return [item.strip() for item in env_value.split(",") if item.strip()]
|
|
1221
|
+
return list(default or [])
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def required_config_value(
|
|
1225
|
+
desired_config,
|
|
1226
|
+
manifest_images,
|
|
1227
|
+
key,
|
|
1228
|
+
env_name,
|
|
1229
|
+
label,
|
|
1230
|
+
image_names=None,
|
|
1231
|
+
default="",
|
|
1232
|
+
app_label="n8n",
|
|
1233
|
+
):
|
|
1234
|
+
value = config_value(desired_config, manifest_images, key, env_name, image_names, default)
|
|
1235
|
+
if isinstance(value, str) and value:
|
|
1236
|
+
return value
|
|
1237
|
+
raise RuntimeError(
|
|
1238
|
+
f"{app_label} managed app operation is missing required desired-state field: {label}."
|
|
1239
|
+
)
|
|
1240
|
+
|
|
1241
|
+
|
|
1242
|
+
def twenty_public_url_from_domain(domain):
|
|
1243
|
+
if not isinstance(domain, str) or not domain.strip():
|
|
1244
|
+
return ""
|
|
1245
|
+
value = domain.strip()
|
|
1246
|
+
if value.startswith("http://") or value.startswith("https://"):
|
|
1247
|
+
return value.rstrip("/")
|
|
1248
|
+
hostname = value if value.startswith("crm.") else f"crm.{value}"
|
|
1249
|
+
return f"https://{hostname}".rstrip("/")
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
def n8n_public_url_from_domain(domain):
|
|
1253
|
+
if not isinstance(domain, str) or not domain.strip():
|
|
1254
|
+
return ""
|
|
1255
|
+
value = domain.strip()
|
|
1256
|
+
if value.startswith("http://") or value.startswith("https://"):
|
|
1257
|
+
return value.rstrip("/")
|
|
1258
|
+
hostname = value if value.startswith("n8n.") else f"n8n.{value}"
|
|
1259
|
+
return f"https://{hostname}".rstrip("/")
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def sibling_app_base_domain(public_url):
|
|
1263
|
+
hostname = url_hostname(public_url)
|
|
1264
|
+
if not hostname:
|
|
1265
|
+
return ""
|
|
1266
|
+
labels = hostname.split(".")
|
|
1267
|
+
if len(labels) <= 2:
|
|
1268
|
+
return hostname
|
|
1269
|
+
return ".".join(labels[1:])
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def first_non_empty_string(*values):
|
|
1273
|
+
for value in values:
|
|
1274
|
+
if isinstance(value, str) and value.strip():
|
|
1275
|
+
return value.strip()
|
|
1276
|
+
return ""
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def canonical_json(value):
|
|
1280
|
+
if value is None or not isinstance(value, (dict, list)):
|
|
1281
|
+
return json.dumps(value, separators=(",", ":"))
|
|
1282
|
+
if isinstance(value, list):
|
|
1283
|
+
return "[" + ",".join(canonical_json(item) for item in value) + "]"
|
|
1284
|
+
return (
|
|
1285
|
+
"{"
|
|
1286
|
+
+ ",".join(
|
|
1287
|
+
json.dumps(key, separators=(",", ":")) + ":" + canonical_json(value[key])
|
|
1288
|
+
for key in sorted(value.keys())
|
|
1289
|
+
)
|
|
1290
|
+
+ "}"
|
|
1291
|
+
)
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def normalize_n8n_package_config(desired_config):
|
|
1295
|
+
specs = config_string_list(
|
|
1296
|
+
desired_config,
|
|
1297
|
+
"customPackageSpecs",
|
|
1298
|
+
"THINKWORK_N8N_CUSTOM_PACKAGE_SPECS",
|
|
1299
|
+
)
|
|
1300
|
+
by_name = {}
|
|
1301
|
+
for raw_spec in specs:
|
|
1302
|
+
if re.search(r"\s", raw_spec) or "://" in raw_spec:
|
|
1303
|
+
raise RuntimeError(
|
|
1304
|
+
f'n8n custom package "{raw_spec}" must be an exact public npm package spec.'
|
|
1305
|
+
)
|
|
1306
|
+
if raw_spec.startswith(("./", "../", "/")) or raw_spec.endswith(".tgz"):
|
|
1307
|
+
raise RuntimeError(
|
|
1308
|
+
f'n8n custom package "{raw_spec}" must be resolved from the public npm registry.'
|
|
1309
|
+
)
|
|
1310
|
+
version_separator_index = raw_spec.rfind("@")
|
|
1311
|
+
if version_separator_index <= 0:
|
|
1312
|
+
raise RuntimeError(
|
|
1313
|
+
f'n8n custom package "{raw_spec}" must include an exact public npm version.'
|
|
1314
|
+
)
|
|
1315
|
+
name = raw_spec[:version_separator_index]
|
|
1316
|
+
version = raw_spec[version_separator_index + 1 :]
|
|
1317
|
+
if not re.fullmatch(
|
|
1318
|
+
r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
|
|
1319
|
+
r"(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?"
|
|
1320
|
+
r"(?:\+[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?",
|
|
1321
|
+
version,
|
|
1322
|
+
):
|
|
1323
|
+
raise RuntimeError(f'n8n custom package "{raw_spec}" must pin an exact semver version.')
|
|
1324
|
+
existing = by_name.get(name)
|
|
1325
|
+
if existing and existing["version"] != version:
|
|
1326
|
+
raise RuntimeError(
|
|
1327
|
+
"n8n custom package "
|
|
1328
|
+
f"{name} declares multiple versions: {existing['version']} and {version}"
|
|
1329
|
+
)
|
|
1330
|
+
by_name[name] = {"name": name, "version": version, "spec": f"{name}@{version}"}
|
|
1331
|
+
|
|
1332
|
+
packages = sorted(by_name.values(), key=lambda entry: entry["name"])
|
|
1333
|
+
digest_payload = {
|
|
1334
|
+
"schemaVersion": 1,
|
|
1335
|
+
"packages": [{"name": entry["name"], "version": entry["version"]} for entry in packages],
|
|
1336
|
+
}
|
|
1337
|
+
digest = hashlib.sha256(canonical_json(digest_payload).encode()).hexdigest()
|
|
1338
|
+
return {
|
|
1339
|
+
"packageSpecs": [entry["spec"] for entry in packages],
|
|
1340
|
+
"digest": digest,
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def assert_optional_digest(expected, actual, label):
|
|
1345
|
+
if expected is None:
|
|
1346
|
+
return
|
|
1347
|
+
if not isinstance(expected, str) or not expected.strip():
|
|
1348
|
+
raise RuntimeError(f"{label} must be a sha256 hex digest.")
|
|
1349
|
+
if not re.fullmatch(r"[a-fA-F0-9]{64}", expected):
|
|
1350
|
+
raise RuntimeError(f"{label} must be a sha256 hex digest.")
|
|
1351
|
+
if expected.lower() != actual.lower():
|
|
1352
|
+
raise RuntimeError(f"{label} must match normalized n8n package config digest {actual}.")
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
def n8n_expected_package_digest(desired_config, key, env_name):
|
|
1356
|
+
value = desired_config.get(key)
|
|
1357
|
+
if isinstance(value, str) and value.strip():
|
|
1358
|
+
return value.strip()
|
|
1359
|
+
env_value = os.environ.get(env_name)
|
|
1360
|
+
if env_value and env_value.strip():
|
|
1361
|
+
return env_value.strip()
|
|
1362
|
+
return None
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def n8n_terraform_overrides(
|
|
1366
|
+
stage,
|
|
1367
|
+
account_id,
|
|
1368
|
+
operation,
|
|
1369
|
+
desired_config,
|
|
1370
|
+
manifest_images,
|
|
1371
|
+
current_outputs=None,
|
|
1372
|
+
n8n_guardrails=None,
|
|
1373
|
+
twenty_guardrails=None,
|
|
1374
|
+
):
|
|
1375
|
+
current_outputs = current_outputs or {}
|
|
1376
|
+
n8n_guardrails = n8n_guardrails or {}
|
|
1377
|
+
twenty_guardrails = twenty_guardrails or {}
|
|
1378
|
+
provisioned = operation != "DESTROY"
|
|
1379
|
+
runtime_enabled = provisioned and operation != "PARK"
|
|
1380
|
+
if not provisioned:
|
|
1381
|
+
return {
|
|
1382
|
+
"n8n_provisioned": False,
|
|
1383
|
+
"n8n_runtime_enabled": False,
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
package_config = normalize_n8n_package_config(desired_config)
|
|
1387
|
+
package_specs = package_config["packageSpecs"]
|
|
1388
|
+
package_config_digest = n8n_expected_package_digest(
|
|
1389
|
+
desired_config,
|
|
1390
|
+
"packageConfigDigest",
|
|
1391
|
+
"THINKWORK_N8N_PACKAGE_CONFIG_DIGEST",
|
|
1392
|
+
)
|
|
1393
|
+
package_image_config_digest = n8n_expected_package_digest(
|
|
1394
|
+
desired_config,
|
|
1395
|
+
"packageImageConfigDigest",
|
|
1396
|
+
"THINKWORK_N8N_PACKAGE_IMAGE_CONFIG_DIGEST",
|
|
1397
|
+
)
|
|
1398
|
+
assert_optional_digest(
|
|
1399
|
+
package_config_digest,
|
|
1400
|
+
package_config["digest"],
|
|
1401
|
+
"n8n packageConfigDigest",
|
|
1402
|
+
)
|
|
1403
|
+
assert_optional_digest(
|
|
1404
|
+
package_image_config_digest,
|
|
1405
|
+
package_config["digest"],
|
|
1406
|
+
"n8n packageImageConfigDigest",
|
|
1407
|
+
)
|
|
1408
|
+
|
|
1409
|
+
release_image_uri = first_non_empty_string(
|
|
1410
|
+
release_runtime_image("n8n-runtime"),
|
|
1411
|
+
release_runtime_image("n8n"),
|
|
1412
|
+
release_runtime_image("managed-app-n8n"),
|
|
1413
|
+
n8n_guardrails.get("n8n_image_uri", ""),
|
|
1414
|
+
)
|
|
1415
|
+
base_image_uri = required_config_value(
|
|
1416
|
+
desired_config,
|
|
1417
|
+
manifest_images,
|
|
1418
|
+
"imageUri",
|
|
1419
|
+
"THINKWORK_N8N_IMAGE_URI",
|
|
1420
|
+
"imageUri",
|
|
1421
|
+
["n8n", "n8n-runtime", "managed-app-n8n"],
|
|
1422
|
+
default=release_image_uri,
|
|
1423
|
+
)
|
|
1424
|
+
package_image_uri = config_value(
|
|
1425
|
+
desired_config,
|
|
1426
|
+
manifest_images,
|
|
1427
|
+
"packageImageUri",
|
|
1428
|
+
"THINKWORK_N8N_PACKAGE_IMAGE_URI",
|
|
1429
|
+
)
|
|
1430
|
+
if package_image_uri and not package_specs:
|
|
1431
|
+
raise RuntimeError("n8n packageImageUri requires at least one custom package spec.")
|
|
1432
|
+
if package_specs and not package_image_uri:
|
|
1433
|
+
raise RuntimeError("n8n custom package specs require n8n packageImageUri.")
|
|
1434
|
+
resolved_image_uri = package_image_uri if package_specs else base_image_uri
|
|
1435
|
+
|
|
1436
|
+
inferred_base_domain = first_non_empty_string(
|
|
1437
|
+
os.environ.get("THINKWORK_DOMAIN", ""),
|
|
1438
|
+
sibling_app_base_domain(twenty_guardrails.get("twenty_public_url", "")),
|
|
1439
|
+
sibling_app_base_domain(state_output(current_outputs, "twenty_url", "")),
|
|
1440
|
+
sibling_app_base_domain(state_output(current_outputs, "app_url", "")),
|
|
1441
|
+
)
|
|
1442
|
+
domain = config_value(
|
|
1443
|
+
desired_config,
|
|
1444
|
+
manifest_images,
|
|
1445
|
+
"domain",
|
|
1446
|
+
"THINKWORK_N8N_DOMAIN",
|
|
1447
|
+
default=inferred_base_domain,
|
|
1448
|
+
)
|
|
1449
|
+
public_url = config_value(
|
|
1450
|
+
desired_config,
|
|
1451
|
+
manifest_images,
|
|
1452
|
+
"publicUrl",
|
|
1453
|
+
"THINKWORK_N8N_PUBLIC_URL",
|
|
1454
|
+
default=n8n_guardrails.get("n8n_public_url", ""),
|
|
1455
|
+
) or n8n_public_url_from_domain(domain)
|
|
1456
|
+
n8n_domain = url_hostname(public_url) or domain
|
|
1457
|
+
database_admin_secret_arn = config_value(
|
|
1458
|
+
desired_config,
|
|
1459
|
+
manifest_images,
|
|
1460
|
+
"databaseAdminSecretArn",
|
|
1461
|
+
"THINKWORK_N8N_DATABASE_ADMIN_SECRET_ARN",
|
|
1462
|
+
default=first_non_empty_string(
|
|
1463
|
+
n8n_guardrails.get("n8n_database_admin_secret_arn", ""),
|
|
1464
|
+
state_output(current_outputs, "db_secret_arn", ""),
|
|
1465
|
+
),
|
|
1466
|
+
)
|
|
1467
|
+
if not database_admin_secret_arn:
|
|
1468
|
+
raise RuntimeError(
|
|
1469
|
+
"n8n managed app operation is missing required desired-state field: "
|
|
1470
|
+
"databaseAdminSecretArn."
|
|
1471
|
+
)
|
|
1472
|
+
generated_secret_guardrail_default = "" if operation == "ENABLE" else None
|
|
1473
|
+
database_url_secret_arn = config_value(
|
|
1474
|
+
desired_config,
|
|
1475
|
+
manifest_images,
|
|
1476
|
+
"databaseUrlSecretArn",
|
|
1477
|
+
"THINKWORK_N8N_DATABASE_URL_SECRET_ARN",
|
|
1478
|
+
default=(
|
|
1479
|
+
generated_secret_guardrail_default
|
|
1480
|
+
if generated_secret_guardrail_default is not None
|
|
1481
|
+
else n8n_guardrails.get("n8n_database_url_secret_arn", "")
|
|
1482
|
+
),
|
|
1483
|
+
)
|
|
1484
|
+
encryption_key_secret_arn = config_value(
|
|
1485
|
+
desired_config,
|
|
1486
|
+
manifest_images,
|
|
1487
|
+
"encryptionKeySecretArn",
|
|
1488
|
+
"THINKWORK_N8N_ENCRYPTION_KEY_SECRET_ARN",
|
|
1489
|
+
default=(
|
|
1490
|
+
generated_secret_guardrail_default
|
|
1491
|
+
if generated_secret_guardrail_default is not None
|
|
1492
|
+
else n8n_guardrails.get("n8n_encryption_key_secret_arn", "")
|
|
1493
|
+
),
|
|
1494
|
+
)
|
|
1495
|
+
operator_secret_arn = config_value(
|
|
1496
|
+
desired_config,
|
|
1497
|
+
manifest_images,
|
|
1498
|
+
"operatorSecretArn",
|
|
1499
|
+
"THINKWORK_N8N_OPERATOR_SECRET_ARN",
|
|
1500
|
+
default=(
|
|
1501
|
+
generated_secret_guardrail_default
|
|
1502
|
+
if generated_secret_guardrail_default is not None
|
|
1503
|
+
else n8n_guardrails.get("n8n_operator_secret_arn", "")
|
|
1504
|
+
),
|
|
1505
|
+
)
|
|
1506
|
+
service_credential_secret_arn = config_value(
|
|
1507
|
+
desired_config,
|
|
1508
|
+
manifest_images,
|
|
1509
|
+
"serviceCredentialSecretArn",
|
|
1510
|
+
"THINKWORK_N8N_SERVICE_CREDENTIAL_SECRET_ARN",
|
|
1511
|
+
default=(
|
|
1512
|
+
generated_secret_guardrail_default
|
|
1513
|
+
if generated_secret_guardrail_default is not None
|
|
1514
|
+
else n8n_guardrails.get("n8n_service_credential_secret_arn", "")
|
|
1515
|
+
),
|
|
1516
|
+
)
|
|
1517
|
+
certificate_arn = config_value(
|
|
1518
|
+
desired_config,
|
|
1519
|
+
manifest_images,
|
|
1520
|
+
"certificateArn",
|
|
1521
|
+
"THINKWORK_N8N_CERTIFICATE_ARN",
|
|
1522
|
+
default="",
|
|
1523
|
+
)
|
|
1524
|
+
create_secret_placeholders = any(
|
|
1525
|
+
not value
|
|
1526
|
+
for value in [
|
|
1527
|
+
database_url_secret_arn,
|
|
1528
|
+
encryption_key_secret_arn,
|
|
1529
|
+
operator_secret_arn,
|
|
1530
|
+
service_credential_secret_arn,
|
|
1531
|
+
]
|
|
1532
|
+
)
|
|
1533
|
+
|
|
1534
|
+
return {
|
|
1535
|
+
"n8n_provisioned": True,
|
|
1536
|
+
"n8n_runtime_enabled": runtime_enabled,
|
|
1537
|
+
"n8n_image_uri": resolved_image_uri,
|
|
1538
|
+
"n8n_database_admin_secret_arn": database_admin_secret_arn,
|
|
1539
|
+
"n8n_database_url_secret_arn": database_url_secret_arn,
|
|
1540
|
+
"n8n_database_name": config_value(
|
|
1541
|
+
desired_config,
|
|
1542
|
+
manifest_images,
|
|
1543
|
+
"databaseName",
|
|
1544
|
+
"THINKWORK_N8N_DATABASE_NAME",
|
|
1545
|
+
default=n8n_guardrails.get("n8n_database_name", "thinkwork_n8n"),
|
|
1546
|
+
),
|
|
1547
|
+
"n8n_database_username": config_value(
|
|
1548
|
+
desired_config,
|
|
1549
|
+
manifest_images,
|
|
1550
|
+
"databaseUsername",
|
|
1551
|
+
"THINKWORK_N8N_DATABASE_USERNAME",
|
|
1552
|
+
default=n8n_guardrails.get("n8n_database_username", "thinkwork_n8n"),
|
|
1553
|
+
),
|
|
1554
|
+
"n8n_encryption_key_secret_arn": encryption_key_secret_arn,
|
|
1555
|
+
"n8n_operator_secret_arn": operator_secret_arn,
|
|
1556
|
+
"n8n_service_credential_secret_arn": service_credential_secret_arn,
|
|
1557
|
+
"n8n_storage_bucket_name": config_value(
|
|
1558
|
+
desired_config,
|
|
1559
|
+
manifest_images,
|
|
1560
|
+
"storageBucketName",
|
|
1561
|
+
"THINKWORK_N8N_STORAGE_BUCKET_NAME",
|
|
1562
|
+
default=n8n_guardrails.get(
|
|
1563
|
+
"n8n_storage_bucket_name",
|
|
1564
|
+
f"thinkwork-{stage}-{account_id}-n8n",
|
|
1565
|
+
),
|
|
1566
|
+
),
|
|
1567
|
+
"n8n_create_storage_bucket": config_bool(
|
|
1568
|
+
desired_config,
|
|
1569
|
+
"createStorageBucket",
|
|
1570
|
+
"THINKWORK_N8N_CREATE_STORAGE_BUCKET",
|
|
1571
|
+
bool(n8n_guardrails.get("n8n_create_storage_bucket", True)),
|
|
1572
|
+
),
|
|
1573
|
+
"n8n_storage_prefix": config_value(
|
|
1574
|
+
desired_config,
|
|
1575
|
+
manifest_images,
|
|
1576
|
+
"storagePrefix",
|
|
1577
|
+
"THINKWORK_N8N_STORAGE_PREFIX",
|
|
1578
|
+
default=n8n_guardrails.get("n8n_storage_prefix", "managed-apps/n8n"),
|
|
1579
|
+
),
|
|
1580
|
+
"n8n_domain": n8n_domain,
|
|
1581
|
+
"n8n_public_url": public_url,
|
|
1582
|
+
"n8n_certificate_arn": certificate_arn,
|
|
1583
|
+
"n8n_main_desired_count": config_int(
|
|
1584
|
+
desired_config,
|
|
1585
|
+
"mainDesiredCount",
|
|
1586
|
+
"THINKWORK_N8N_MAIN_DESIRED_COUNT",
|
|
1587
|
+
n8n_guardrails.get("n8n_main_desired_count", 1),
|
|
1588
|
+
),
|
|
1589
|
+
"n8n_worker_desired_count": config_int(
|
|
1590
|
+
desired_config,
|
|
1591
|
+
"workerDesiredCount",
|
|
1592
|
+
"THINKWORK_N8N_WORKER_DESIRED_COUNT",
|
|
1593
|
+
n8n_guardrails.get("n8n_worker_desired_count", 1),
|
|
1594
|
+
),
|
|
1595
|
+
"n8n_worker_concurrency": config_int(
|
|
1596
|
+
desired_config,
|
|
1597
|
+
"workerConcurrency",
|
|
1598
|
+
"THINKWORK_N8N_WORKER_CONCURRENCY",
|
|
1599
|
+
n8n_guardrails.get("n8n_worker_concurrency", 10),
|
|
1600
|
+
),
|
|
1601
|
+
"n8n_container_port": config_int(
|
|
1602
|
+
desired_config,
|
|
1603
|
+
"containerPort",
|
|
1604
|
+
"THINKWORK_N8N_CONTAINER_PORT",
|
|
1605
|
+
n8n_guardrails.get("n8n_container_port", 5678),
|
|
1606
|
+
),
|
|
1607
|
+
"n8n_queue_mode": True,
|
|
1608
|
+
"n8n_task_runners_enabled": config_bool(
|
|
1609
|
+
desired_config,
|
|
1610
|
+
"taskRunnersEnabled",
|
|
1611
|
+
"THINKWORK_N8N_TASK_RUNNERS_ENABLED",
|
|
1612
|
+
bool(n8n_guardrails.get("n8n_task_runners_enabled", True)),
|
|
1613
|
+
),
|
|
1614
|
+
"n8n_package_config_digest": package_config["digest"]
|
|
1615
|
+
if package_specs or package_config_digest is not None
|
|
1616
|
+
else n8n_guardrails.get("n8n_package_config_digest", ""),
|
|
1617
|
+
"n8n_custom_package_specs": package_specs
|
|
1618
|
+
or n8n_guardrails.get("n8n_custom_package_specs", []),
|
|
1619
|
+
"n8n_execution_data_storage_mode": config_value(
|
|
1620
|
+
desired_config,
|
|
1621
|
+
manifest_images,
|
|
1622
|
+
"executionDataStorageMode",
|
|
1623
|
+
"THINKWORK_N8N_EXECUTION_DATA_STORAGE_MODE",
|
|
1624
|
+
default=n8n_guardrails.get("n8n_execution_data_storage_mode", "database"),
|
|
1625
|
+
),
|
|
1626
|
+
"n8n_binary_data_mode": n8n_binary_data_mode(
|
|
1627
|
+
config_value(
|
|
1628
|
+
desired_config,
|
|
1629
|
+
manifest_images,
|
|
1630
|
+
"binaryDataMode",
|
|
1631
|
+
"THINKWORK_N8N_BINARY_DATA_MODE",
|
|
1632
|
+
default=n8n_guardrails.get("n8n_binary_data_mode", "default"),
|
|
1633
|
+
)
|
|
1634
|
+
),
|
|
1635
|
+
"n8n_cache_engine": config_value(
|
|
1636
|
+
desired_config,
|
|
1637
|
+
manifest_images,
|
|
1638
|
+
"cacheEngine",
|
|
1639
|
+
"THINKWORK_N8N_CACHE_ENGINE",
|
|
1640
|
+
default=n8n_guardrails.get("n8n_cache_engine", "valkey"),
|
|
1641
|
+
),
|
|
1642
|
+
"n8n_cache_engine_version": config_value(
|
|
1643
|
+
desired_config,
|
|
1644
|
+
manifest_images,
|
|
1645
|
+
"cacheEngineVersion",
|
|
1646
|
+
"THINKWORK_N8N_CACHE_ENGINE_VERSION",
|
|
1647
|
+
default=n8n_guardrails.get("n8n_cache_engine_version", "8.0"),
|
|
1648
|
+
),
|
|
1649
|
+
"n8n_cache_parameter_group_family": config_value(
|
|
1650
|
+
desired_config,
|
|
1651
|
+
manifest_images,
|
|
1652
|
+
"cacheParameterGroupFamily",
|
|
1653
|
+
"THINKWORK_N8N_CACHE_PARAMETER_GROUP_FAMILY",
|
|
1654
|
+
default=n8n_guardrails.get(
|
|
1655
|
+
"n8n_cache_parameter_group_family",
|
|
1656
|
+
"valkey8",
|
|
1657
|
+
),
|
|
1658
|
+
),
|
|
1659
|
+
"n8n_cache_node_type": config_value(
|
|
1660
|
+
desired_config,
|
|
1661
|
+
manifest_images,
|
|
1662
|
+
"cacheNodeType",
|
|
1663
|
+
"THINKWORK_N8N_CACHE_NODE_TYPE",
|
|
1664
|
+
default=n8n_guardrails.get("n8n_cache_node_type", "cache.t4g.micro"),
|
|
1665
|
+
),
|
|
1666
|
+
"n8n_cache_num_cache_clusters": config_int(
|
|
1667
|
+
desired_config,
|
|
1668
|
+
"cacheNumCacheClusters",
|
|
1669
|
+
"THINKWORK_N8N_CACHE_NUM_CACHE_CLUSTERS",
|
|
1670
|
+
n8n_guardrails.get("n8n_cache_num_cache_clusters", 1),
|
|
1671
|
+
),
|
|
1672
|
+
"n8n_allowed_public_cidr_blocks": config_string_list(
|
|
1673
|
+
desired_config,
|
|
1674
|
+
"allowedPublicCidrBlocks",
|
|
1675
|
+
"THINKWORK_N8N_ALLOWED_PUBLIC_CIDR_BLOCKS",
|
|
1676
|
+
n8n_guardrails.get("n8n_allowed_public_cidr_blocks", ["0.0.0.0/0"]),
|
|
1677
|
+
),
|
|
1678
|
+
"n8n_kms_key_arns": config_string_list(
|
|
1679
|
+
desired_config,
|
|
1680
|
+
"kmsKeyArns",
|
|
1681
|
+
"THINKWORK_N8N_KMS_KEY_ARNS",
|
|
1682
|
+
n8n_guardrails.get("n8n_kms_key_arns", []),
|
|
1683
|
+
),
|
|
1684
|
+
"deployment_control_plane_create_secret_placeholders": create_secret_placeholders,
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
|
|
1688
|
+
def twenty_terraform_overrides(
|
|
1689
|
+
operation,
|
|
1690
|
+
desired_config,
|
|
1691
|
+
manifest_images,
|
|
1692
|
+
current_outputs=None,
|
|
1693
|
+
twenty_guardrails=None,
|
|
1694
|
+
payload=None,
|
|
1695
|
+
):
|
|
1696
|
+
current_outputs = current_outputs or {}
|
|
1697
|
+
twenty_guardrails = twenty_guardrails or {}
|
|
1698
|
+
payload = payload or {}
|
|
1699
|
+
provisioned = operation != "DESTROY"
|
|
1700
|
+
runtime_enabled = provisioned and operation != "PARK"
|
|
1701
|
+
if not provisioned:
|
|
1702
|
+
return {
|
|
1703
|
+
"twenty_provisioned": False,
|
|
1704
|
+
"twenty_runtime_enabled": False,
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
release_image_uri = first_non_empty_string(
|
|
1708
|
+
release_runtime_image("twenty"),
|
|
1709
|
+
release_runtime_image("twenty-crm"),
|
|
1710
|
+
release_runtime_image("managed-app-twenty"),
|
|
1711
|
+
twenty_guardrails.get("twenty_image_uri", ""),
|
|
1712
|
+
)
|
|
1713
|
+
image_uri = required_config_value(
|
|
1714
|
+
desired_config,
|
|
1715
|
+
manifest_images,
|
|
1716
|
+
"imageUri",
|
|
1717
|
+
"THINKWORK_TWENTY_IMAGE_URI",
|
|
1718
|
+
"imageUri",
|
|
1719
|
+
["twenty", "twenty-crm", "managed-app-twenty"],
|
|
1720
|
+
default=release_image_uri,
|
|
1721
|
+
app_label="twenty",
|
|
1722
|
+
)
|
|
1723
|
+
inferred_domain = first_non_empty_string(
|
|
1724
|
+
desired_config.get("domain", ""),
|
|
1725
|
+
os.environ.get("THINKWORK_TWENTY_DOMAIN", ""),
|
|
1726
|
+
os.environ.get("THINKWORK_DOMAIN", ""),
|
|
1727
|
+
payload.get("customerDomain", ""),
|
|
1728
|
+
sibling_app_base_domain(state_output(current_outputs, "app_url", "")),
|
|
1729
|
+
)
|
|
1730
|
+
public_url = config_value(
|
|
1731
|
+
desired_config,
|
|
1732
|
+
manifest_images,
|
|
1733
|
+
"publicUrl",
|
|
1734
|
+
"THINKWORK_TWENTY_PUBLIC_URL",
|
|
1735
|
+
default=first_non_empty_string(
|
|
1736
|
+
twenty_guardrails.get("twenty_public_url", ""),
|
|
1737
|
+
state_output(current_outputs, "twenty_url", ""),
|
|
1738
|
+
twenty_public_url_from_domain(inferred_domain),
|
|
1739
|
+
),
|
|
1740
|
+
)
|
|
1741
|
+
certificate_arn = required_config_value(
|
|
1742
|
+
desired_config,
|
|
1743
|
+
manifest_images,
|
|
1744
|
+
"certificateArn",
|
|
1745
|
+
"THINKWORK_TWENTY_CERTIFICATE_ARN",
|
|
1746
|
+
"certificateArn",
|
|
1747
|
+
default=first_non_empty_string(
|
|
1748
|
+
twenty_guardrails.get("twenty_certificate_arn", ""),
|
|
1749
|
+
payload.get("appCertificateArn", ""),
|
|
1750
|
+
),
|
|
1751
|
+
app_label="twenty",
|
|
1752
|
+
)
|
|
1753
|
+
db_url_secret_arn = config_value(
|
|
1754
|
+
desired_config,
|
|
1755
|
+
manifest_images,
|
|
1756
|
+
"dbUrlSecretArn",
|
|
1757
|
+
"THINKWORK_TWENTY_DB_URL_SECRET_ARN",
|
|
1758
|
+
default=twenty_guardrails.get("twenty_db_url_secret_arn", ""),
|
|
1759
|
+
)
|
|
1760
|
+
encryption_key_secret_arn = config_value(
|
|
1761
|
+
desired_config,
|
|
1762
|
+
manifest_images,
|
|
1763
|
+
"encryptionKeySecretArn",
|
|
1764
|
+
"THINKWORK_TWENTY_ENCRYPTION_KEY_SECRET_ARN",
|
|
1765
|
+
default=twenty_guardrails.get("twenty_encryption_key_secret_arn", ""),
|
|
1766
|
+
)
|
|
1767
|
+
|
|
1768
|
+
return {
|
|
1769
|
+
"twenty_provisioned": True,
|
|
1770
|
+
"twenty_runtime_enabled": runtime_enabled,
|
|
1771
|
+
"twenty_image_uri": image_uri,
|
|
1772
|
+
"twenty_db_username": config_value(
|
|
1773
|
+
desired_config,
|
|
1774
|
+
manifest_images,
|
|
1775
|
+
"dbUsername",
|
|
1776
|
+
"THINKWORK_TWENTY_DB_USERNAME",
|
|
1777
|
+
default=twenty_guardrails.get("twenty_db_username", "thinkwork_twenty"),
|
|
1778
|
+
),
|
|
1779
|
+
"twenty_db_name": config_value(
|
|
1780
|
+
desired_config,
|
|
1781
|
+
manifest_images,
|
|
1782
|
+
"dbName",
|
|
1783
|
+
"THINKWORK_TWENTY_DB_NAME",
|
|
1784
|
+
default=twenty_guardrails.get("twenty_db_name", "thinkwork_twenty"),
|
|
1785
|
+
),
|
|
1786
|
+
"twenty_db_url_secret_arn": db_url_secret_arn,
|
|
1787
|
+
"twenty_encryption_key_secret_arn": encryption_key_secret_arn,
|
|
1788
|
+
"twenty_email_from_address": config_value(
|
|
1789
|
+
desired_config,
|
|
1790
|
+
manifest_images,
|
|
1791
|
+
"emailFromAddress",
|
|
1792
|
+
"THINKWORK_TWENTY_EMAIL_FROM_ADDRESS",
|
|
1793
|
+
default=twenty_guardrails.get("twenty_email_from_address", ""),
|
|
1794
|
+
),
|
|
1795
|
+
"twenty_email_from_name": config_value(
|
|
1796
|
+
desired_config,
|
|
1797
|
+
manifest_images,
|
|
1798
|
+
"emailFromName",
|
|
1799
|
+
"THINKWORK_TWENTY_EMAIL_FROM_NAME",
|
|
1800
|
+
default=twenty_guardrails.get("twenty_email_from_name", "ThinkWork CRM"),
|
|
1801
|
+
),
|
|
1802
|
+
"twenty_public_url": public_url,
|
|
1803
|
+
"twenty_certificate_arn": certificate_arn,
|
|
1804
|
+
"deployment_control_plane_create_secret_placeholders": (
|
|
1805
|
+
not db_url_secret_arn or not encryption_key_secret_arn
|
|
1806
|
+
),
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
|
|
1810
|
+
def validate_managed_app_desired_state(payload, desired_config, manifest_images):
|
|
1811
|
+
app_key = payload.get("appKey")
|
|
1812
|
+
if not app_key:
|
|
1813
|
+
return
|
|
1814
|
+
operation = str(payload.get("operation") or "").upper()
|
|
1815
|
+
if operation not in {"ENABLE", "PARK", "DESTROY", "UPGRADE"}:
|
|
1816
|
+
raise RuntimeError(
|
|
1817
|
+
"Managed app operation requires operation to be one of "
|
|
1818
|
+
"ENABLE, PARK, DESTROY, or UPGRADE."
|
|
1819
|
+
)
|
|
1820
|
+
|
|
1821
|
+
|
|
1822
|
+
def managed_app_terraform_overrides(payload, stage, account_id, current_outputs, current_state):
|
|
1823
|
+
app_key = payload.get("appKey")
|
|
1824
|
+
operation = str(payload.get("operation") or "").upper()
|
|
1825
|
+
desired_config = payload.get("desiredConfig")
|
|
1826
|
+
if not isinstance(desired_config, dict):
|
|
1827
|
+
desired_config = {}
|
|
1828
|
+
manifest_images = payload.get("manifestImages")
|
|
1829
|
+
if not isinstance(manifest_images, dict):
|
|
1830
|
+
manifest_images = {}
|
|
1831
|
+
validate_managed_app_desired_state(payload, desired_config, manifest_images)
|
|
1832
|
+
cognee_guardrails = state_terraform_data_input(
|
|
1833
|
+
current_state,
|
|
1834
|
+
"cognee_configuration_guardrails",
|
|
1835
|
+
)
|
|
1836
|
+
twenty_guardrails = state_terraform_data_input(
|
|
1837
|
+
current_state,
|
|
1838
|
+
"twenty_configuration_guardrails",
|
|
1839
|
+
)
|
|
1840
|
+
n8n_guardrails = state_terraform_data_input(
|
|
1841
|
+
current_state,
|
|
1842
|
+
"n8n_configuration_guardrails",
|
|
1843
|
+
)
|
|
1844
|
+
|
|
1845
|
+
overrides = {
|
|
1846
|
+
"enable_cognee": bool(state_output(current_outputs, "cognee_enabled", False)),
|
|
1847
|
+
"cognee_image_uri": cognee_guardrails.get("cognee_image_uri", ""),
|
|
1848
|
+
"cognee_db_username": cognee_guardrails.get("cognee_db_username", "thinkwork_cognee"),
|
|
1849
|
+
"cognee_db_name": cognee_guardrails.get("cognee_db_name", "thinkwork_cognee"),
|
|
1850
|
+
"cognee_db_password_secret_arn": cognee_guardrails.get(
|
|
1851
|
+
"cognee_db_password_secret_arn",
|
|
1852
|
+
"",
|
|
1853
|
+
),
|
|
1854
|
+
"cognee_backend_mode": cognee_guardrails.get("cognee_backend_mode", "dogfood"),
|
|
1855
|
+
"cognee_desired_count": cognee_guardrails.get("cognee_desired_count", 1),
|
|
1856
|
+
"cognee_brain_tenant_id": state_output(current_outputs, "cognee_brain_tenant_id", ""),
|
|
1857
|
+
"cognee_brain_instance_key": state_output(
|
|
1858
|
+
current_outputs,
|
|
1859
|
+
"cognee_brain_instance_key",
|
|
1860
|
+
"",
|
|
1861
|
+
),
|
|
1862
|
+
"cognee_brain_storage_tier": cognee_guardrails.get(
|
|
1863
|
+
"cognee_brain_storage_tier",
|
|
1864
|
+
state_output(current_outputs, "cognee_brain_storage_tier", "default"),
|
|
1865
|
+
),
|
|
1866
|
+
"cognee_brain_s3_artifact_root": state_output(
|
|
1867
|
+
current_outputs,
|
|
1868
|
+
"cognee_s3_artifact_root",
|
|
1869
|
+
"",
|
|
1870
|
+
),
|
|
1871
|
+
"cognee_brain_s3_manifest_root": state_output(
|
|
1872
|
+
current_outputs,
|
|
1873
|
+
"cognee_s3_manifest_root",
|
|
1874
|
+
"",
|
|
1875
|
+
),
|
|
1876
|
+
"cognee_brain_s3_vault_projection_root": state_output(
|
|
1877
|
+
current_outputs,
|
|
1878
|
+
"cognee_s3_vault_projection_root",
|
|
1879
|
+
"",
|
|
1880
|
+
),
|
|
1881
|
+
"cognee_private_substrate_mode": bool(
|
|
1882
|
+
state_output(current_outputs, "cognee_private_substrate_mode", True)
|
|
1883
|
+
),
|
|
1884
|
+
"cognee_llm_provider": cognee_guardrails.get("cognee_llm_provider", "bedrock"),
|
|
1885
|
+
"cognee_llm_model": "bedrock/moonshotai.kimi-k2.5",
|
|
1886
|
+
"cognee_embedding_provider": cognee_guardrails.get(
|
|
1887
|
+
"cognee_embedding_provider",
|
|
1888
|
+
"bedrock",
|
|
1889
|
+
),
|
|
1890
|
+
"cognee_embedding_model": state_output(
|
|
1891
|
+
current_outputs,
|
|
1892
|
+
"cognee_embedding_model",
|
|
1893
|
+
"amazon.titan-embed-text-v2:0",
|
|
1894
|
+
),
|
|
1895
|
+
"cognee_embedding_dimensions": state_output(
|
|
1896
|
+
current_outputs,
|
|
1897
|
+
"cognee_embedding_dimensions",
|
|
1898
|
+
1024,
|
|
1899
|
+
),
|
|
1900
|
+
"cognee_vector_db_provider": state_output(
|
|
1901
|
+
current_outputs,
|
|
1902
|
+
"cognee_vector_db_provider",
|
|
1903
|
+
"lancedb",
|
|
1904
|
+
),
|
|
1905
|
+
"cognee_graph_database_provider": state_output(
|
|
1906
|
+
current_outputs,
|
|
1907
|
+
"cognee_graph_database_provider",
|
|
1908
|
+
"kuzu",
|
|
1909
|
+
),
|
|
1910
|
+
"cognee_neptune_graph_id": state_output(current_outputs, "cognee_neptune_graph_id", ""),
|
|
1911
|
+
"cognee_neptune_endpoint": state_output(current_outputs, "cognee_neptune_endpoint", ""),
|
|
1912
|
+
"cognee_production_posture": state_output(
|
|
1913
|
+
current_outputs,
|
|
1914
|
+
"cognee_production_posture",
|
|
1915
|
+
"",
|
|
1916
|
+
),
|
|
1917
|
+
"cognee_bedrock_model_resource_arns": cognee_guardrails.get(
|
|
1918
|
+
"cognee_bedrock_model_resources",
|
|
1919
|
+
[],
|
|
1920
|
+
),
|
|
1921
|
+
"twenty_provisioned": bool(state_output(current_outputs, "twenty_provisioned", False)),
|
|
1922
|
+
"twenty_runtime_enabled": bool(
|
|
1923
|
+
state_output(current_outputs, "twenty_runtime_enabled", False)
|
|
1924
|
+
),
|
|
1925
|
+
"twenty_image_uri": twenty_guardrails.get("twenty_image_uri", ""),
|
|
1926
|
+
"twenty_db_username": twenty_guardrails.get("twenty_db_username", "thinkwork_twenty"),
|
|
1927
|
+
"twenty_db_name": twenty_guardrails.get("twenty_db_name", "thinkwork_twenty"),
|
|
1928
|
+
"twenty_db_url_secret_arn": twenty_guardrails.get("twenty_db_url_secret_arn", ""),
|
|
1929
|
+
"twenty_encryption_key_secret_arn": twenty_guardrails.get(
|
|
1930
|
+
"twenty_encryption_key_secret_arn",
|
|
1931
|
+
"",
|
|
1932
|
+
),
|
|
1933
|
+
"twenty_email_from_address": "",
|
|
1934
|
+
"twenty_email_from_name": "ThinkWork CRM",
|
|
1935
|
+
"twenty_public_url": twenty_guardrails.get(
|
|
1936
|
+
"twenty_public_url",
|
|
1937
|
+
state_output(current_outputs, "twenty_url", ""),
|
|
1938
|
+
),
|
|
1939
|
+
"twenty_certificate_arn": twenty_guardrails.get("twenty_certificate_arn", ""),
|
|
1940
|
+
"enable_deployment_control_plane": False,
|
|
1941
|
+
"deployment_control_plane_create_secret_placeholders": False,
|
|
1942
|
+
"cloudflare_zone_id": state_cloudflare_zone_id(current_state),
|
|
1943
|
+
"n8n_provisioned": bool(state_output(current_outputs, "n8n_provisioned", False)),
|
|
1944
|
+
"n8n_runtime_enabled": bool(state_output(current_outputs, "n8n_runtime_enabled", False)),
|
|
1945
|
+
"n8n_image_uri": n8n_guardrails.get("n8n_image_uri", ""),
|
|
1946
|
+
"n8n_database_admin_secret_arn": n8n_guardrails.get(
|
|
1947
|
+
"n8n_database_admin_secret_arn",
|
|
1948
|
+
"",
|
|
1949
|
+
),
|
|
1950
|
+
"n8n_database_url_secret_arn": n8n_guardrails.get(
|
|
1951
|
+
"n8n_database_url_secret_arn",
|
|
1952
|
+
"",
|
|
1953
|
+
),
|
|
1954
|
+
"n8n_database_name": n8n_guardrails.get("n8n_database_name", "thinkwork_n8n"),
|
|
1955
|
+
"n8n_database_username": n8n_guardrails.get(
|
|
1956
|
+
"n8n_database_username",
|
|
1957
|
+
"thinkwork_n8n",
|
|
1958
|
+
),
|
|
1959
|
+
"n8n_encryption_key_secret_arn": n8n_guardrails.get(
|
|
1960
|
+
"n8n_encryption_key_secret_arn",
|
|
1961
|
+
"",
|
|
1962
|
+
),
|
|
1963
|
+
"n8n_operator_secret_arn": n8n_guardrails.get("n8n_operator_secret_arn", ""),
|
|
1964
|
+
"n8n_service_credential_secret_arn": n8n_guardrails.get(
|
|
1965
|
+
"n8n_service_credential_secret_arn",
|
|
1966
|
+
"",
|
|
1967
|
+
),
|
|
1968
|
+
"n8n_storage_bucket_name": n8n_guardrails.get("n8n_storage_bucket_name", ""),
|
|
1969
|
+
"n8n_create_storage_bucket": n8n_guardrails.get("n8n_create_storage_bucket", True),
|
|
1970
|
+
"n8n_storage_prefix": n8n_guardrails.get("n8n_storage_prefix", "managed-apps/n8n"),
|
|
1971
|
+
"n8n_domain": "",
|
|
1972
|
+
"n8n_public_url": n8n_guardrails.get(
|
|
1973
|
+
"n8n_public_url",
|
|
1974
|
+
state_output(current_outputs, "n8n_url", ""),
|
|
1975
|
+
),
|
|
1976
|
+
"n8n_certificate_arn": n8n_guardrails.get("n8n_certificate_arn", ""),
|
|
1977
|
+
"n8n_main_desired_count": n8n_guardrails.get("n8n_main_desired_count", 1),
|
|
1978
|
+
"n8n_worker_desired_count": n8n_guardrails.get("n8n_worker_desired_count", 1),
|
|
1979
|
+
"n8n_worker_concurrency": n8n_guardrails.get("n8n_worker_concurrency", 10),
|
|
1980
|
+
"n8n_container_port": n8n_guardrails.get("n8n_container_port", 5678),
|
|
1981
|
+
"n8n_queue_mode": n8n_guardrails.get("n8n_queue_mode", True),
|
|
1982
|
+
"n8n_task_runners_enabled": n8n_guardrails.get("n8n_task_runners_enabled", True),
|
|
1983
|
+
"n8n_package_config_digest": n8n_guardrails.get("n8n_package_config_digest", ""),
|
|
1984
|
+
"n8n_custom_package_specs": n8n_guardrails.get("n8n_custom_package_specs", []),
|
|
1985
|
+
"n8n_execution_data_storage_mode": n8n_guardrails.get(
|
|
1986
|
+
"n8n_execution_data_storage_mode",
|
|
1987
|
+
"database",
|
|
1988
|
+
),
|
|
1989
|
+
"n8n_binary_data_mode": n8n_binary_data_mode(
|
|
1990
|
+
n8n_guardrails.get("n8n_binary_data_mode", "default")
|
|
1991
|
+
),
|
|
1992
|
+
"n8n_cache_engine": n8n_guardrails.get("n8n_cache_engine", "valkey"),
|
|
1993
|
+
"n8n_cache_engine_version": n8n_guardrails.get("n8n_cache_engine_version", "8.0"),
|
|
1994
|
+
"n8n_cache_parameter_group_family": n8n_guardrails.get(
|
|
1995
|
+
"n8n_cache_parameter_group_family",
|
|
1996
|
+
"valkey8",
|
|
1997
|
+
),
|
|
1998
|
+
"n8n_cache_node_type": n8n_guardrails.get(
|
|
1999
|
+
"n8n_cache_node_type",
|
|
2000
|
+
"cache.t4g.micro",
|
|
2001
|
+
),
|
|
2002
|
+
"n8n_cache_num_cache_clusters": n8n_guardrails.get(
|
|
2003
|
+
"n8n_cache_num_cache_clusters",
|
|
2004
|
+
1,
|
|
2005
|
+
),
|
|
2006
|
+
"n8n_allowed_public_cidr_blocks": n8n_guardrails.get(
|
|
2007
|
+
"n8n_allowed_public_cidr_blocks",
|
|
2008
|
+
["0.0.0.0/0"],
|
|
2009
|
+
),
|
|
2010
|
+
"n8n_kms_key_arns": n8n_guardrails.get("n8n_kms_key_arns", []),
|
|
2011
|
+
"n8n_dns_enabled": False,
|
|
2012
|
+
"n8n_dns_name": "",
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
if app_key == "n8n":
|
|
2016
|
+
provisioned = operation != "DESTROY"
|
|
2017
|
+
overrides.update(
|
|
2018
|
+
n8n_terraform_overrides(
|
|
2019
|
+
stage,
|
|
2020
|
+
account_id,
|
|
2021
|
+
operation,
|
|
2022
|
+
desired_config,
|
|
2023
|
+
manifest_images,
|
|
2024
|
+
current_outputs,
|
|
2025
|
+
n8n_guardrails,
|
|
2026
|
+
twenty_guardrails,
|
|
2027
|
+
)
|
|
2028
|
+
)
|
|
2029
|
+
n8n_dns_name = overrides["n8n_domain"] or url_hostname(overrides["n8n_public_url"])
|
|
2030
|
+
overrides["cloudflare_zone_id"] = overrides[
|
|
2031
|
+
"cloudflare_zone_id"
|
|
2032
|
+
] or cloudflare_zone_id_for_hostname(stage, n8n_dns_name)
|
|
2033
|
+
overrides["n8n_dns_name"] = n8n_dns_name
|
|
2034
|
+
overrides["n8n_dns_enabled"] = (
|
|
2035
|
+
provisioned and bool(overrides["cloudflare_zone_id"]) and bool(n8n_dns_name)
|
|
2036
|
+
)
|
|
2037
|
+
return overrides
|
|
2038
|
+
|
|
2039
|
+
if app_key == "twenty":
|
|
2040
|
+
overrides.update(
|
|
2041
|
+
twenty_terraform_overrides(
|
|
2042
|
+
operation,
|
|
2043
|
+
desired_config,
|
|
2044
|
+
manifest_images,
|
|
2045
|
+
current_outputs,
|
|
2046
|
+
twenty_guardrails,
|
|
2047
|
+
payload,
|
|
2048
|
+
)
|
|
2049
|
+
)
|
|
2050
|
+
return overrides
|
|
2051
|
+
|
|
2052
|
+
return overrides
|
|
2053
|
+
|
|
2054
|
+
|
|
2055
|
+
def existing_stage_secret_string(stage, suffix):
|
|
2056
|
+
"""Read a plain-string platform secret (e.g. thinkwork/<stage>/api-auth).
|
|
2057
|
+
|
|
2058
|
+
Returns "" when the secret does not exist yet (first install) or cannot
|
|
2059
|
+
be read — callers mint a fresh value in that case.
|
|
2060
|
+
"""
|
|
2061
|
+
try:
|
|
2062
|
+
body = output(
|
|
2063
|
+
[
|
|
2064
|
+
"aws",
|
|
2065
|
+
"secretsmanager",
|
|
2066
|
+
"get-secret-value",
|
|
2067
|
+
"--secret-id",
|
|
2068
|
+
f"thinkwork/{stage}/{suffix}",
|
|
2069
|
+
"--query",
|
|
2070
|
+
"SecretString",
|
|
2071
|
+
"--output",
|
|
2072
|
+
"text",
|
|
2073
|
+
]
|
|
2074
|
+
)
|
|
2075
|
+
return body.strip() if isinstance(body, str) else ""
|
|
2076
|
+
except Exception:
|
|
2077
|
+
return ""
|
|
2078
|
+
|
|
2079
|
+
|
|
2080
|
+
def existing_stage_secret_field(stage, field):
|
|
2081
|
+
try:
|
|
2082
|
+
body = output(
|
|
2083
|
+
[
|
|
2084
|
+
"aws",
|
|
2085
|
+
"secretsmanager",
|
|
2086
|
+
"get-secret-value",
|
|
2087
|
+
"--secret-id",
|
|
2088
|
+
f"thinkwork-{stage}-db-credentials",
|
|
2089
|
+
"--query",
|
|
2090
|
+
"SecretString",
|
|
2091
|
+
"--output",
|
|
2092
|
+
"text",
|
|
2093
|
+
]
|
|
2094
|
+
)
|
|
2095
|
+
secret = json.loads(body or "{}")
|
|
2096
|
+
value = secret.get(field)
|
|
2097
|
+
return value if isinstance(value, str) else ""
|
|
2098
|
+
except Exception:
|
|
2099
|
+
return ""
|
|
2100
|
+
|
|
2101
|
+
|
|
2102
|
+
def hcl_string(value):
|
|
2103
|
+
return json.dumps(value)
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
def release_runtime_image(name):
|
|
2107
|
+
if not MANIFEST.exists():
|
|
2108
|
+
return ""
|
|
2109
|
+
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
|
2110
|
+
for image in manifest.get("runtimeImages", []):
|
|
2111
|
+
if image.get("name") == name:
|
|
2112
|
+
return image.get("uri") or ""
|
|
2113
|
+
return ""
|
|
2114
|
+
|
|
2115
|
+
|
|
2116
|
+
def release_git_sha():
|
|
2117
|
+
if not MANIFEST.exists():
|
|
2118
|
+
return ""
|
|
2119
|
+
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
|
2120
|
+
release = manifest.get("release")
|
|
2121
|
+
if isinstance(release, dict):
|
|
2122
|
+
git_sha = release.get("gitSha")
|
|
2123
|
+
return git_sha if isinstance(git_sha, str) else ""
|
|
2124
|
+
return ""
|
|
2125
|
+
|
|
2126
|
+
|
|
2127
|
+
def ensure_release_manifest_available(manifest_url, manifest_sha256):
|
|
2128
|
+
if not manifest_url and not MANIFEST.exists():
|
|
2129
|
+
return
|
|
2130
|
+
if not MANIFEST.exists():
|
|
2131
|
+
download(manifest_url, MANIFEST)
|
|
2132
|
+
verify_release_manifest_digest(MANIFEST, manifest_sha256)
|
|
2133
|
+
|
|
2134
|
+
|
|
2135
|
+
def source_repo_and_ref(module_source, release_version):
|
|
2136
|
+
source = module_source.removeprefix("git::")
|
|
2137
|
+
source_path, _, query = source.partition("?")
|
|
2138
|
+
params = urllib.parse.parse_qs(query)
|
|
2139
|
+
ref = params.get("ref", [release_version])[0]
|
|
2140
|
+
if source_path == "thinkwork-ai/thinkwork/aws":
|
|
2141
|
+
repo = "https://github.com/thinkwork-ai/thinkwork.git"
|
|
2142
|
+
ref = release_git_sha() or release_version
|
|
2143
|
+
elif source_path.startswith("github.com/"):
|
|
2144
|
+
github_source = source_path
|
|
2145
|
+
if "//terraform/" in github_source:
|
|
2146
|
+
github_source = github_source.split("//terraform/", 1)[0]
|
|
2147
|
+
repo = f"https://{github_source.removesuffix('.git')}.git"
|
|
2148
|
+
elif ".git//" in source_path:
|
|
2149
|
+
repo = source_path.split(".git//", 1)[0] + ".git"
|
|
2150
|
+
elif "//terraform/" in source_path:
|
|
2151
|
+
repo = source_path.split("//terraform/", 1)[0]
|
|
2152
|
+
else:
|
|
2153
|
+
repo = source_path
|
|
2154
|
+
return repo, ref
|
|
2155
|
+
|
|
2156
|
+
|
|
2157
|
+
def terraform_module_source_and_version(module_source, module_version, release_version):
|
|
2158
|
+
source = module_source.removeprefix("git::")
|
|
2159
|
+
source_path, _, _query = source.partition("?")
|
|
2160
|
+
if source_path == "thinkwork-ai/thinkwork/aws":
|
|
2161
|
+
ref = release_git_sha() or release_version
|
|
2162
|
+
quoted_ref = urllib.parse.quote(ref, safe="")
|
|
2163
|
+
return (
|
|
2164
|
+
"git::https://github.com/thinkwork-ai/thinkwork.git"
|
|
2165
|
+
f"//terraform/modules/thinkwork?ref={quoted_ref}",
|
|
2166
|
+
"",
|
|
2167
|
+
)
|
|
2168
|
+
if module_source.startswith("git::") or ".git//" in source_path:
|
|
2169
|
+
return module_source, ""
|
|
2170
|
+
return module_source, module_version
|
|
2171
|
+
|
|
2172
|
+
|
|
2173
|
+
def is_pinned_thinkwork_module_source(module_source):
|
|
2174
|
+
source = module_source.removeprefix("git::")
|
|
2175
|
+
source_path, _, _query = source.partition("?")
|
|
2176
|
+
return source_path in {
|
|
2177
|
+
"https://github.com/thinkwork-ai/thinkwork.git//terraform/modules/thinkwork",
|
|
2178
|
+
"https://github.com/thinkwork-ai/thinkwork//terraform/modules/thinkwork",
|
|
2179
|
+
"github.com/thinkwork-ai/thinkwork.git//terraform/modules/thinkwork",
|
|
2180
|
+
"github.com/thinkwork-ai/thinkwork//terraform/modules/thinkwork",
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
|
|
2184
|
+
def selected_module_source_inputs(payload, release_version):
|
|
2185
|
+
payload_module_source = safe_get(payload, "terraformModuleSource", default="")
|
|
2186
|
+
if payload_module_source:
|
|
2187
|
+
return (
|
|
2188
|
+
payload_module_source,
|
|
2189
|
+
safe_get(payload, "terraformModuleVersion", default=""),
|
|
2190
|
+
)
|
|
2191
|
+
|
|
2192
|
+
module_source = os.environ["THINKWORK_TERRAFORM_MODULE_SOURCE"]
|
|
2193
|
+
module_version = os.environ.get("THINKWORK_TERRAFORM_MODULE_VERSION", "")
|
|
2194
|
+
if is_pinned_thinkwork_module_source(module_source):
|
|
2195
|
+
# Existing controllers may have persisted a concrete git ref for the
|
|
2196
|
+
# module source. Managed-app payloads still generate the wrapper from
|
|
2197
|
+
# the selected release, so the child module must follow that release
|
|
2198
|
+
# too or new app arguments fail Terraform config loading immediately.
|
|
2199
|
+
return "thinkwork-ai/thinkwork/aws", release_version.removeprefix("v")
|
|
2200
|
+
return module_source, module_version
|
|
2201
|
+
|
|
2202
|
+
|
|
2203
|
+
def checkout_source(module_source, release_version):
|
|
2204
|
+
if SOURCE.exists():
|
|
2205
|
+
return
|
|
2206
|
+
repo, ref = source_repo_and_ref(module_source, release_version)
|
|
2207
|
+
if not repo.startswith(("https://", "git@")):
|
|
2208
|
+
raise RuntimeError(f"Cannot initialize database schema from module source: {module_source}")
|
|
2209
|
+
run(["git", "clone", "--no-checkout", "--filter=blob:none", repo, str(SOURCE)])
|
|
2210
|
+
run(["git", "-C", str(SOURCE), "fetch", "--depth", "1", "origin", ref])
|
|
2211
|
+
run(["git", "-C", str(SOURCE), "checkout", "--detach", "FETCH_HEAD"])
|
|
2212
|
+
|
|
2213
|
+
|
|
2214
|
+
def database_url_from_outputs(outputs):
|
|
2215
|
+
endpoint = outputs.get("db_cluster_endpoint", {}).get("value")
|
|
2216
|
+
secret_arn = outputs.get("db_secret_arn", {}).get("value")
|
|
2217
|
+
database_name = outputs.get("database_name", {}).get("value") or "thinkwork"
|
|
2218
|
+
if not endpoint or not secret_arn:
|
|
2219
|
+
raise RuntimeError("Terraform outputs missing database endpoint or secret ARN")
|
|
2220
|
+
body = output(
|
|
2221
|
+
[
|
|
2222
|
+
"aws",
|
|
2223
|
+
"secretsmanager",
|
|
2224
|
+
"get-secret-value",
|
|
2225
|
+
"--secret-id",
|
|
2226
|
+
str(secret_arn),
|
|
2227
|
+
"--query",
|
|
2228
|
+
"SecretString",
|
|
2229
|
+
"--output",
|
|
2230
|
+
"text",
|
|
2231
|
+
]
|
|
2232
|
+
)
|
|
2233
|
+
secret = json.loads(body or "{}")
|
|
2234
|
+
username = secret.get("username") or "thinkwork_admin"
|
|
2235
|
+
password = secret.get("password") or ""
|
|
2236
|
+
if not password:
|
|
2237
|
+
raise RuntimeError("Database secret is missing password")
|
|
2238
|
+
return (
|
|
2239
|
+
"postgresql://"
|
|
2240
|
+
f"{urllib.parse.quote(str(username), safe='')}:"
|
|
2241
|
+
f"{urllib.parse.quote(str(password), safe='')}@"
|
|
2242
|
+
f"{endpoint}:5432/{database_name}?sslmode=require"
|
|
2243
|
+
)
|
|
2244
|
+
|
|
2245
|
+
|
|
2246
|
+
def psql_env(database_url):
|
|
2247
|
+
env = os.environ.copy()
|
|
2248
|
+
env["DATABASE_URL"] = database_url
|
|
2249
|
+
return env
|
|
2250
|
+
|
|
2251
|
+
|
|
2252
|
+
def psql(database_url, sql=None, file=None, variables=None):
|
|
2253
|
+
args = ["psql", database_url, "-v", "ON_ERROR_STOP=1"]
|
|
2254
|
+
for key, value in (variables or {}).items():
|
|
2255
|
+
args.extend(["-v", f"{key}={value}"])
|
|
2256
|
+
if file:
|
|
2257
|
+
args.extend(["-f", str(file)])
|
|
2258
|
+
return run(args)
|
|
2259
|
+
return subprocess.run(args, input=sql, check=True, text=True)
|
|
2260
|
+
|
|
2261
|
+
|
|
2262
|
+
def psql_output(database_url, sql):
|
|
2263
|
+
return output(["psql", database_url, "-tAc", sql])
|
|
2264
|
+
|
|
2265
|
+
|
|
2266
|
+
def put_secret_value(secret_id, payload):
|
|
2267
|
+
run(
|
|
2268
|
+
[
|
|
2269
|
+
"aws",
|
|
2270
|
+
"secretsmanager",
|
|
2271
|
+
"put-secret-value",
|
|
2272
|
+
"--secret-id",
|
|
2273
|
+
secret_id,
|
|
2274
|
+
"--secret-string",
|
|
2275
|
+
json.dumps(payload),
|
|
2276
|
+
]
|
|
2277
|
+
)
|
|
2278
|
+
|
|
2279
|
+
|
|
2280
|
+
def ensure_compliance_roles(database_url, outputs, vars_json):
|
|
2281
|
+
stage = vars_json["stage"]
|
|
2282
|
+
endpoint = outputs.get("db_cluster_endpoint", {}).get("value")
|
|
2283
|
+
database_name = outputs.get("database_name", {}).get("value") or "thinkwork"
|
|
2284
|
+
secrets_by_role = {
|
|
2285
|
+
"writer_pass": ("compliance_writer", f"thinkwork/{stage}/compliance/writer-credentials"),
|
|
2286
|
+
"drainer_pass": ("compliance_drainer", f"thinkwork/{stage}/compliance/drainer-credentials"),
|
|
2287
|
+
"reader_pass": ("compliance_reader", f"thinkwork/{stage}/compliance/reader-credentials"),
|
|
2288
|
+
}
|
|
2289
|
+
variables = {}
|
|
2290
|
+
for variable, (username, secret_id) in secrets_by_role.items():
|
|
2291
|
+
password = secrets.token_urlsafe(36)
|
|
2292
|
+
variables[variable] = password
|
|
2293
|
+
put_secret_value(
|
|
2294
|
+
secret_id,
|
|
2295
|
+
{
|
|
2296
|
+
"username": username,
|
|
2297
|
+
"password": password,
|
|
2298
|
+
"host": endpoint,
|
|
2299
|
+
"port": 5432,
|
|
2300
|
+
"dbname": database_name,
|
|
2301
|
+
},
|
|
2302
|
+
)
|
|
2303
|
+
psql(
|
|
2304
|
+
database_url,
|
|
2305
|
+
file=SOURCE / "packages/database-pg/drizzle/0070_compliance_aurora_roles.sql",
|
|
2306
|
+
variables=variables,
|
|
2307
|
+
)
|
|
2308
|
+
|
|
2309
|
+
|
|
2310
|
+
def migration_files():
|
|
2311
|
+
migrations = SOURCE / "packages/database-pg/drizzle"
|
|
2312
|
+
return sorted(path for path in migrations.glob("*.sql") if "rollback" not in path.name)
|
|
2313
|
+
|
|
2314
|
+
|
|
2315
|
+
def apply_migration_file(database_url, outputs, vars_json, path):
|
|
2316
|
+
if path.name == "0031_thread_cleanup_drops.sql":
|
|
2317
|
+
psql(
|
|
2318
|
+
database_url,
|
|
2319
|
+
sql="""
|
|
2320
|
+
DROP INDEX IF EXISTS public.idx_threads_tenant_status;
|
|
2321
|
+
DROP INDEX IF EXISTS public.idx_threads_parent_id;
|
|
2322
|
+
DROP TABLE IF EXISTS public.thread_comments CASCADE;
|
|
2323
|
+
""",
|
|
2324
|
+
)
|
|
2325
|
+
return
|
|
2326
|
+
if path.name == "0070_compliance_aurora_roles.sql":
|
|
2327
|
+
ensure_compliance_roles(database_url, outputs, vars_json)
|
|
2328
|
+
return
|
|
2329
|
+
psql(database_url, file=path, variables={"stage": vars_json["stage"]})
|
|
2330
|
+
|
|
2331
|
+
|
|
2332
|
+
def initialize_greenfield_database(database_url, outputs, vars_json):
|
|
2333
|
+
psql(
|
|
2334
|
+
database_url,
|
|
2335
|
+
sql="""
|
|
2336
|
+
DROP SCHEMA IF EXISTS public CASCADE;
|
|
2337
|
+
DROP SCHEMA IF EXISTS ontology CASCADE;
|
|
2338
|
+
DROP SCHEMA IF EXISTS compliance CASCADE;
|
|
2339
|
+
DROP SCHEMA IF EXISTS brain CASCADE;
|
|
2340
|
+
DROP SCHEMA IF EXISTS wiki CASCADE;
|
|
2341
|
+
CREATE SCHEMA public;
|
|
2342
|
+
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
2343
|
+
CREATE EXTENSION IF NOT EXISTS vector;
|
|
2344
|
+
""",
|
|
2345
|
+
)
|
|
2346
|
+
for path in migration_files():
|
|
2347
|
+
apply_migration_file(database_url, outputs, vars_json, path)
|
|
2348
|
+
|
|
2349
|
+
|
|
2350
|
+
def sql_literal(value):
|
|
2351
|
+
return "'" + str(value).replace("'", "''") + "'"
|
|
2352
|
+
|
|
2353
|
+
|
|
2354
|
+
def ensure_migration_ledger(database_url):
|
|
2355
|
+
psql(
|
|
2356
|
+
database_url,
|
|
2357
|
+
sql="""
|
|
2358
|
+
CREATE TABLE IF NOT EXISTS public.platform_schema_migrations (
|
|
2359
|
+
name text PRIMARY KEY,
|
|
2360
|
+
source text NOT NULL DEFAULT 'runner',
|
|
2361
|
+
applied_at timestamptz NOT NULL DEFAULT now()
|
|
2362
|
+
);
|
|
2363
|
+
""",
|
|
2364
|
+
)
|
|
2365
|
+
|
|
2366
|
+
|
|
2367
|
+
def recorded_platform_migrations(database_url):
|
|
2368
|
+
rows = psql_output(database_url, "SELECT name FROM public.platform_schema_migrations")
|
|
2369
|
+
return {line.strip() for line in rows.splitlines() if line.strip()}
|
|
2370
|
+
|
|
2371
|
+
|
|
2372
|
+
def record_platform_migrations(database_url, names, source):
|
|
2373
|
+
names = list(names)
|
|
2374
|
+
if not names:
|
|
2375
|
+
return
|
|
2376
|
+
values = ", ".join(f"({sql_literal(name)}, {sql_literal(source)})" for name in names)
|
|
2377
|
+
psql(
|
|
2378
|
+
database_url,
|
|
2379
|
+
sql=(
|
|
2380
|
+
"INSERT INTO public.platform_schema_migrations (name, source) "
|
|
2381
|
+
f"VALUES {values} ON CONFLICT (name) DO NOTHING;"
|
|
2382
|
+
),
|
|
2383
|
+
)
|
|
2384
|
+
|
|
2385
|
+
|
|
2386
|
+
def declared_migration_objects(path):
|
|
2387
|
+
"""Parse `-- creates*:` markers from a migration file's leading comment block."""
|
|
2388
|
+
objects = []
|
|
2389
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
2390
|
+
stripped = line.strip()
|
|
2391
|
+
if not stripped:
|
|
2392
|
+
continue
|
|
2393
|
+
if not stripped.startswith("--"):
|
|
2394
|
+
break
|
|
2395
|
+
for prefix, kind in MIGRATION_MARKER_KINDS:
|
|
2396
|
+
if stripped.startswith(prefix):
|
|
2397
|
+
name = stripped[len(prefix) :].strip()
|
|
2398
|
+
if name:
|
|
2399
|
+
objects.append((kind, name))
|
|
2400
|
+
break
|
|
2401
|
+
return objects
|
|
2402
|
+
|
|
2403
|
+
|
|
2404
|
+
def platform_migration_object_present(database_url, kind, name):
|
|
2405
|
+
"""True/False when verifiable against the database; None when the marker
|
|
2406
|
+
names something we cannot check (the transition then assumes applied)."""
|
|
2407
|
+
parts = [part for part in name.split(".") if part]
|
|
2408
|
+
if kind == "column" and len(parts) == 3:
|
|
2409
|
+
schema, table, column = parts
|
|
2410
|
+
return bool(
|
|
2411
|
+
psql_output(
|
|
2412
|
+
database_url,
|
|
2413
|
+
"SELECT 1 FROM information_schema.columns WHERE "
|
|
2414
|
+
f"table_schema = {sql_literal(schema)} AND table_name = {sql_literal(table)} "
|
|
2415
|
+
f"AND column_name = {sql_literal(column)}",
|
|
2416
|
+
).strip()
|
|
2417
|
+
)
|
|
2418
|
+
if kind == "constraint" and len(parts) == 3:
|
|
2419
|
+
schema, table, constraint = parts
|
|
2420
|
+
return bool(
|
|
2421
|
+
psql_output(
|
|
2422
|
+
database_url,
|
|
2423
|
+
"SELECT 1 FROM pg_constraint c "
|
|
2424
|
+
"JOIN pg_class r ON r.oid = c.conrelid "
|
|
2425
|
+
"JOIN pg_namespace n ON n.oid = r.relnamespace "
|
|
2426
|
+
f"WHERE n.nspname = {sql_literal(schema)} AND r.relname = {sql_literal(table)} "
|
|
2427
|
+
f"AND c.conname = {sql_literal(constraint)}",
|
|
2428
|
+
).strip()
|
|
2429
|
+
)
|
|
2430
|
+
if kind == "object" and len(parts) == 2:
|
|
2431
|
+
schema, obj = parts
|
|
2432
|
+
return bool(
|
|
2433
|
+
psql_output(
|
|
2434
|
+
database_url,
|
|
2435
|
+
f"SELECT 1 WHERE to_regclass({sql_literal(name)}) IS NOT NULL "
|
|
2436
|
+
"UNION ALL SELECT 1 FROM pg_proc p "
|
|
2437
|
+
"JOIN pg_namespace n ON n.oid = p.pronamespace "
|
|
2438
|
+
f"WHERE n.nspname = {sql_literal(schema)} AND p.proname = {sql_literal(obj)} "
|
|
2439
|
+
"LIMIT 1",
|
|
2440
|
+
).strip()
|
|
2441
|
+
)
|
|
2442
|
+
if kind == "object" and len(parts) == 3:
|
|
2443
|
+
schema, table, child = parts
|
|
2444
|
+
return bool(
|
|
2445
|
+
psql_output(
|
|
2446
|
+
database_url,
|
|
2447
|
+
"SELECT 1 FROM information_schema.columns WHERE "
|
|
2448
|
+
f"table_schema = {sql_literal(schema)} AND table_name = {sql_literal(table)} "
|
|
2449
|
+
f"AND column_name = {sql_literal(child)} "
|
|
2450
|
+
"UNION ALL SELECT 1 FROM pg_constraint c "
|
|
2451
|
+
"JOIN pg_class r ON r.oid = c.conrelid "
|
|
2452
|
+
"JOIN pg_namespace n ON n.oid = r.relnamespace "
|
|
2453
|
+
f"WHERE n.nspname = {sql_literal(schema)} AND r.relname = {sql_literal(table)} "
|
|
2454
|
+
f"AND c.conname = {sql_literal(child)} "
|
|
2455
|
+
"UNION ALL SELECT 1 FROM pg_trigger t "
|
|
2456
|
+
"JOIN pg_class r ON r.oid = t.tgrelid "
|
|
2457
|
+
"JOIN pg_namespace n ON n.oid = r.relnamespace "
|
|
2458
|
+
f"WHERE n.nspname = {sql_literal(schema)} AND r.relname = {sql_literal(table)} "
|
|
2459
|
+
f"AND t.tgname = {sql_literal(child)} AND NOT t.tgisinternal "
|
|
2460
|
+
"LIMIT 1",
|
|
2461
|
+
).strip()
|
|
2462
|
+
)
|
|
2463
|
+
return None
|
|
2464
|
+
|
|
2465
|
+
|
|
2466
|
+
def backfill_platform_migration_ledger(database_url):
|
|
2467
|
+
"""One-time ledger bootstrap for environments installed before the ledger
|
|
2468
|
+
existed. Every file shipping with this release is recorded as assumed
|
|
2469
|
+
applied — auto-re-running old files is unsafe (markers can name objects
|
|
2470
|
+
that later migrations intentionally dropped). Marker-verified drift is
|
|
2471
|
+
reported so an operator can true it up manually; releases after the
|
|
2472
|
+
transition apply through the exact pending path."""
|
|
2473
|
+
verified = []
|
|
2474
|
+
assumed = []
|
|
2475
|
+
for path in migration_files():
|
|
2476
|
+
verdicts = [
|
|
2477
|
+
platform_migration_object_present(database_url, kind, name)
|
|
2478
|
+
for kind, name in declared_migration_objects(path)
|
|
2479
|
+
]
|
|
2480
|
+
if any(verdict is False for verdict in verdicts):
|
|
2481
|
+
print(
|
|
2482
|
+
f"[migrations] transition WARNING: {path.name} declares objects missing from "
|
|
2483
|
+
"this database; apply it manually if the feature it backs is expected here"
|
|
2484
|
+
)
|
|
2485
|
+
assumed.append(path.name)
|
|
2486
|
+
elif any(verdict is True for verdict in verdicts):
|
|
2487
|
+
verified.append(path.name)
|
|
2488
|
+
else:
|
|
2489
|
+
assumed.append(path.name)
|
|
2490
|
+
record_platform_migrations(database_url, verified, "transition-verified")
|
|
2491
|
+
record_platform_migrations(database_url, assumed, "transition-assumed")
|
|
2492
|
+
|
|
2493
|
+
|
|
2494
|
+
def apply_pending_platform_migrations(database_url, outputs, vars_json):
|
|
2495
|
+
recorded = recorded_platform_migrations(database_url)
|
|
2496
|
+
for path in migration_files():
|
|
2497
|
+
if path.name in recorded:
|
|
2498
|
+
continue
|
|
2499
|
+
print(f"[migrations] applying {path.name}")
|
|
2500
|
+
apply_migration_file(database_url, outputs, vars_json, path)
|
|
2501
|
+
record_platform_migrations(database_url, [path.name], "runner")
|
|
2502
|
+
|
|
2503
|
+
|
|
2504
|
+
def seed_platform_bootstrap_defaults(database_url):
|
|
2505
|
+
psql(
|
|
2506
|
+
database_url,
|
|
2507
|
+
sql="""
|
|
2508
|
+
BEGIN;
|
|
2509
|
+
|
|
2510
|
+
INSERT INTO public.model_catalog (
|
|
2511
|
+
model_id,
|
|
2512
|
+
provider,
|
|
2513
|
+
display_name,
|
|
2514
|
+
input_cost_per_million,
|
|
2515
|
+
output_cost_per_million,
|
|
2516
|
+
context_window,
|
|
2517
|
+
max_output_tokens,
|
|
2518
|
+
supports_vision,
|
|
2519
|
+
supports_tools,
|
|
2520
|
+
is_available
|
|
2521
|
+
) VALUES
|
|
2522
|
+
(
|
|
2523
|
+
'us.anthropic.claude-sonnet-4-6',
|
|
2524
|
+
'anthropic',
|
|
2525
|
+
'Claude Sonnet 4.6',
|
|
2526
|
+
3.00,
|
|
2527
|
+
15.00,
|
|
2528
|
+
200000,
|
|
2529
|
+
64000,
|
|
2530
|
+
true,
|
|
2531
|
+
true,
|
|
2532
|
+
true
|
|
2533
|
+
),
|
|
2534
|
+
(
|
|
2535
|
+
'us.anthropic.claude-opus-4-6-v1',
|
|
2536
|
+
'anthropic',
|
|
2537
|
+
'Claude Opus 4.6',
|
|
2538
|
+
15.00,
|
|
2539
|
+
75.00,
|
|
2540
|
+
200000,
|
|
2541
|
+
32000,
|
|
2542
|
+
true,
|
|
2543
|
+
true,
|
|
2544
|
+
true
|
|
2545
|
+
),
|
|
2546
|
+
(
|
|
2547
|
+
'us.anthropic.claude-haiku-4-5-20251001-v1:0',
|
|
2548
|
+
'anthropic',
|
|
2549
|
+
'Claude Haiku 4.5',
|
|
2550
|
+
0.80,
|
|
2551
|
+
4.00,
|
|
2552
|
+
200000,
|
|
2553
|
+
64000,
|
|
2554
|
+
true,
|
|
2555
|
+
true,
|
|
2556
|
+
true
|
|
2557
|
+
)
|
|
2558
|
+
ON CONFLICT (model_id) DO UPDATE SET
|
|
2559
|
+
provider = EXCLUDED.provider,
|
|
2560
|
+
display_name = EXCLUDED.display_name,
|
|
2561
|
+
input_cost_per_million = EXCLUDED.input_cost_per_million,
|
|
2562
|
+
output_cost_per_million = EXCLUDED.output_cost_per_million,
|
|
2563
|
+
context_window = EXCLUDED.context_window,
|
|
2564
|
+
max_output_tokens = EXCLUDED.max_output_tokens,
|
|
2565
|
+
supports_vision = EXCLUDED.supports_vision,
|
|
2566
|
+
supports_tools = EXCLUDED.supports_tools,
|
|
2567
|
+
is_available = EXCLUDED.is_available,
|
|
2568
|
+
updated_at = now();
|
|
2569
|
+
|
|
2570
|
+
INSERT INTO public.tenant_settings (tenant_id, default_model)
|
|
2571
|
+
SELECT id, 'us.anthropic.claude-sonnet-4-6'
|
|
2572
|
+
FROM public.tenants
|
|
2573
|
+
ON CONFLICT (tenant_id) DO UPDATE SET
|
|
2574
|
+
default_model = COALESCE(public.tenant_settings.default_model, EXCLUDED.default_model),
|
|
2575
|
+
updated_at = now();
|
|
2576
|
+
|
|
2577
|
+
INSERT INTO public.agents (
|
|
2578
|
+
tenant_id,
|
|
2579
|
+
name,
|
|
2580
|
+
slug,
|
|
2581
|
+
workspace_folder_name,
|
|
2582
|
+
source,
|
|
2583
|
+
runtime,
|
|
2584
|
+
status,
|
|
2585
|
+
system_prompt,
|
|
2586
|
+
model,
|
|
2587
|
+
is_platform_default
|
|
2588
|
+
)
|
|
2589
|
+
SELECT
|
|
2590
|
+
t.id,
|
|
2591
|
+
'ThinkWork Agent',
|
|
2592
|
+
'thinkwork-agent-' || left(md5(t.id::text), 12),
|
|
2593
|
+
'thinkwork-agent',
|
|
2594
|
+
'system',
|
|
2595
|
+
'pi',
|
|
2596
|
+
'idle',
|
|
2597
|
+
'You are ThinkWork Agent, the default assistant for this workspace.',
|
|
2598
|
+
'us.anthropic.claude-sonnet-4-6',
|
|
2599
|
+
true
|
|
2600
|
+
FROM public.tenants t
|
|
2601
|
+
WHERE NOT EXISTS (
|
|
2602
|
+
SELECT 1
|
|
2603
|
+
FROM public.agents a
|
|
2604
|
+
WHERE a.tenant_id = t.id
|
|
2605
|
+
AND a.is_platform_default IS TRUE
|
|
2606
|
+
);
|
|
2607
|
+
|
|
2608
|
+
UPDATE public.agents
|
|
2609
|
+
SET model = 'us.anthropic.claude-sonnet-4-6',
|
|
2610
|
+
updated_at = now()
|
|
2611
|
+
WHERE is_platform_default IS TRUE
|
|
2612
|
+
AND model IS NULL;
|
|
2613
|
+
|
|
2614
|
+
WITH default_models AS (
|
|
2615
|
+
SELECT tenant_id, default_model AS model_id
|
|
2616
|
+
FROM public.tenant_settings
|
|
2617
|
+
WHERE default_model IS NOT NULL
|
|
2618
|
+
UNION
|
|
2619
|
+
SELECT tenant_id, model AS model_id
|
|
2620
|
+
FROM public.agents
|
|
2621
|
+
WHERE model IS NOT NULL
|
|
2622
|
+
UNION
|
|
2623
|
+
SELECT tenant_id, model AS model_id
|
|
2624
|
+
FROM public.agent_templates
|
|
2625
|
+
WHERE model IS NOT NULL
|
|
2626
|
+
),
|
|
2627
|
+
available_defaults AS (
|
|
2628
|
+
SELECT DISTINCT
|
|
2629
|
+
u.tenant_id,
|
|
2630
|
+
u.id AS user_id,
|
|
2631
|
+
d.model_id
|
|
2632
|
+
FROM public.users u
|
|
2633
|
+
JOIN default_models d
|
|
2634
|
+
ON d.tenant_id = u.tenant_id
|
|
2635
|
+
JOIN public.model_catalog mc
|
|
2636
|
+
ON mc.model_id = d.model_id
|
|
2637
|
+
AND mc.is_available IS TRUE
|
|
2638
|
+
WHERE u.tenant_id IS NOT NULL
|
|
2639
|
+
)
|
|
2640
|
+
INSERT INTO public.user_model_approvals (tenant_id, user_id, model_id)
|
|
2641
|
+
SELECT tenant_id, user_id, model_id
|
|
2642
|
+
FROM available_defaults
|
|
2643
|
+
ON CONFLICT (tenant_id, user_id, model_id) DO NOTHING;
|
|
2644
|
+
|
|
2645
|
+
COMMIT;
|
|
2646
|
+
""",
|
|
2647
|
+
)
|
|
2648
|
+
|
|
2649
|
+
|
|
2650
|
+
def is_valid_tenant_slug(value):
|
|
2651
|
+
if not value or len(value) > 63:
|
|
2652
|
+
return False
|
|
2653
|
+
if value[0] == "-" or value[-1] == "-":
|
|
2654
|
+
return False
|
|
2655
|
+
return all(ch.isascii() and (ch.islower() or ch.isdigit() or ch == "-") for ch in value)
|
|
2656
|
+
|
|
2657
|
+
|
|
2658
|
+
def is_plausible_email(value):
|
|
2659
|
+
if not value or any(ch.isspace() or ch in "'\"\\" for ch in value):
|
|
2660
|
+
return False
|
|
2661
|
+
local, sep, domain = value.partition("@")
|
|
2662
|
+
return bool(sep and local and "." in domain and domain[0] != "." and domain[-1] != ".")
|
|
2663
|
+
|
|
2664
|
+
|
|
2665
|
+
def first_admin_email(vars_json):
|
|
2666
|
+
raw = vars_json.get("platform_operator_emails") or ""
|
|
2667
|
+
first = raw.split(",")[0].strip()
|
|
2668
|
+
return first if is_plausible_email(first) else ""
|
|
2669
|
+
|
|
2670
|
+
|
|
2671
|
+
def first_admin_tenant_slug(payload, runner_secrets, vars_json):
|
|
2672
|
+
"""Slug for the first-run tenant. MUST equal the customer-domain label when a
|
|
2673
|
+
customer domain is configured (KTD8: email-inbound resolves the tenant from
|
|
2674
|
+
the recipient subdomain), so the domain label outranks everything except an
|
|
2675
|
+
explicit override."""
|
|
2676
|
+
explicit = (
|
|
2677
|
+
safe_get(
|
|
2678
|
+
runner_secrets,
|
|
2679
|
+
"tenantSlug",
|
|
2680
|
+
default=safe_get(payload, "tenantSlug", default=""),
|
|
2681
|
+
)
|
|
2682
|
+
.strip()
|
|
2683
|
+
.lower()
|
|
2684
|
+
)
|
|
2685
|
+
domain = (vars_json.get("customer_domain") or "").strip().lower()
|
|
2686
|
+
domain_label = domain.split(".")[0] if domain else ""
|
|
2687
|
+
candidate = explicit or domain_label or vars_json["stage"]
|
|
2688
|
+
if not is_valid_tenant_slug(candidate):
|
|
2689
|
+
raise RuntimeError(f"first-admin tenant slug {candidate!r} fails the slug pattern")
|
|
2690
|
+
if domain_label and candidate != domain_label:
|
|
2691
|
+
raise RuntimeError(
|
|
2692
|
+
f"first-admin tenant slug {candidate!r} must equal the customer domain "
|
|
2693
|
+
f"label {domain_label!r} (KTD8) — inbound email resolves tenants by subdomain"
|
|
2694
|
+
)
|
|
2695
|
+
return candidate
|
|
2696
|
+
|
|
2697
|
+
|
|
2698
|
+
def cognito_idp(args, region, check=True):
|
|
2699
|
+
result = subprocess.run(
|
|
2700
|
+
["aws", "cognito-idp", *args, "--region", region],
|
|
2701
|
+
capture_output=True,
|
|
2702
|
+
text=True,
|
|
2703
|
+
)
|
|
2704
|
+
if check and result.returncode != 0:
|
|
2705
|
+
raise RuntimeError(f"cognito-idp {args[0]} failed: {result.stderr.strip()}")
|
|
2706
|
+
return result
|
|
2707
|
+
|
|
2708
|
+
|
|
2709
|
+
def ensure_first_admin_cognito_user(user_pool_id, email, region):
|
|
2710
|
+
"""Idempotently ensure the first admin exists in the user pool. Returns
|
|
2711
|
+
(sub, created). New users get Cognito's invite email with a temporary
|
|
2712
|
+
password — sent by Cognito's default sender, so it works while the
|
|
2713
|
+
account's SES identity is still sandboxed."""
|
|
2714
|
+
probe = cognito_idp(
|
|
2715
|
+
["admin-get-user", "--user-pool-id", user_pool_id, "--username", email],
|
|
2716
|
+
region,
|
|
2717
|
+
check=False,
|
|
2718
|
+
)
|
|
2719
|
+
created = False
|
|
2720
|
+
if probe.returncode != 0:
|
|
2721
|
+
if "UserNotFoundException" not in (probe.stderr or ""):
|
|
2722
|
+
raise RuntimeError(f"admin-get-user failed: {probe.stderr.strip()}")
|
|
2723
|
+
cognito_idp(
|
|
2724
|
+
[
|
|
2725
|
+
"admin-create-user",
|
|
2726
|
+
"--user-pool-id",
|
|
2727
|
+
user_pool_id,
|
|
2728
|
+
"--username",
|
|
2729
|
+
email,
|
|
2730
|
+
"--user-attributes",
|
|
2731
|
+
f"Name=email,Value={email}",
|
|
2732
|
+
"Name=email_verified,Value=true",
|
|
2733
|
+
"--desired-delivery-mediums",
|
|
2734
|
+
"EMAIL",
|
|
2735
|
+
],
|
|
2736
|
+
region,
|
|
2737
|
+
)
|
|
2738
|
+
created = True
|
|
2739
|
+
probe = cognito_idp(
|
|
2740
|
+
["admin-get-user", "--user-pool-id", user_pool_id, "--username", email],
|
|
2741
|
+
region,
|
|
2742
|
+
)
|
|
2743
|
+
attributes = json.loads(probe.stdout or "{}").get("UserAttributes", [])
|
|
2744
|
+
sub = next((a["Value"] for a in attributes if a.get("Name") == "sub"), "")
|
|
2745
|
+
if not sub:
|
|
2746
|
+
raise RuntimeError(f"could not resolve Cognito sub for {email}")
|
|
2747
|
+
return sub, created
|
|
2748
|
+
|
|
2749
|
+
|
|
2750
|
+
FIRST_ADMIN_PROVISION_SQL = """
|
|
2751
|
+
BEGIN;
|
|
2752
|
+
|
|
2753
|
+
-- Tenant: created only when the environment has no tenants at all. An
|
|
2754
|
+
-- established environment is never given an extra tenant by this step.
|
|
2755
|
+
INSERT INTO public.tenants (name, slug, plan, issue_prefix, issue_counter)
|
|
2756
|
+
SELECT :'tenant_name', :'tenant_slug', 'free', 'TW', 0
|
|
2757
|
+
WHERE NOT EXISTS (SELECT 1 FROM public.tenants);
|
|
2758
|
+
|
|
2759
|
+
INSERT INTO public.tenant_settings (tenant_id, default_model)
|
|
2760
|
+
SELECT id, 'us.anthropic.claude-sonnet-4-6'
|
|
2761
|
+
FROM public.tenants WHERE slug = :'tenant_slug'
|
|
2762
|
+
ON CONFLICT (tenant_id) DO NOTHING;
|
|
2763
|
+
|
|
2764
|
+
INSERT INTO public.users (tenant_id, email, name, workspace_folder_name, cognito_sub)
|
|
2765
|
+
SELECT t.id, :'admin_email', :'admin_name', :'admin_folder', :'cognito_sub'
|
|
2766
|
+
FROM public.tenants t
|
|
2767
|
+
WHERE t.slug = :'tenant_slug'
|
|
2768
|
+
AND NOT EXISTS (
|
|
2769
|
+
SELECT 1 FROM public.users WHERE lower(email) = lower(:'admin_email')
|
|
2770
|
+
);
|
|
2771
|
+
|
|
2772
|
+
-- Heal a stranded user row (signed in before provisioning existed).
|
|
2773
|
+
UPDATE public.users u
|
|
2774
|
+
SET tenant_id = t.id,
|
|
2775
|
+
cognito_sub = COALESCE(u.cognito_sub, :'cognito_sub'),
|
|
2776
|
+
updated_at = now()
|
|
2777
|
+
FROM public.tenants t
|
|
2778
|
+
WHERE t.slug = :'tenant_slug'
|
|
2779
|
+
AND lower(u.email) = lower(:'admin_email')
|
|
2780
|
+
AND u.tenant_id IS NULL;
|
|
2781
|
+
|
|
2782
|
+
INSERT INTO public.tenant_members (tenant_id, principal_type, principal_id, role, status)
|
|
2783
|
+
SELECT u.tenant_id, 'user', u.id, 'owner', 'active'
|
|
2784
|
+
FROM public.users u
|
|
2785
|
+
JOIN public.tenants t ON t.id = u.tenant_id
|
|
2786
|
+
WHERE t.slug = :'tenant_slug'
|
|
2787
|
+
AND lower(u.email) = lower(:'admin_email')
|
|
2788
|
+
AND NOT EXISTS (
|
|
2789
|
+
SELECT 1 FROM public.tenant_members m
|
|
2790
|
+
WHERE m.tenant_id = u.tenant_id
|
|
2791
|
+
AND m.principal_type = 'user'
|
|
2792
|
+
AND m.principal_id = u.id
|
|
2793
|
+
);
|
|
2794
|
+
|
|
2795
|
+
UPDATE public.tenants t
|
|
2796
|
+
SET pending_owner_email = NULL,
|
|
2797
|
+
first_admin_claim_required = false,
|
|
2798
|
+
first_admin_claimed_at = COALESCE(t.first_admin_claimed_at, now()),
|
|
2799
|
+
first_admin_claimed_user_id = COALESCE(t.first_admin_claimed_user_id, u.id),
|
|
2800
|
+
updated_at = now()
|
|
2801
|
+
FROM public.users u
|
|
2802
|
+
WHERE t.slug = :'tenant_slug'
|
|
2803
|
+
AND u.tenant_id = t.id
|
|
2804
|
+
AND lower(u.email) = lower(:'admin_email');
|
|
2805
|
+
|
|
2806
|
+
-- Default Space: created only when the tenant has no Spaces, so a fresh
|
|
2807
|
+
-- environment's composer has a target without operator setup.
|
|
2808
|
+
INSERT INTO public.spaces (
|
|
2809
|
+
tenant_id, slug, workspace_folder_name, name, description,
|
|
2810
|
+
status, kind, access_mode, template_key, config
|
|
2811
|
+
)
|
|
2812
|
+
SELECT t.id, 'general', 'general', 'General',
|
|
2813
|
+
'Default workspace created at install time.',
|
|
2814
|
+
'active', 'custom', 'public', 'general',
|
|
2815
|
+
'{"workflow":"custom","version":1,"source":"first_admin_bootstrap"}'::jsonb
|
|
2816
|
+
FROM public.tenants t
|
|
2817
|
+
WHERE t.slug = :'tenant_slug'
|
|
2818
|
+
AND NOT EXISTS (SELECT 1 FROM public.spaces s WHERE s.tenant_id = t.id);
|
|
2819
|
+
|
|
2820
|
+
INSERT INTO public.space_members (tenant_id, space_id, user_id, role, notification_preference)
|
|
2821
|
+
SELECT s.tenant_id, s.id, u.id, 'owner', 'subscribed'
|
|
2822
|
+
FROM public.spaces s
|
|
2823
|
+
JOIN public.tenants t ON t.id = s.tenant_id
|
|
2824
|
+
JOIN public.users u ON u.tenant_id = t.id AND lower(u.email) = lower(:'admin_email')
|
|
2825
|
+
WHERE t.slug = :'tenant_slug'
|
|
2826
|
+
AND s.slug = 'general'
|
|
2827
|
+
AND NOT EXISTS (
|
|
2828
|
+
SELECT 1 FROM public.space_members m WHERE m.space_id = s.id AND m.user_id = u.id
|
|
2829
|
+
);
|
|
2830
|
+
|
|
2831
|
+
COMMIT;
|
|
2832
|
+
"""
|
|
2833
|
+
|
|
2834
|
+
|
|
2835
|
+
def ensure_first_admin(outputs_path, vars_json, payload, runner_secrets):
|
|
2836
|
+
"""First-run admin provisioning: when the deployment carries an adminEmail,
|
|
2837
|
+
make a fresh environment sign-in-ready — tenant (slug = customer-domain
|
|
2838
|
+
label, KTD8), Cognito admin user with an invite email, owner membership,
|
|
2839
|
+
custom:tenant_id, and a default Space. Idempotent; never mutates an
|
|
2840
|
+
environment that already has tenants beyond attaching the admin when the
|
|
2841
|
+
expected tenant slug exists. Non-fatal: failures are logged and echoed
|
|
2842
|
+
into deployment evidence (firstAdminBootstrap) instead of failing an
|
|
2843
|
+
otherwise healthy deploy."""
|
|
2844
|
+
global FIRST_ADMIN_EVIDENCE
|
|
2845
|
+
email = first_admin_email(vars_json)
|
|
2846
|
+
if not email:
|
|
2847
|
+
FIRST_ADMIN_EVIDENCE = {"status": "skipped", "reason": "no adminEmail configured"}
|
|
2848
|
+
return
|
|
2849
|
+
try:
|
|
2850
|
+
slug = first_admin_tenant_slug(payload, runner_secrets, vars_json)
|
|
2851
|
+
outputs = json.loads(outputs_path.read_text(encoding="utf-8"))
|
|
2852
|
+
user_pool_id = (outputs.get("user_pool_id") or {}).get("value", "")
|
|
2853
|
+
if not user_pool_id:
|
|
2854
|
+
raise RuntimeError("terraform outputs are missing user_pool_id")
|
|
2855
|
+
database_url = database_url_from_outputs(outputs)
|
|
2856
|
+
|
|
2857
|
+
tenant_count = int(psql_output(database_url, "SELECT count(*) FROM public.tenants") or 0)
|
|
2858
|
+
slug_present = bool(
|
|
2859
|
+
psql_output(
|
|
2860
|
+
database_url,
|
|
2861
|
+
f"SELECT 1 FROM public.tenants WHERE slug = {pg_literal(slug)}",
|
|
2862
|
+
).strip()
|
|
2863
|
+
)
|
|
2864
|
+
if tenant_count and not slug_present:
|
|
2865
|
+
FIRST_ADMIN_EVIDENCE = {
|
|
2866
|
+
"status": "skipped",
|
|
2867
|
+
"reason": f"environment already has {tenant_count} tenant(s) and none is {slug!r}",
|
|
2868
|
+
}
|
|
2869
|
+
print(f"[runner] first-admin bootstrap skipped: {FIRST_ADMIN_EVIDENCE['reason']}")
|
|
2870
|
+
return
|
|
2871
|
+
|
|
2872
|
+
region = vars_json.get("region") or os.environ.get("AWS_REGION") or "us-east-1"
|
|
2873
|
+
sub, created = ensure_first_admin_cognito_user(user_pool_id, email, region)
|
|
2874
|
+
local_part = email.split("@")[0]
|
|
2875
|
+
folder = (
|
|
2876
|
+
"".join(
|
|
2877
|
+
ch if (ch.isascii() and (ch.islower() or ch.isdigit())) else "-"
|
|
2878
|
+
for ch in local_part.lower()
|
|
2879
|
+
).strip("-")
|
|
2880
|
+
or "user"
|
|
2881
|
+
)
|
|
2882
|
+
psql(
|
|
2883
|
+
database_url,
|
|
2884
|
+
sql=FIRST_ADMIN_PROVISION_SQL,
|
|
2885
|
+
variables={
|
|
2886
|
+
"tenant_name": slug.capitalize(),
|
|
2887
|
+
"tenant_slug": slug,
|
|
2888
|
+
"admin_email": email,
|
|
2889
|
+
"admin_name": local_part,
|
|
2890
|
+
"admin_folder": folder,
|
|
2891
|
+
"cognito_sub": sub,
|
|
2892
|
+
},
|
|
2893
|
+
)
|
|
2894
|
+
tenant_id = psql_output(
|
|
2895
|
+
database_url,
|
|
2896
|
+
f"SELECT id FROM public.tenants WHERE slug = {pg_literal(slug)}",
|
|
2897
|
+
).strip()
|
|
2898
|
+
if tenant_id:
|
|
2899
|
+
cognito_idp(
|
|
2900
|
+
[
|
|
2901
|
+
"admin-update-user-attributes",
|
|
2902
|
+
"--user-pool-id",
|
|
2903
|
+
user_pool_id,
|
|
2904
|
+
"--username",
|
|
2905
|
+
email,
|
|
2906
|
+
"--user-attributes",
|
|
2907
|
+
f"Name=custom:tenant_id,Value={tenant_id}",
|
|
2908
|
+
],
|
|
2909
|
+
region,
|
|
2910
|
+
)
|
|
2911
|
+
# Re-run the platform seed so the just-created tenant gets the default
|
|
2912
|
+
# agent / settings / model approvals (the earlier seed saw no tenants).
|
|
2913
|
+
seed_platform_bootstrap_defaults(database_url)
|
|
2914
|
+
FIRST_ADMIN_EVIDENCE = {
|
|
2915
|
+
"status": "succeeded",
|
|
2916
|
+
"adminEmail": email,
|
|
2917
|
+
"tenantSlug": slug,
|
|
2918
|
+
"tenantId": tenant_id,
|
|
2919
|
+
"cognitoUserCreated": created,
|
|
2920
|
+
"inviteEmailSent": created,
|
|
2921
|
+
}
|
|
2922
|
+
print(
|
|
2923
|
+
f"[runner] first-admin bootstrap succeeded: tenant={slug} admin={email} "
|
|
2924
|
+
f"cognitoUserCreated={created}"
|
|
2925
|
+
)
|
|
2926
|
+
except Exception as exc:
|
|
2927
|
+
FIRST_ADMIN_EVIDENCE = {"status": "failed", "adminEmail": email, "error": str(exc)}
|
|
2928
|
+
print(f"[runner] first-admin bootstrap FAILED (non-fatal): {exc}")
|
|
2929
|
+
|
|
2930
|
+
|
|
2931
|
+
def pg_literal(value):
|
|
2932
|
+
return "'" + value.replace("'", "''") + "'"
|
|
2933
|
+
|
|
2934
|
+
|
|
2935
|
+
def push_database_schema(outputs_path, vars_json):
|
|
2936
|
+
outputs = json.loads(outputs_path.read_text(encoding="utf-8"))
|
|
2937
|
+
checkout_source(
|
|
2938
|
+
vars_json.get("deployment_terraform_module_source")
|
|
2939
|
+
or os.environ["THINKWORK_TERRAFORM_MODULE_SOURCE"],
|
|
2940
|
+
os.environ.get("THINKWORK_RELEASE_VERSION", "main"),
|
|
2941
|
+
)
|
|
2942
|
+
database_url = database_url_from_outputs(outputs)
|
|
2943
|
+
fresh = not psql_output(database_url, "SELECT to_regclass('public.tenants')").strip()
|
|
2944
|
+
ledger_present = bool(
|
|
2945
|
+
psql_output(database_url, "SELECT to_regclass('public.platform_schema_migrations')").strip()
|
|
2946
|
+
)
|
|
2947
|
+
if fresh:
|
|
2948
|
+
initialize_greenfield_database(database_url, outputs, vars_json)
|
|
2949
|
+
ensure_migration_ledger(database_url)
|
|
2950
|
+
if fresh:
|
|
2951
|
+
record_platform_migrations(
|
|
2952
|
+
database_url, [path.name for path in migration_files()], "greenfield"
|
|
2953
|
+
)
|
|
2954
|
+
elif not ledger_present:
|
|
2955
|
+
backfill_platform_migration_ledger(database_url)
|
|
2956
|
+
apply_pending_platform_migrations(database_url, outputs, vars_json)
|
|
2957
|
+
seed_platform_bootstrap_defaults(database_url)
|
|
2958
|
+
migrations = SOURCE / "packages/database-pg/drizzle"
|
|
2959
|
+
for name in POST_SEED_MIGRATIONS:
|
|
2960
|
+
path = migrations / name
|
|
2961
|
+
if not path.is_file():
|
|
2962
|
+
raise RuntimeError(f"Required platform migration is missing: {path}")
|
|
2963
|
+
apply_migration_file(database_url, outputs, vars_json, path)
|
|
2964
|
+
|
|
2965
|
+
|
|
2966
|
+
def write_runner_files(payload, runner_secrets):
|
|
2967
|
+
preserved_config = payload.get("preservedConfig")
|
|
2968
|
+
if not isinstance(preserved_config, dict):
|
|
2969
|
+
preserved_config = {}
|
|
2970
|
+
reviewed_payload = dict(payload)
|
|
2971
|
+
reviewed_payload.update(preserved_config)
|
|
2972
|
+
stage = safe_get(
|
|
2973
|
+
payload,
|
|
2974
|
+
"stage",
|
|
2975
|
+
"environmentName",
|
|
2976
|
+
default=os.environ["THINKWORK_STAGE"],
|
|
2977
|
+
)
|
|
2978
|
+
region = safe_get(
|
|
2979
|
+
payload,
|
|
2980
|
+
"awsRegion",
|
|
2981
|
+
"region",
|
|
2982
|
+
default=os.environ.get("AWS_REGION") or "us-east-1",
|
|
2983
|
+
)
|
|
2984
|
+
account_id = safe_get(payload, "awsAccountId", "accountId", default="")
|
|
2985
|
+
if not account_id:
|
|
2986
|
+
account_id = output(
|
|
2987
|
+
["aws", "sts", "get-caller-identity", "--query", "Account", "--output", "text"]
|
|
2988
|
+
)
|
|
2989
|
+
current_state = current_terraform_state(stage)
|
|
2990
|
+
current_outputs = current_state.get("outputs")
|
|
2991
|
+
if not isinstance(current_outputs, dict):
|
|
2992
|
+
current_outputs = {}
|
|
2993
|
+
|
|
2994
|
+
selected_release = release_selection(payload)
|
|
2995
|
+
release_version = selected_release.get("version") or "unresolved"
|
|
2996
|
+
release_manifest_url = selected_release.get("manifestUrl") or ""
|
|
2997
|
+
release_manifest_sha256 = selected_release.get("manifestSha256") or ""
|
|
2998
|
+
release_manifest_signature_url = selected_release.get("manifestSignatureUrl") or ""
|
|
2999
|
+
release_manifest_trust_policy_value = (
|
|
3000
|
+
selected_release.get("manifestTrustPolicy") or "allow_unsigned_canary"
|
|
3001
|
+
)
|
|
3002
|
+
if release_manifest_trust_policy_value not in RELEASE_MANIFEST_TRUST_POLICIES:
|
|
3003
|
+
raise RuntimeError(
|
|
3004
|
+
"Unsupported release manifest trust policy "
|
|
3005
|
+
f"{release_manifest_trust_policy_value!r}; expected one of "
|
|
3006
|
+
f"{sorted(RELEASE_MANIFEST_TRUST_POLICIES)}"
|
|
3007
|
+
)
|
|
3008
|
+
ensure_release_manifest_available(release_manifest_url, release_manifest_sha256)
|
|
3009
|
+
release_manifest_trusted_keys_json = json.dumps(
|
|
3010
|
+
trusted_release_keys(),
|
|
3011
|
+
separators=(",", ":"),
|
|
3012
|
+
sort_keys=True,
|
|
3013
|
+
)
|
|
3014
|
+
module_source, module_version = selected_module_source_inputs(
|
|
3015
|
+
payload,
|
|
3016
|
+
release_version,
|
|
3017
|
+
)
|
|
3018
|
+
terraform_module_source, terraform_module_version = terraform_module_source_and_version(
|
|
3019
|
+
module_source,
|
|
3020
|
+
module_version,
|
|
3021
|
+
release_version,
|
|
3022
|
+
)
|
|
3023
|
+
module_version_line = (
|
|
3024
|
+
f" version = {hcl_string(terraform_module_version)}\n" if terraform_module_version else ""
|
|
3025
|
+
)
|
|
3026
|
+
db_password = safe_get(
|
|
3027
|
+
runner_secrets,
|
|
3028
|
+
"dbPassword",
|
|
3029
|
+
"databasePassword",
|
|
3030
|
+
default=safe_get(payload, "dbPassword", "databasePassword", default=""),
|
|
3031
|
+
)
|
|
3032
|
+
if not db_password:
|
|
3033
|
+
db_password = existing_stage_secret_field(stage, "password")
|
|
3034
|
+
api_auth_secret = safe_get(
|
|
3035
|
+
runner_secrets,
|
|
3036
|
+
"apiAuthSecret",
|
|
3037
|
+
default=safe_get(payload, "apiAuthSecret", default=""),
|
|
3038
|
+
)
|
|
3039
|
+
if not db_password:
|
|
3040
|
+
db_password = secrets.token_urlsafe(36)
|
|
3041
|
+
if not api_auth_secret:
|
|
3042
|
+
# Reuse the stage's existing service-auth secret (written to Secrets
|
|
3043
|
+
# Manager by terraform on the previous apply). Minting a fresh value
|
|
3044
|
+
# on every run rotated API_AUTH_SECRET per release, which silently
|
|
3045
|
+
# drifted the env copy away from the pinned Secrets Manager copy —
|
|
3046
|
+
# harmless while readers are env-first, fatal once env drops (R8).
|
|
3047
|
+
api_auth_secret = existing_stage_secret_string(stage, "api-auth")
|
|
3048
|
+
if not api_auth_secret:
|
|
3049
|
+
api_auth_secret = secrets.token_urlsafe(48)
|
|
3050
|
+
|
|
3051
|
+
vars_json = {
|
|
3052
|
+
"stage": stage,
|
|
3053
|
+
"region": region,
|
|
3054
|
+
"account_id": account_id,
|
|
3055
|
+
"db_password": db_password,
|
|
3056
|
+
"api_auth_secret": api_auth_secret,
|
|
3057
|
+
"database_engine": safe_get(
|
|
3058
|
+
reviewed_payload,
|
|
3059
|
+
"databaseEngine",
|
|
3060
|
+
default="aurora-serverless",
|
|
3061
|
+
),
|
|
3062
|
+
"enable_hindsight": safe_get_bool(
|
|
3063
|
+
{},
|
|
3064
|
+
reviewed_payload,
|
|
3065
|
+
"enableHindsight",
|
|
3066
|
+
default=False,
|
|
3067
|
+
),
|
|
3068
|
+
"platform_operator_emails": safe_get(
|
|
3069
|
+
runner_secrets,
|
|
3070
|
+
"adminEmail",
|
|
3071
|
+
"platformOperatorEmails",
|
|
3072
|
+
default=safe_get(
|
|
3073
|
+
reviewed_payload,
|
|
3074
|
+
"adminEmail",
|
|
3075
|
+
"platformOperatorEmails",
|
|
3076
|
+
default="",
|
|
3077
|
+
),
|
|
3078
|
+
),
|
|
3079
|
+
"google_oauth_client_id": safe_get(
|
|
3080
|
+
runner_secrets,
|
|
3081
|
+
"googleOauthClientId",
|
|
3082
|
+
default=safe_get(
|
|
3083
|
+
reviewed_payload,
|
|
3084
|
+
"googleOauthClientId",
|
|
3085
|
+
default="",
|
|
3086
|
+
),
|
|
3087
|
+
),
|
|
3088
|
+
"google_oauth_client_secret": safe_get(
|
|
3089
|
+
runner_secrets,
|
|
3090
|
+
"googleOauthClientSecret",
|
|
3091
|
+
default=safe_get(
|
|
3092
|
+
reviewed_payload,
|
|
3093
|
+
"googleOauthClientSecret",
|
|
3094
|
+
default="",
|
|
3095
|
+
),
|
|
3096
|
+
),
|
|
3097
|
+
"cognito_email_source_arn": safe_get(
|
|
3098
|
+
runner_secrets,
|
|
3099
|
+
"cognitoEmailSourceArn",
|
|
3100
|
+
default=safe_get(
|
|
3101
|
+
reviewed_payload,
|
|
3102
|
+
"cognitoEmailSourceArn",
|
|
3103
|
+
default="",
|
|
3104
|
+
),
|
|
3105
|
+
),
|
|
3106
|
+
"cognito_from_email_address": safe_get(
|
|
3107
|
+
runner_secrets,
|
|
3108
|
+
"cognitoFromEmailAddress",
|
|
3109
|
+
default=safe_get(
|
|
3110
|
+
reviewed_payload,
|
|
3111
|
+
"cognitoFromEmailAddress",
|
|
3112
|
+
default="",
|
|
3113
|
+
),
|
|
3114
|
+
),
|
|
3115
|
+
"cognito_reply_to_email_address": safe_get(
|
|
3116
|
+
runner_secrets,
|
|
3117
|
+
"cognitoReplyToEmailAddress",
|
|
3118
|
+
default=safe_get(
|
|
3119
|
+
reviewed_payload,
|
|
3120
|
+
"cognitoReplyToEmailAddress",
|
|
3121
|
+
default="",
|
|
3122
|
+
),
|
|
3123
|
+
),
|
|
3124
|
+
"app_domain": safe_get(
|
|
3125
|
+
runner_secrets,
|
|
3126
|
+
"appDomain",
|
|
3127
|
+
default=safe_get(reviewed_payload, "appDomain", default=""),
|
|
3128
|
+
),
|
|
3129
|
+
"app_certificate_arn": safe_get(
|
|
3130
|
+
runner_secrets,
|
|
3131
|
+
"appCertificateArn",
|
|
3132
|
+
default=safe_get(reviewed_payload, "appCertificateArn", default=""),
|
|
3133
|
+
),
|
|
3134
|
+
"customer_domain": safe_get(
|
|
3135
|
+
runner_secrets,
|
|
3136
|
+
"customerDomain",
|
|
3137
|
+
default=safe_get(reviewed_payload, "customerDomain", default=""),
|
|
3138
|
+
),
|
|
3139
|
+
"customer_domain_delegated": safe_get_bool(
|
|
3140
|
+
runner_secrets,
|
|
3141
|
+
reviewed_payload,
|
|
3142
|
+
"customerDomainDelegated",
|
|
3143
|
+
default=False,
|
|
3144
|
+
),
|
|
3145
|
+
"customer_domain_legacy_retired": safe_get_bool(
|
|
3146
|
+
runner_secrets,
|
|
3147
|
+
reviewed_payload,
|
|
3148
|
+
"customerDomainLegacyRetired",
|
|
3149
|
+
default=False,
|
|
3150
|
+
),
|
|
3151
|
+
"lambda_artifact_bucket": os.environ["THINKWORK_RELEASE_ARTIFACT_BUCKET"],
|
|
3152
|
+
"lambda_artifact_prefix": f"releases/{release_version}/lambdas",
|
|
3153
|
+
"deployment_release_version": release_version,
|
|
3154
|
+
"deployment_release_manifest_url": release_manifest_url,
|
|
3155
|
+
"deployment_release_manifest_sha256": release_manifest_sha256,
|
|
3156
|
+
"deployment_release_manifest_signature_url": release_manifest_signature_url,
|
|
3157
|
+
"deployment_release_manifest_trust_policy": release_manifest_trust_policy_value,
|
|
3158
|
+
"deployment_release_manifest_trusted_keys_json": release_manifest_trusted_keys_json,
|
|
3159
|
+
"deployment_state_machine_arn": os.environ.get(
|
|
3160
|
+
"THINKWORK_DEPLOYMENT_STATE_MACHINE_ARN",
|
|
3161
|
+
"",
|
|
3162
|
+
),
|
|
3163
|
+
"deployment_evidence_bucket": os.environ.get("THINKWORK_EVIDENCE_BUCKET", ""),
|
|
3164
|
+
"deployment_terraform_module_source": terraform_module_source,
|
|
3165
|
+
"deployment_terraform_module_version": terraform_module_version,
|
|
3166
|
+
"agentcore_pi_source_image_uri": safe_get(
|
|
3167
|
+
payload,
|
|
3168
|
+
"agentcorePiSourceImageUri",
|
|
3169
|
+
default=release_runtime_image("agentcore-pi-amd64"),
|
|
3170
|
+
),
|
|
3171
|
+
}
|
|
3172
|
+
enforce_customer_domain_preservation(
|
|
3173
|
+
current_outputs,
|
|
3174
|
+
vars_json,
|
|
3175
|
+
payload,
|
|
3176
|
+
runner_secrets,
|
|
3177
|
+
)
|
|
3178
|
+
vars_json.update(
|
|
3179
|
+
managed_app_terraform_overrides(payload, stage, account_id, current_outputs, current_state)
|
|
3180
|
+
)
|
|
3181
|
+
|
|
3182
|
+
TF.mkdir(parents=True, exist_ok=True)
|
|
3183
|
+
(TF / "backend.hcl").write_text(
|
|
3184
|
+
"\n".join(
|
|
3185
|
+
[
|
|
3186
|
+
f"bucket = {hcl_string(os.environ['THINKWORK_TERRAFORM_STATE_BUCKET'])}",
|
|
3187
|
+
f"key = {hcl_string(terraform_backend_key(stage, payload))}",
|
|
3188
|
+
f"region = {hcl_string(region)}",
|
|
3189
|
+
f"dynamodb_table = {hcl_string(os.environ['THINKWORK_TERRAFORM_LOCK_TABLE'])}",
|
|
3190
|
+
"encrypt = true",
|
|
3191
|
+
"",
|
|
3192
|
+
]
|
|
3193
|
+
),
|
|
3194
|
+
encoding="utf-8",
|
|
3195
|
+
)
|
|
3196
|
+
(TF / "terraform.auto.tfvars.json").write_text(
|
|
3197
|
+
json.dumps(vars_json, indent=2, sort_keys=True),
|
|
3198
|
+
encoding="utf-8",
|
|
3199
|
+
)
|
|
3200
|
+
(TF / "main.tf").write_text(
|
|
3201
|
+
f"""
|
|
3202
|
+
terraform {{
|
|
3203
|
+
required_version = ">= 1.5"
|
|
3204
|
+
|
|
3205
|
+
backend "s3" {{}}
|
|
3206
|
+
|
|
3207
|
+
required_providers {{
|
|
3208
|
+
aws = {{
|
|
3209
|
+
source = "hashicorp/aws"
|
|
3210
|
+
version = "~> 5.0"
|
|
3211
|
+
}}
|
|
3212
|
+
cloudflare = {{
|
|
3213
|
+
source = "cloudflare/cloudflare"
|
|
3214
|
+
version = "~> 4.0"
|
|
3215
|
+
}}
|
|
3216
|
+
}}
|
|
3217
|
+
}}
|
|
3218
|
+
|
|
3219
|
+
provider "aws" {{
|
|
3220
|
+
region = var.region
|
|
3221
|
+
}}
|
|
3222
|
+
|
|
3223
|
+
# The thinkwork module declares configuration_aliases = [aws.us_east_1]
|
|
3224
|
+
# (customer-domain ACM certificates must live in us-east-1 for CloudFront),
|
|
3225
|
+
# so every generated root must define the alias and pass it through the
|
|
3226
|
+
# module's providers mapping — even when no customer domain is configured.
|
|
3227
|
+
provider "aws" {{
|
|
3228
|
+
alias = "us_east_1"
|
|
3229
|
+
region = "us-east-1"
|
|
3230
|
+
}}
|
|
3231
|
+
|
|
3232
|
+
# Existing greenfield stacks keep Cloudflare DNS resources in the same state
|
|
3233
|
+
# file. Managed-app targeted plans still need the provider schema available
|
|
3234
|
+
# even though they do not change those records.
|
|
3235
|
+
provider "cloudflare" {{}}
|
|
3236
|
+
|
|
3237
|
+
variable "stage" {{
|
|
3238
|
+
type = string
|
|
3239
|
+
}}
|
|
3240
|
+
|
|
3241
|
+
variable "region" {{
|
|
3242
|
+
type = string
|
|
3243
|
+
}}
|
|
3244
|
+
|
|
3245
|
+
variable "account_id" {{
|
|
3246
|
+
type = string
|
|
3247
|
+
}}
|
|
3248
|
+
|
|
3249
|
+
variable "db_password" {{
|
|
3250
|
+
type = string
|
|
3251
|
+
sensitive = true
|
|
3252
|
+
}}
|
|
3253
|
+
|
|
3254
|
+
variable "api_auth_secret" {{
|
|
3255
|
+
type = string
|
|
3256
|
+
sensitive = true
|
|
3257
|
+
}}
|
|
3258
|
+
|
|
3259
|
+
variable "database_engine" {{
|
|
3260
|
+
type = string
|
|
3261
|
+
}}
|
|
3262
|
+
|
|
3263
|
+
variable "enable_hindsight" {{
|
|
3264
|
+
type = bool
|
|
3265
|
+
}}
|
|
3266
|
+
|
|
3267
|
+
variable "platform_operator_emails" {{
|
|
3268
|
+
type = string
|
|
3269
|
+
}}
|
|
3270
|
+
|
|
3271
|
+
variable "cognito_email_source_arn" {{
|
|
3272
|
+
type = string
|
|
3273
|
+
}}
|
|
3274
|
+
|
|
3275
|
+
variable "cognito_from_email_address" {{
|
|
3276
|
+
type = string
|
|
3277
|
+
}}
|
|
3278
|
+
|
|
3279
|
+
variable "cognito_reply_to_email_address" {{
|
|
3280
|
+
type = string
|
|
3281
|
+
}}
|
|
3282
|
+
|
|
3283
|
+
variable "app_domain" {{
|
|
3284
|
+
type = string
|
|
3285
|
+
}}
|
|
3286
|
+
|
|
3287
|
+
variable "app_certificate_arn" {{
|
|
3288
|
+
type = string
|
|
3289
|
+
}}
|
|
3290
|
+
|
|
3291
|
+
variable "customer_domain" {{
|
|
3292
|
+
type = string
|
|
3293
|
+
}}
|
|
3294
|
+
|
|
3295
|
+
variable "customer_domain_delegated" {{
|
|
3296
|
+
type = bool
|
|
3297
|
+
}}
|
|
3298
|
+
|
|
3299
|
+
variable "customer_domain_legacy_retired" {{
|
|
3300
|
+
type = bool
|
|
3301
|
+
}}
|
|
3302
|
+
|
|
3303
|
+
variable "google_oauth_client_id" {{
|
|
3304
|
+
type = string
|
|
3305
|
+
}}
|
|
3306
|
+
|
|
3307
|
+
variable "google_oauth_client_secret" {{
|
|
3308
|
+
type = string
|
|
3309
|
+
sensitive = true
|
|
3310
|
+
}}
|
|
3311
|
+
|
|
3312
|
+
variable "lambda_artifact_bucket" {{
|
|
3313
|
+
type = string
|
|
3314
|
+
}}
|
|
3315
|
+
|
|
3316
|
+
variable "lambda_artifact_prefix" {{
|
|
3317
|
+
type = string
|
|
3318
|
+
}}
|
|
3319
|
+
|
|
3320
|
+
variable "agentcore_pi_source_image_uri" {{
|
|
3321
|
+
type = string
|
|
3322
|
+
}}
|
|
3323
|
+
|
|
3324
|
+
variable "deployment_release_version" {{
|
|
3325
|
+
type = string
|
|
3326
|
+
}}
|
|
3327
|
+
|
|
3328
|
+
variable "deployment_release_manifest_url" {{
|
|
3329
|
+
type = string
|
|
3330
|
+
}}
|
|
3331
|
+
|
|
3332
|
+
variable "deployment_release_manifest_sha256" {{
|
|
3333
|
+
type = string
|
|
3334
|
+
}}
|
|
3335
|
+
|
|
3336
|
+
variable "deployment_release_manifest_signature_url" {{
|
|
3337
|
+
type = string
|
|
3338
|
+
}}
|
|
3339
|
+
|
|
3340
|
+
variable "deployment_release_manifest_trust_policy" {{
|
|
3341
|
+
type = string
|
|
3342
|
+
}}
|
|
3343
|
+
|
|
3344
|
+
variable "deployment_release_manifest_trusted_keys_json" {{
|
|
3345
|
+
type = string
|
|
3346
|
+
}}
|
|
3347
|
+
|
|
3348
|
+
variable "deployment_state_machine_arn" {{
|
|
3349
|
+
type = string
|
|
3350
|
+
}}
|
|
3351
|
+
|
|
3352
|
+
variable "deployment_evidence_bucket" {{
|
|
3353
|
+
type = string
|
|
3354
|
+
}}
|
|
3355
|
+
|
|
3356
|
+
variable "deployment_terraform_module_source" {{
|
|
3357
|
+
type = string
|
|
3358
|
+
}}
|
|
3359
|
+
|
|
3360
|
+
variable "deployment_terraform_module_version" {{
|
|
3361
|
+
type = string
|
|
3362
|
+
}}
|
|
3363
|
+
|
|
3364
|
+
variable "deployment_control_plane_create_secret_placeholders" {{
|
|
3365
|
+
type = bool
|
|
3366
|
+
}}
|
|
3367
|
+
|
|
3368
|
+
variable "enable_cognee" {{
|
|
3369
|
+
type = bool
|
|
3370
|
+
}}
|
|
3371
|
+
|
|
3372
|
+
variable "cognee_image_uri" {{
|
|
3373
|
+
type = string
|
|
3374
|
+
}}
|
|
3375
|
+
|
|
3376
|
+
variable "cognee_db_username" {{
|
|
3377
|
+
type = string
|
|
3378
|
+
}}
|
|
3379
|
+
|
|
3380
|
+
variable "cognee_db_name" {{
|
|
3381
|
+
type = string
|
|
3382
|
+
}}
|
|
3383
|
+
|
|
3384
|
+
variable "cognee_db_password_secret_arn" {{
|
|
3385
|
+
type = string
|
|
3386
|
+
}}
|
|
3387
|
+
|
|
3388
|
+
variable "cognee_backend_mode" {{
|
|
3389
|
+
type = string
|
|
3390
|
+
}}
|
|
3391
|
+
|
|
3392
|
+
variable "cognee_desired_count" {{
|
|
3393
|
+
type = number
|
|
3394
|
+
}}
|
|
3395
|
+
|
|
3396
|
+
variable "cognee_brain_tenant_id" {{
|
|
3397
|
+
type = string
|
|
3398
|
+
}}
|
|
3399
|
+
|
|
3400
|
+
variable "cognee_brain_instance_key" {{
|
|
3401
|
+
type = string
|
|
3402
|
+
}}
|
|
3403
|
+
|
|
3404
|
+
variable "cognee_brain_storage_tier" {{
|
|
3405
|
+
type = string
|
|
3406
|
+
}}
|
|
3407
|
+
|
|
3408
|
+
variable "cognee_brain_s3_artifact_root" {{
|
|
3409
|
+
type = string
|
|
3410
|
+
}}
|
|
3411
|
+
|
|
3412
|
+
variable "cognee_brain_s3_manifest_root" {{
|
|
3413
|
+
type = string
|
|
3414
|
+
}}
|
|
3415
|
+
|
|
3416
|
+
variable "cognee_brain_s3_vault_projection_root" {{
|
|
3417
|
+
type = string
|
|
3418
|
+
}}
|
|
3419
|
+
|
|
3420
|
+
variable "cognee_private_substrate_mode" {{
|
|
3421
|
+
type = bool
|
|
3422
|
+
}}
|
|
3423
|
+
|
|
3424
|
+
variable "cognee_llm_provider" {{
|
|
3425
|
+
type = string
|
|
3426
|
+
}}
|
|
3427
|
+
|
|
3428
|
+
variable "cognee_llm_model" {{
|
|
3429
|
+
type = string
|
|
3430
|
+
}}
|
|
3431
|
+
|
|
3432
|
+
variable "cognee_embedding_provider" {{
|
|
3433
|
+
type = string
|
|
3434
|
+
}}
|
|
3435
|
+
|
|
3436
|
+
variable "cognee_embedding_model" {{
|
|
3437
|
+
type = string
|
|
3438
|
+
}}
|
|
3439
|
+
|
|
3440
|
+
variable "cognee_embedding_dimensions" {{
|
|
3441
|
+
type = number
|
|
3442
|
+
}}
|
|
3443
|
+
|
|
3444
|
+
variable "cognee_vector_db_provider" {{
|
|
3445
|
+
type = string
|
|
3446
|
+
}}
|
|
3447
|
+
|
|
3448
|
+
variable "cognee_graph_database_provider" {{
|
|
3449
|
+
type = string
|
|
3450
|
+
}}
|
|
3451
|
+
|
|
3452
|
+
variable "cognee_neptune_graph_id" {{
|
|
3453
|
+
type = string
|
|
3454
|
+
}}
|
|
3455
|
+
|
|
3456
|
+
variable "cognee_neptune_endpoint" {{
|
|
3457
|
+
type = string
|
|
3458
|
+
}}
|
|
3459
|
+
|
|
3460
|
+
variable "cognee_production_posture" {{
|
|
3461
|
+
type = string
|
|
3462
|
+
}}
|
|
3463
|
+
|
|
3464
|
+
variable "cognee_bedrock_model_resource_arns" {{
|
|
3465
|
+
type = list(string)
|
|
3466
|
+
}}
|
|
3467
|
+
|
|
3468
|
+
variable "enable_deployment_control_plane" {{
|
|
3469
|
+
type = bool
|
|
3470
|
+
}}
|
|
3471
|
+
|
|
3472
|
+
variable "twenty_provisioned" {{
|
|
3473
|
+
type = bool
|
|
3474
|
+
}}
|
|
3475
|
+
|
|
3476
|
+
variable "twenty_runtime_enabled" {{
|
|
3477
|
+
type = bool
|
|
3478
|
+
}}
|
|
3479
|
+
|
|
3480
|
+
variable "twenty_image_uri" {{
|
|
3481
|
+
type = string
|
|
3482
|
+
}}
|
|
3483
|
+
|
|
3484
|
+
variable "twenty_db_username" {{
|
|
3485
|
+
type = string
|
|
3486
|
+
}}
|
|
3487
|
+
|
|
3488
|
+
variable "twenty_db_name" {{
|
|
3489
|
+
type = string
|
|
3490
|
+
}}
|
|
3491
|
+
|
|
3492
|
+
variable "twenty_db_url_secret_arn" {{
|
|
3493
|
+
type = string
|
|
3494
|
+
}}
|
|
3495
|
+
|
|
3496
|
+
variable "twenty_encryption_key_secret_arn" {{
|
|
3497
|
+
type = string
|
|
3498
|
+
}}
|
|
3499
|
+
|
|
3500
|
+
variable "twenty_email_from_address" {{
|
|
3501
|
+
type = string
|
|
3502
|
+
}}
|
|
3503
|
+
|
|
3504
|
+
variable "twenty_email_from_name" {{
|
|
3505
|
+
type = string
|
|
3506
|
+
}}
|
|
3507
|
+
|
|
3508
|
+
variable "twenty_public_url" {{
|
|
3509
|
+
type = string
|
|
3510
|
+
}}
|
|
3511
|
+
|
|
3512
|
+
variable "twenty_certificate_arn" {{
|
|
3513
|
+
type = string
|
|
3514
|
+
}}
|
|
3515
|
+
|
|
3516
|
+
variable "n8n_provisioned" {{
|
|
3517
|
+
type = bool
|
|
3518
|
+
}}
|
|
3519
|
+
|
|
3520
|
+
variable "n8n_runtime_enabled" {{
|
|
3521
|
+
type = bool
|
|
3522
|
+
}}
|
|
3523
|
+
|
|
3524
|
+
variable "n8n_image_uri" {{
|
|
3525
|
+
type = string
|
|
3526
|
+
}}
|
|
3527
|
+
|
|
3528
|
+
variable "n8n_database_admin_secret_arn" {{
|
|
3529
|
+
type = string
|
|
3530
|
+
}}
|
|
3531
|
+
|
|
3532
|
+
variable "n8n_database_url_secret_arn" {{
|
|
3533
|
+
type = string
|
|
3534
|
+
}}
|
|
3535
|
+
|
|
3536
|
+
variable "n8n_database_username" {{
|
|
3537
|
+
type = string
|
|
3538
|
+
}}
|
|
3539
|
+
|
|
3540
|
+
variable "n8n_database_name" {{
|
|
3541
|
+
type = string
|
|
3542
|
+
}}
|
|
3543
|
+
|
|
3544
|
+
variable "n8n_encryption_key_secret_arn" {{
|
|
3545
|
+
type = string
|
|
3546
|
+
}}
|
|
3547
|
+
|
|
3548
|
+
variable "n8n_operator_secret_arn" {{
|
|
3549
|
+
type = string
|
|
3550
|
+
}}
|
|
3551
|
+
|
|
3552
|
+
variable "n8n_service_credential_secret_arn" {{
|
|
3553
|
+
type = string
|
|
3554
|
+
}}
|
|
3555
|
+
|
|
3556
|
+
variable "n8n_storage_bucket_name" {{
|
|
3557
|
+
type = string
|
|
3558
|
+
}}
|
|
3559
|
+
|
|
3560
|
+
variable "n8n_create_storage_bucket" {{
|
|
3561
|
+
type = bool
|
|
3562
|
+
}}
|
|
3563
|
+
|
|
3564
|
+
variable "n8n_storage_prefix" {{
|
|
3565
|
+
type = string
|
|
3566
|
+
}}
|
|
3567
|
+
|
|
3568
|
+
variable "n8n_domain" {{
|
|
3569
|
+
type = string
|
|
3570
|
+
}}
|
|
3571
|
+
|
|
3572
|
+
variable "n8n_public_url" {{
|
|
3573
|
+
type = string
|
|
3574
|
+
}}
|
|
3575
|
+
|
|
3576
|
+
variable "n8n_certificate_arn" {{
|
|
3577
|
+
type = string
|
|
3578
|
+
}}
|
|
3579
|
+
|
|
3580
|
+
variable "n8n_main_desired_count" {{
|
|
3581
|
+
type = number
|
|
3582
|
+
}}
|
|
3583
|
+
|
|
3584
|
+
variable "n8n_worker_desired_count" {{
|
|
3585
|
+
type = number
|
|
3586
|
+
}}
|
|
3587
|
+
|
|
3588
|
+
variable "n8n_worker_concurrency" {{
|
|
3589
|
+
type = number
|
|
3590
|
+
}}
|
|
3591
|
+
|
|
3592
|
+
variable "n8n_container_port" {{
|
|
3593
|
+
type = number
|
|
3594
|
+
}}
|
|
3595
|
+
|
|
3596
|
+
variable "n8n_queue_mode" {{
|
|
3597
|
+
type = bool
|
|
3598
|
+
}}
|
|
3599
|
+
|
|
3600
|
+
variable "n8n_task_runners_enabled" {{
|
|
3601
|
+
type = bool
|
|
3602
|
+
}}
|
|
3603
|
+
|
|
3604
|
+
variable "n8n_package_config_digest" {{
|
|
3605
|
+
type = string
|
|
3606
|
+
}}
|
|
3607
|
+
|
|
3608
|
+
variable "n8n_custom_package_specs" {{
|
|
3609
|
+
type = list(string)
|
|
3610
|
+
}}
|
|
3611
|
+
|
|
3612
|
+
variable "n8n_execution_data_storage_mode" {{
|
|
3613
|
+
type = string
|
|
3614
|
+
}}
|
|
3615
|
+
|
|
3616
|
+
variable "n8n_binary_data_mode" {{
|
|
3617
|
+
type = string
|
|
3618
|
+
}}
|
|
3619
|
+
|
|
3620
|
+
variable "n8n_cache_engine" {{
|
|
3621
|
+
type = string
|
|
3622
|
+
}}
|
|
3623
|
+
|
|
3624
|
+
variable "n8n_cache_engine_version" {{
|
|
3625
|
+
type = string
|
|
3626
|
+
}}
|
|
3627
|
+
|
|
3628
|
+
variable "n8n_cache_parameter_group_family" {{
|
|
3629
|
+
type = string
|
|
3630
|
+
}}
|
|
3631
|
+
|
|
3632
|
+
variable "n8n_cache_node_type" {{
|
|
3633
|
+
type = string
|
|
3634
|
+
}}
|
|
3635
|
+
|
|
3636
|
+
variable "n8n_cache_num_cache_clusters" {{
|
|
3637
|
+
type = number
|
|
3638
|
+
}}
|
|
3639
|
+
|
|
3640
|
+
variable "n8n_allowed_public_cidr_blocks" {{
|
|
3641
|
+
type = list(string)
|
|
3642
|
+
}}
|
|
3643
|
+
|
|
3644
|
+
variable "n8n_kms_key_arns" {{
|
|
3645
|
+
type = list(string)
|
|
3646
|
+
}}
|
|
3647
|
+
|
|
3648
|
+
variable "cloudflare_zone_id" {{
|
|
3649
|
+
type = string
|
|
3650
|
+
}}
|
|
3651
|
+
|
|
3652
|
+
variable "n8n_dns_enabled" {{
|
|
3653
|
+
type = bool
|
|
3654
|
+
}}
|
|
3655
|
+
|
|
3656
|
+
variable "n8n_dns_name" {{
|
|
3657
|
+
type = string
|
|
3658
|
+
}}
|
|
3659
|
+
|
|
3660
|
+
locals {{
|
|
3661
|
+
n8n_managed_certificate_enabled = (
|
|
3662
|
+
var.n8n_provisioned &&
|
|
3663
|
+
var.n8n_certificate_arn == "" &&
|
|
3664
|
+
var.n8n_domain != "" &&
|
|
3665
|
+
var.cloudflare_zone_id != ""
|
|
3666
|
+
)
|
|
3667
|
+
n8n_effective_certificate_arn = (
|
|
3668
|
+
var.n8n_certificate_arn != ""
|
|
3669
|
+
? var.n8n_certificate_arn
|
|
3670
|
+
: (
|
|
3671
|
+
local.n8n_managed_certificate_enabled
|
|
3672
|
+
? aws_acm_certificate_validation.n8n[0].certificate_arn
|
|
3673
|
+
: ""
|
|
3674
|
+
)
|
|
3675
|
+
)
|
|
3676
|
+
}}
|
|
3677
|
+
|
|
3678
|
+
resource "aws_acm_certificate" "n8n" {{
|
|
3679
|
+
count = local.n8n_managed_certificate_enabled ? 1 : 0
|
|
3680
|
+
|
|
3681
|
+
domain_name = var.n8n_domain
|
|
3682
|
+
validation_method = "DNS"
|
|
3683
|
+
|
|
3684
|
+
lifecycle {{
|
|
3685
|
+
create_before_destroy = true
|
|
3686
|
+
}}
|
|
3687
|
+
|
|
3688
|
+
tags = {{
|
|
3689
|
+
Name = "thinkwork-${{var.stage}}-n8n"
|
|
3690
|
+
}}
|
|
3691
|
+
}}
|
|
3692
|
+
|
|
3693
|
+
resource "cloudflare_record" "n8n_acm_validation" {{
|
|
3694
|
+
for_each = local.n8n_managed_certificate_enabled ? {{
|
|
3695
|
+
for cert in aws_acm_certificate.n8n :
|
|
3696
|
+
cert.domain_name => tolist(cert.domain_validation_options)[0]
|
|
3697
|
+
}} : {{}}
|
|
3698
|
+
|
|
3699
|
+
zone_id = var.cloudflare_zone_id
|
|
3700
|
+
name = trimsuffix(each.value.resource_record_name, ".")
|
|
3701
|
+
content = trimsuffix(each.value.resource_record_value, ".")
|
|
3702
|
+
type = each.value.resource_record_type
|
|
3703
|
+
ttl = 60
|
|
3704
|
+
proxied = false
|
|
3705
|
+
comment = "ACM DNS validation for ${{each.key}}"
|
|
3706
|
+
|
|
3707
|
+
allow_overwrite = true
|
|
3708
|
+
}}
|
|
3709
|
+
|
|
3710
|
+
resource "aws_acm_certificate_validation" "n8n" {{
|
|
3711
|
+
count = local.n8n_managed_certificate_enabled ? 1 : 0
|
|
3712
|
+
|
|
3713
|
+
certificate_arn = aws_acm_certificate.n8n[0].arn
|
|
3714
|
+
validation_record_fqdns = [
|
|
3715
|
+
for record in cloudflare_record.n8n_acm_validation : record.hostname
|
|
3716
|
+
]
|
|
3717
|
+
}}
|
|
3718
|
+
|
|
3719
|
+
module "thinkwork" {{
|
|
3720
|
+
source = {hcl_string(terraform_module_source)}
|
|
3721
|
+
{module_version_line}
|
|
3722
|
+
providers = {{
|
|
3723
|
+
aws.us_east_1 = aws.us_east_1
|
|
3724
|
+
}}
|
|
3725
|
+
|
|
3726
|
+
stage = var.stage
|
|
3727
|
+
region = var.region
|
|
3728
|
+
account_id = var.account_id
|
|
3729
|
+
|
|
3730
|
+
db_password = var.db_password
|
|
3731
|
+
api_auth_secret = var.api_auth_secret
|
|
3732
|
+
database_engine = var.database_engine
|
|
3733
|
+
|
|
3734
|
+
google_oauth_client_id = var.google_oauth_client_id
|
|
3735
|
+
google_oauth_client_secret = var.google_oauth_client_secret
|
|
3736
|
+
platform_operator_emails = var.platform_operator_emails
|
|
3737
|
+
|
|
3738
|
+
cognito_email_source_arn = var.cognito_email_source_arn
|
|
3739
|
+
cognito_from_email_address = var.cognito_from_email_address
|
|
3740
|
+
cognito_reply_to_email_address = var.cognito_reply_to_email_address
|
|
3741
|
+
|
|
3742
|
+
app_domain = var.app_domain
|
|
3743
|
+
app_certificate_arn = var.app_certificate_arn
|
|
3744
|
+
|
|
3745
|
+
customer_domain = var.customer_domain
|
|
3746
|
+
customer_domain_delegated = var.customer_domain_delegated
|
|
3747
|
+
customer_domain_legacy_retired = var.customer_domain_legacy_retired
|
|
3748
|
+
|
|
3749
|
+
lambda_artifact_bucket = var.lambda_artifact_bucket
|
|
3750
|
+
lambda_artifact_prefix = var.lambda_artifact_prefix
|
|
3751
|
+
require_lambda_artifacts = true
|
|
3752
|
+
agentcore_pi_source_image_uri = var.agentcore_pi_source_image_uri
|
|
3753
|
+
|
|
3754
|
+
enable_hindsight = var.enable_hindsight
|
|
3755
|
+
enable_workspace_orchestration = true
|
|
3756
|
+
|
|
3757
|
+
enable_cognee = var.enable_cognee
|
|
3758
|
+
cognee_image_uri = var.cognee_image_uri
|
|
3759
|
+
cognee_db_username = var.cognee_db_username
|
|
3760
|
+
cognee_db_name = var.cognee_db_name
|
|
3761
|
+
cognee_db_password_secret_arn = var.cognee_db_password_secret_arn
|
|
3762
|
+
cognee_backend_mode = var.cognee_backend_mode
|
|
3763
|
+
cognee_desired_count = var.cognee_desired_count
|
|
3764
|
+
cognee_brain_tenant_id = var.cognee_brain_tenant_id
|
|
3765
|
+
cognee_brain_instance_key = var.cognee_brain_instance_key
|
|
3766
|
+
cognee_brain_storage_tier = var.cognee_brain_storage_tier
|
|
3767
|
+
cognee_brain_s3_artifact_root = var.cognee_brain_s3_artifact_root
|
|
3768
|
+
cognee_brain_s3_manifest_root = var.cognee_brain_s3_manifest_root
|
|
3769
|
+
cognee_brain_s3_vault_projection_root = var.cognee_brain_s3_vault_projection_root
|
|
3770
|
+
cognee_private_substrate_mode = var.cognee_private_substrate_mode
|
|
3771
|
+
cognee_llm_provider = var.cognee_llm_provider
|
|
3772
|
+
cognee_llm_model = var.cognee_llm_model
|
|
3773
|
+
cognee_embedding_provider = var.cognee_embedding_provider
|
|
3774
|
+
cognee_embedding_model = var.cognee_embedding_model
|
|
3775
|
+
cognee_embedding_dimensions = var.cognee_embedding_dimensions
|
|
3776
|
+
cognee_vector_db_provider = var.cognee_vector_db_provider
|
|
3777
|
+
cognee_graph_database_provider = var.cognee_graph_database_provider
|
|
3778
|
+
cognee_neptune_graph_id = var.cognee_neptune_graph_id
|
|
3779
|
+
cognee_neptune_endpoint = var.cognee_neptune_endpoint
|
|
3780
|
+
cognee_production_posture = var.cognee_production_posture
|
|
3781
|
+
cognee_bedrock_model_resource_arns = var.cognee_bedrock_model_resource_arns
|
|
3782
|
+
twenty_provisioned = var.twenty_provisioned
|
|
3783
|
+
twenty_runtime_enabled = var.twenty_runtime_enabled
|
|
3784
|
+
twenty_image_uri = var.twenty_image_uri
|
|
3785
|
+
twenty_db_username = var.twenty_db_username
|
|
3786
|
+
twenty_db_name = var.twenty_db_name
|
|
3787
|
+
twenty_db_url_secret_arn = var.twenty_db_url_secret_arn
|
|
3788
|
+
twenty_encryption_key_secret_arn = var.twenty_encryption_key_secret_arn
|
|
3789
|
+
twenty_email_from_address = var.twenty_email_from_address
|
|
3790
|
+
twenty_email_from_name = var.twenty_email_from_name
|
|
3791
|
+
twenty_public_url = var.twenty_public_url
|
|
3792
|
+
twenty_certificate_arn = var.twenty_certificate_arn
|
|
3793
|
+
n8n_provisioned = var.n8n_provisioned
|
|
3794
|
+
n8n_runtime_enabled = var.n8n_runtime_enabled
|
|
3795
|
+
n8n_image_uri = var.n8n_image_uri
|
|
3796
|
+
n8n_database_admin_secret_arn = var.n8n_database_admin_secret_arn
|
|
3797
|
+
n8n_database_url_secret_arn = var.n8n_database_url_secret_arn
|
|
3798
|
+
n8n_database_username = var.n8n_database_username
|
|
3799
|
+
n8n_database_name = var.n8n_database_name
|
|
3800
|
+
n8n_encryption_key_secret_arn = var.n8n_encryption_key_secret_arn
|
|
3801
|
+
n8n_operator_secret_arn = var.n8n_operator_secret_arn
|
|
3802
|
+
n8n_service_credential_secret_arn = var.n8n_service_credential_secret_arn
|
|
3803
|
+
n8n_storage_bucket_name = var.n8n_storage_bucket_name
|
|
3804
|
+
n8n_create_storage_bucket = var.n8n_create_storage_bucket
|
|
3805
|
+
n8n_storage_prefix = var.n8n_storage_prefix
|
|
3806
|
+
n8n_domain = var.n8n_domain
|
|
3807
|
+
n8n_public_url = var.n8n_public_url
|
|
3808
|
+
n8n_certificate_arn = local.n8n_effective_certificate_arn
|
|
3809
|
+
n8n_main_desired_count = var.n8n_main_desired_count
|
|
3810
|
+
n8n_worker_desired_count = var.n8n_worker_desired_count
|
|
3811
|
+
n8n_worker_concurrency = var.n8n_worker_concurrency
|
|
3812
|
+
n8n_container_port = var.n8n_container_port
|
|
3813
|
+
n8n_queue_mode = var.n8n_queue_mode
|
|
3814
|
+
n8n_task_runners_enabled = var.n8n_task_runners_enabled
|
|
3815
|
+
n8n_package_config_digest = var.n8n_package_config_digest
|
|
3816
|
+
n8n_custom_package_specs = var.n8n_custom_package_specs
|
|
3817
|
+
n8n_execution_data_storage_mode = var.n8n_execution_data_storage_mode
|
|
3818
|
+
n8n_binary_data_mode = var.n8n_binary_data_mode
|
|
3819
|
+
n8n_cache_engine = var.n8n_cache_engine
|
|
3820
|
+
n8n_cache_engine_version = var.n8n_cache_engine_version
|
|
3821
|
+
n8n_cache_parameter_group_family = var.n8n_cache_parameter_group_family
|
|
3822
|
+
n8n_cache_node_type = var.n8n_cache_node_type
|
|
3823
|
+
n8n_cache_num_cache_clusters = var.n8n_cache_num_cache_clusters
|
|
3824
|
+
n8n_allowed_public_cidr_blocks = var.n8n_allowed_public_cidr_blocks
|
|
3825
|
+
n8n_kms_key_arns = var.n8n_kms_key_arns
|
|
3826
|
+
enable_stripe_billing = false
|
|
3827
|
+
enable_slack_workspace_app = false
|
|
3828
|
+
|
|
3829
|
+
enable_deployment_control_plane = var.enable_deployment_control_plane
|
|
3830
|
+
deployment_control_plane_create_secret_placeholders = var.deployment_control_plane_create_secret_placeholders
|
|
3831
|
+
deployment_state_machine_arn = var.deployment_state_machine_arn
|
|
3832
|
+
deployment_evidence_bucket = var.deployment_evidence_bucket
|
|
3833
|
+
deployment_release_version = var.deployment_release_version
|
|
3834
|
+
deployment_release_manifest_url = var.deployment_release_manifest_url
|
|
3835
|
+
deployment_release_manifest_sha256 = var.deployment_release_manifest_sha256
|
|
3836
|
+
deployment_release_manifest_signature_url = var.deployment_release_manifest_signature_url
|
|
3837
|
+
deployment_release_manifest_trust_policy = var.deployment_release_manifest_trust_policy
|
|
3838
|
+
deployment_release_manifest_trusted_keys_json = var.deployment_release_manifest_trusted_keys_json
|
|
3839
|
+
deployment_terraform_module_source = var.deployment_terraform_module_source
|
|
3840
|
+
deployment_terraform_module_version = var.deployment_terraform_module_version
|
|
3841
|
+
}}
|
|
3842
|
+
|
|
3843
|
+
resource "cloudflare_record" "n8n" {{
|
|
3844
|
+
count = var.n8n_dns_enabled ? 1 : 0
|
|
3845
|
+
|
|
3846
|
+
zone_id = var.cloudflare_zone_id
|
|
3847
|
+
name = var.n8n_dns_name
|
|
3848
|
+
content = module.thinkwork.n8n_alb_dns_name
|
|
3849
|
+
type = "CNAME"
|
|
3850
|
+
ttl = 300
|
|
3851
|
+
proxied = false
|
|
3852
|
+
comment = "thinkwork-${{var.stage}} n8n -> n8n public ALB"
|
|
3853
|
+
}}
|
|
3854
|
+
|
|
3855
|
+
output "app_url" {{ value = module.thinkwork.app_url }}
|
|
3856
|
+
output "app_bucket_name" {{ value = module.thinkwork.app_bucket_name }}
|
|
3857
|
+
output "app_distribution_id" {{ value = module.thinkwork.app_distribution_id }}
|
|
3858
|
+
output "api_endpoint" {{ value = module.thinkwork.api_endpoint }}
|
|
3859
|
+
output "appsync_api_url" {{ value = module.thinkwork.appsync_api_url }}
|
|
3860
|
+
output "appsync_realtime_url" {{ value = module.thinkwork.appsync_realtime_url }}
|
|
3861
|
+
output "appsync_api_key" {{
|
|
3862
|
+
value = module.thinkwork.appsync_api_key
|
|
3863
|
+
sensitive = true
|
|
3864
|
+
}}
|
|
3865
|
+
output "auth_domain" {{ value = module.thinkwork.auth_domain }}
|
|
3866
|
+
output "customer_domain" {{ value = module.thinkwork.customer_domain }}
|
|
3867
|
+
output "customer_domain_name_servers" {{ value = module.thinkwork.customer_domain_name_servers }}
|
|
3868
|
+
output "db_cluster_endpoint" {{ value = module.thinkwork.db_cluster_endpoint }}
|
|
3869
|
+
output "db_secret_arn" {{ value = module.thinkwork.db_secret_arn }}
|
|
3870
|
+
output "database_name" {{ value = module.thinkwork.database_name }}
|
|
3871
|
+
output "user_pool_id" {{ value = module.thinkwork.user_pool_id }}
|
|
3872
|
+
output "admin_client_id" {{ value = module.thinkwork.admin_client_id }}
|
|
3873
|
+
output "docs_bucket_name" {{ value = module.thinkwork.docs_bucket_name }}
|
|
3874
|
+
output "docs_distribution_id" {{ value = module.thinkwork.docs_distribution_id }}
|
|
3875
|
+
output "docs_distribution_domain" {{ value = module.thinkwork.docs_distribution_domain }}
|
|
3876
|
+
output "cognee_enabled" {{ value = module.thinkwork.cognee_enabled }}
|
|
3877
|
+
output "twenty_provisioned" {{ value = module.thinkwork.twenty_provisioned }}
|
|
3878
|
+
output "twenty_runtime_enabled" {{ value = module.thinkwork.twenty_runtime_enabled }}
|
|
3879
|
+
output "twenty_url" {{ value = module.thinkwork.twenty_url }}
|
|
3880
|
+
output "n8n_provisioned" {{ value = module.thinkwork.n8n_provisioned }}
|
|
3881
|
+
output "n8n_runtime_enabled" {{ value = module.thinkwork.n8n_runtime_enabled }}
|
|
3882
|
+
output "n8n_url" {{ value = module.thinkwork.n8n_url }}
|
|
3883
|
+
output "n8n_alb_dns_name" {{ value = module.thinkwork.n8n_alb_dns_name }}
|
|
3884
|
+
output "n8n_alb_arn" {{ value = module.thinkwork.n8n_alb_arn }}
|
|
3885
|
+
output "n8n_target_group_arn" {{ value = module.thinkwork.n8n_target_group_arn }}
|
|
3886
|
+
output "n8n_cluster_arn" {{ value = module.thinkwork.n8n_cluster_arn }}
|
|
3887
|
+
output "n8n_main_service_name" {{ value = module.thinkwork.n8n_main_service_name }}
|
|
3888
|
+
output "n8n_worker_service_name" {{ value = module.thinkwork.n8n_worker_service_name }}
|
|
3889
|
+
output "n8n_main_log_group_name" {{ value = module.thinkwork.n8n_main_log_group_name }}
|
|
3890
|
+
output "n8n_worker_log_group_name" {{ value = module.thinkwork.n8n_worker_log_group_name }}
|
|
3891
|
+
output "n8n_database_name" {{ value = module.thinkwork.n8n_database_name }}
|
|
3892
|
+
output "n8n_database_secret_arn" {{ value = module.thinkwork.n8n_database_secret_arn }}
|
|
3893
|
+
output "n8n_valkey_endpoint" {{ value = module.thinkwork.n8n_valkey_endpoint }}
|
|
3894
|
+
output "n8n_storage_bucket_name" {{ value = module.thinkwork.n8n_storage_bucket_name }}
|
|
3895
|
+
output "n8n_storage_prefix" {{ value = module.thinkwork.n8n_storage_prefix }}
|
|
3896
|
+
output "n8n_image_digest" {{ value = module.thinkwork.n8n_image_digest }}
|
|
3897
|
+
output "n8n_package_config_digest" {{ value = module.thinkwork.n8n_package_config_digest }}
|
|
3898
|
+
output "n8n_service_credential_secret_arn" {{ value = module.thinkwork.n8n_service_credential_secret_arn }}
|
|
3899
|
+
output "deployment_control_plane_enabled" {{ value = module.thinkwork.deployment_control_plane_enabled }}
|
|
3900
|
+
output "deployment_state_machine_arn" {{ value = module.thinkwork.deployment_state_machine_arn }}
|
|
3901
|
+
output "deployment_state_machine_name" {{ value = module.thinkwork.deployment_state_machine_name }}
|
|
3902
|
+
output "deployment_runner_project_name" {{ value = module.thinkwork.deployment_runner_project_name }}
|
|
3903
|
+
output "deployment_runner_project_arn" {{ value = module.thinkwork.deployment_runner_project_arn }}
|
|
3904
|
+
output "deployment_evidence_bucket_name" {{ value = module.thinkwork.deployment_evidence_bucket_name }}
|
|
3905
|
+
output "deployment_ssm_prefix" {{ value = module.thinkwork.deployment_ssm_prefix }}
|
|
3906
|
+
output "deployment_appconfig_application_id" {{ value = module.thinkwork.deployment_appconfig_application_id }}
|
|
3907
|
+
output "deployment_appconfig_environment_id" {{ value = module.thinkwork.deployment_appconfig_environment_id }}
|
|
3908
|
+
output "deployment_appconfig_configuration_profile_id" {{ value = module.thinkwork.deployment_appconfig_configuration_profile_id }}
|
|
3909
|
+
""",
|
|
3910
|
+
encoding="utf-8",
|
|
3911
|
+
)
|
|
3912
|
+
return vars_json
|
|
3913
|
+
|
|
3914
|
+
|
|
3915
|
+
def sync_release_artifacts(artifact_types=None, artifact_names=None):
|
|
3916
|
+
global RELEASE_EVIDENCE
|
|
3917
|
+
artifact_types = set(artifact_types or {"lambda", "static-site"})
|
|
3918
|
+
artifact_names = set(artifact_names or [])
|
|
3919
|
+
manifest_url = os.environ.get("THINKWORK_RELEASE_MANIFEST_URL")
|
|
3920
|
+
expected = os.environ.get("THINKWORK_RELEASE_MANIFEST_SHA256", "").lower()
|
|
3921
|
+
if not manifest_url:
|
|
3922
|
+
raise RuntimeError("THINKWORK_RELEASE_MANIFEST_URL is required")
|
|
3923
|
+
download(manifest_url, MANIFEST)
|
|
3924
|
+
manifest_byte_digest = sha256_file(MANIFEST)
|
|
3925
|
+
manifest = verify_release_manifest_digest(MANIFEST, expected)
|
|
3926
|
+
canonical_digest = release_manifest_sha256(manifest)
|
|
3927
|
+
trust_evidence = enforce_release_manifest_trust(manifest, canonical_digest, manifest_url)
|
|
3928
|
+
bundled_paths, bundle_evidence = download_and_extract_artifact_bundles(manifest)
|
|
3929
|
+
lambda_prefix = f"releases/{os.environ['THINKWORK_RELEASE_VERSION']}/lambdas"
|
|
3930
|
+
static_files = {}
|
|
3931
|
+
artifact_evidence = []
|
|
3932
|
+
for artifact in manifest.get("artifacts", []):
|
|
3933
|
+
if artifact.get("type") not in artifact_types:
|
|
3934
|
+
continue
|
|
3935
|
+
if artifact_names and artifact.get("name") not in artifact_names:
|
|
3936
|
+
continue
|
|
3937
|
+
destination, digest, source = materialize_release_artifact(artifact, bundled_paths)
|
|
3938
|
+
artifact_evidence.append(
|
|
3939
|
+
{
|
|
3940
|
+
"name": artifact.get("name"),
|
|
3941
|
+
"type": artifact.get("type"),
|
|
3942
|
+
"fileName": artifact.get("fileName"),
|
|
3943
|
+
"sha256": digest,
|
|
3944
|
+
"source": source,
|
|
3945
|
+
}
|
|
3946
|
+
)
|
|
3947
|
+
if artifact.get("type") == "lambda":
|
|
3948
|
+
run(
|
|
3949
|
+
[
|
|
3950
|
+
"aws",
|
|
3951
|
+
"s3",
|
|
3952
|
+
"cp",
|
|
3953
|
+
str(destination),
|
|
3954
|
+
f"s3://{os.environ['THINKWORK_RELEASE_ARTIFACT_BUCKET']}/{lambda_prefix}/{artifact['fileName']}",
|
|
3955
|
+
]
|
|
3956
|
+
)
|
|
3957
|
+
else:
|
|
3958
|
+
static_files[artifact.get("name")] = destination
|
|
3959
|
+
RELEASE_EVIDENCE = {
|
|
3960
|
+
"manifestSha256": manifest_byte_digest,
|
|
3961
|
+
"manifestCanonicalSha256": canonical_digest,
|
|
3962
|
+
"trust": trust_evidence,
|
|
3963
|
+
"bundles": bundle_evidence,
|
|
3964
|
+
"artifacts": artifact_evidence,
|
|
3965
|
+
}
|
|
3966
|
+
return static_files
|
|
3967
|
+
|
|
3968
|
+
|
|
3969
|
+
def managed_app_operation(payload):
|
|
3970
|
+
return str(payload.get("operation") or "").strip().upper()
|
|
3971
|
+
|
|
3972
|
+
|
|
3973
|
+
def is_twenty_thinkwork_app_sync_operation(payload):
|
|
3974
|
+
app_key = str(payload.get("appKey") or "").strip().lower()
|
|
3975
|
+
return app_key == "twenty" and managed_app_operation(payload) in {"ENABLE", "UPGRADE"}
|
|
3976
|
+
|
|
3977
|
+
|
|
3978
|
+
def stage_managed_app_release_artifacts(action, payload):
|
|
3979
|
+
if action not in {"deploy", "update"}:
|
|
3980
|
+
return {}
|
|
3981
|
+
if not is_twenty_thinkwork_app_sync_operation(payload):
|
|
3982
|
+
return {}
|
|
3983
|
+
return sync_release_artifacts(
|
|
3984
|
+
artifact_types={"seed"},
|
|
3985
|
+
artifact_names={"twenty-thinkwork-app"},
|
|
3986
|
+
)
|
|
3987
|
+
|
|
3988
|
+
|
|
3989
|
+
def twenty_app_sync_api_key(runner_secrets, payload):
|
|
3990
|
+
return first_non_empty_string(
|
|
3991
|
+
runner_secrets.get("twentyAppSyncApiKey", ""),
|
|
3992
|
+
runner_secrets.get("twentyDeployApiKey", ""),
|
|
3993
|
+
payload.get("twentyAppSyncApiKey", ""),
|
|
3994
|
+
payload.get("twentyDeployApiKey", ""),
|
|
3995
|
+
os.environ.get("TWENTY_APP_SYNC_API_KEY", ""),
|
|
3996
|
+
os.environ.get("TWENTY_DEPLOY_API_KEY", ""),
|
|
3997
|
+
)
|
|
3998
|
+
|
|
3999
|
+
|
|
4000
|
+
def sync_twenty_thinkwork_app(outputs_path, vars_json, payload, runner_secrets, artifacts):
|
|
4001
|
+
global MANAGED_APP_EVIDENCE
|
|
4002
|
+
if not is_twenty_thinkwork_app_sync_operation(payload):
|
|
4003
|
+
return
|
|
4004
|
+
|
|
4005
|
+
outputs = {}
|
|
4006
|
+
if outputs_path.is_file():
|
|
4007
|
+
outputs = json.loads(outputs_path.read_text(encoding="utf-8"))
|
|
4008
|
+
|
|
4009
|
+
runtime_enabled = bool_state_output(outputs, "twenty_runtime_enabled") or bool(
|
|
4010
|
+
vars_json.get("twenty_runtime_enabled", False)
|
|
4011
|
+
)
|
|
4012
|
+
if not runtime_enabled:
|
|
4013
|
+
MANAGED_APP_EVIDENCE["twentyThinkWorkApp"] = {
|
|
4014
|
+
"status": "skipped",
|
|
4015
|
+
"reason": "twenty-runtime-disabled",
|
|
4016
|
+
}
|
|
4017
|
+
return
|
|
4018
|
+
|
|
4019
|
+
public_url = first_non_empty_string(
|
|
4020
|
+
string_state_output(outputs, "twenty_url"),
|
|
4021
|
+
vars_json.get("twenty_public_url", ""),
|
|
4022
|
+
payload.get("desiredConfig", {}).get("publicUrl", "")
|
|
4023
|
+
if isinstance(payload.get("desiredConfig"), dict)
|
|
4024
|
+
else "",
|
|
4025
|
+
)
|
|
4026
|
+
if not public_url:
|
|
4027
|
+
raise RuntimeError(
|
|
4028
|
+
"Twenty ThinkWork native app sync requires twenty_url from Terraform outputs "
|
|
4029
|
+
"or twenty_public_url from runner variables."
|
|
4030
|
+
)
|
|
4031
|
+
|
|
4032
|
+
api_key = twenty_app_sync_api_key(runner_secrets, payload)
|
|
4033
|
+
if not api_key:
|
|
4034
|
+
raise RuntimeError(
|
|
4035
|
+
"Twenty managed app ENABLE/UPGRADE requires twentyAppSyncApiKey or "
|
|
4036
|
+
"twentyDeployApiKey in the deployment runner secret."
|
|
4037
|
+
)
|
|
4038
|
+
|
|
4039
|
+
archive = artifacts.get("twenty-thinkwork-app")
|
|
4040
|
+
if not archive or not archive.is_file():
|
|
4041
|
+
raise RuntimeError(
|
|
4042
|
+
"Twenty managed app ENABLE/UPGRADE requires release artifact twenty-thinkwork-app."
|
|
4043
|
+
)
|
|
4044
|
+
|
|
4045
|
+
target = RELEASE / "extract-twenty-thinkwork-app"
|
|
4046
|
+
safe_extract_tar_file(archive, target)
|
|
4047
|
+
script = target / "plugins/twenty/scripts/sync-thinkwork-app.mjs"
|
|
4048
|
+
app_dir = target / "plugins/twenty/twenty-app"
|
|
4049
|
+
if not script.is_file() or not app_dir.is_dir():
|
|
4050
|
+
raise RuntimeError(
|
|
4051
|
+
"Twenty ThinkWork app artifact must contain "
|
|
4052
|
+
"plugins/twenty/scripts/sync-thinkwork-app.mjs and "
|
|
4053
|
+
"plugins/twenty/twenty-app."
|
|
4054
|
+
)
|
|
4055
|
+
|
|
4056
|
+
env = dict(os.environ)
|
|
4057
|
+
env.update(
|
|
4058
|
+
{
|
|
4059
|
+
"TWENTY_PUBLIC_URL": public_url,
|
|
4060
|
+
"TWENTY_APP_SYNC_API_KEY": api_key,
|
|
4061
|
+
"TWENTY_THINKWORK_APP_SYNC_DRY_RUN": "0",
|
|
4062
|
+
"TWENTY_THINKWORK_APP_DIR": str(app_dir),
|
|
4063
|
+
}
|
|
4064
|
+
)
|
|
4065
|
+
run(["node", str(script), "--apply"], cwd=target, env=env)
|
|
4066
|
+
MANAGED_APP_EVIDENCE["twentyThinkWorkApp"] = {
|
|
4067
|
+
"status": "installed",
|
|
4068
|
+
"artifact": "twenty-thinkwork-app",
|
|
4069
|
+
"publicUrl": public_url,
|
|
4070
|
+
"operation": managed_app_operation(payload),
|
|
4071
|
+
}
|
|
4072
|
+
|
|
4073
|
+
|
|
4074
|
+
def write_current_outputs_from_state(stage, outputs_path):
|
|
4075
|
+
outputs = current_terraform_outputs(stage)
|
|
4076
|
+
required = ["app_bucket_name", "app_distribution_id", "app_url"]
|
|
4077
|
+
missing = [name for name in required if name not in outputs]
|
|
4078
|
+
if missing:
|
|
4079
|
+
raise RuntimeError(
|
|
4080
|
+
"Web-only update requires existing Terraform outputs in state; missing "
|
|
4081
|
+
+ ", ".join(missing)
|
|
4082
|
+
)
|
|
4083
|
+
outputs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
4084
|
+
outputs_path.write_text(json.dumps(outputs, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
4085
|
+
return outputs
|
|
4086
|
+
|
|
4087
|
+
|
|
4088
|
+
def write_outputs_to_ssm(outputs_path, vars_json):
|
|
4089
|
+
outputs = json.loads(outputs_path.read_text(encoding="utf-8"))
|
|
4090
|
+
profile, web_env = runtime_profile(outputs, vars_json)
|
|
4091
|
+
mapping = {
|
|
4092
|
+
"profile/api-endpoint": "api_endpoint",
|
|
4093
|
+
"profile/app-url": "app_url",
|
|
4094
|
+
"profile/graphql-http-url": None,
|
|
4095
|
+
"profile/appsync-url": "appsync_api_url",
|
|
4096
|
+
"profile/appsync-realtime-url": "appsync_realtime_url",
|
|
4097
|
+
"profile/appsync-api-key": "appsync_api_key",
|
|
4098
|
+
"profile/cognito-domain": None,
|
|
4099
|
+
"profile/cognito-user-pool-id": "user_pool_id",
|
|
4100
|
+
"profile/cognito-client-id": "admin_client_id",
|
|
4101
|
+
}
|
|
4102
|
+
for suffix, output_name in mapping.items():
|
|
4103
|
+
if output_name:
|
|
4104
|
+
value = outputs.get(output_name, {}).get("value")
|
|
4105
|
+
elif suffix == "profile/graphql-http-url":
|
|
4106
|
+
value = profile.get("graphqlHttpUrl")
|
|
4107
|
+
elif suffix == "profile/cognito-domain":
|
|
4108
|
+
value = profile.get("cognitoDomain")
|
|
4109
|
+
else:
|
|
4110
|
+
value = None
|
|
4111
|
+
if value:
|
|
4112
|
+
run(
|
|
4113
|
+
[
|
|
4114
|
+
"aws",
|
|
4115
|
+
"ssm",
|
|
4116
|
+
"put-parameter",
|
|
4117
|
+
"--overwrite",
|
|
4118
|
+
"--type",
|
|
4119
|
+
"String",
|
|
4120
|
+
"--name",
|
|
4121
|
+
f"{os.environ['THINKWORK_SSM_PREFIX']}/{suffix}",
|
|
4122
|
+
"--value",
|
|
4123
|
+
str(value),
|
|
4124
|
+
]
|
|
4125
|
+
)
|
|
4126
|
+
|
|
4127
|
+
run(
|
|
4128
|
+
[
|
|
4129
|
+
"aws",
|
|
4130
|
+
"ssm",
|
|
4131
|
+
"put-parameter",
|
|
4132
|
+
"--overwrite",
|
|
4133
|
+
"--type",
|
|
4134
|
+
"String",
|
|
4135
|
+
"--name",
|
|
4136
|
+
f"{os.environ['THINKWORK_SSM_PREFIX']}/profile/json",
|
|
4137
|
+
"--value",
|
|
4138
|
+
json.dumps(profile, sort_keys=True),
|
|
4139
|
+
]
|
|
4140
|
+
)
|
|
4141
|
+
run(
|
|
4142
|
+
[
|
|
4143
|
+
"aws",
|
|
4144
|
+
"ssm",
|
|
4145
|
+
"put-parameter",
|
|
4146
|
+
"--overwrite",
|
|
4147
|
+
"--type",
|
|
4148
|
+
"String",
|
|
4149
|
+
"--name",
|
|
4150
|
+
f"{os.environ['THINKWORK_SSM_PREFIX']}/profile/web-env",
|
|
4151
|
+
"--value",
|
|
4152
|
+
web_env,
|
|
4153
|
+
]
|
|
4154
|
+
)
|
|
4155
|
+
|
|
4156
|
+
|
|
4157
|
+
def controller_terraform_module_version(vars_json):
|
|
4158
|
+
configured = vars_json.get("deployment_terraform_module_version")
|
|
4159
|
+
if configured:
|
|
4160
|
+
return configured
|
|
4161
|
+
source = vars_json.get("deployment_terraform_module_source") or ""
|
|
4162
|
+
if source.startswith("git::") or source.startswith("github.com/"):
|
|
4163
|
+
return ""
|
|
4164
|
+
return str(vars_json.get("deployment_release_version") or "").removeprefix("v")
|
|
4165
|
+
|
|
4166
|
+
|
|
4167
|
+
def put_controller_parameter(name, value):
|
|
4168
|
+
if not value:
|
|
4169
|
+
return
|
|
4170
|
+
run(
|
|
4171
|
+
[
|
|
4172
|
+
"aws",
|
|
4173
|
+
"ssm",
|
|
4174
|
+
"put-parameter",
|
|
4175
|
+
"--overwrite",
|
|
4176
|
+
"--type",
|
|
4177
|
+
"String",
|
|
4178
|
+
"--name",
|
|
4179
|
+
f"{os.environ['THINKWORK_SSM_PREFIX']}/{name}",
|
|
4180
|
+
"--value",
|
|
4181
|
+
str(value),
|
|
4182
|
+
]
|
|
4183
|
+
)
|
|
4184
|
+
|
|
4185
|
+
|
|
4186
|
+
def write_controller_release_selection_to_ssm(vars_json):
|
|
4187
|
+
if not os.environ.get("THINKWORK_SSM_PREFIX"):
|
|
4188
|
+
return {}
|
|
4189
|
+
selected = {
|
|
4190
|
+
"selected-release-version": vars_json.get("deployment_release_version"),
|
|
4191
|
+
"selected-release-manifest-url": vars_json.get("deployment_release_manifest_url"),
|
|
4192
|
+
"selected-release-manifest-sha256": vars_json.get("deployment_release_manifest_sha256"),
|
|
4193
|
+
"selected-release-signature-url": vars_json.get(
|
|
4194
|
+
"deployment_release_manifest_signature_url"
|
|
4195
|
+
),
|
|
4196
|
+
"selected-release-trust-policy": vars_json.get("deployment_release_manifest_trust_policy"),
|
|
4197
|
+
"selected-release-trusted-keys-json": vars_json.get(
|
|
4198
|
+
"deployment_release_manifest_trusted_keys_json"
|
|
4199
|
+
),
|
|
4200
|
+
"terraform-module-source": vars_json.get("deployment_terraform_module_source"),
|
|
4201
|
+
"terraform-module-version": controller_terraform_module_version(vars_json),
|
|
4202
|
+
}
|
|
4203
|
+
for name, value in selected.items():
|
|
4204
|
+
put_controller_parameter(name, value)
|
|
4205
|
+
return {name: value for name, value in selected.items() if value}
|
|
4206
|
+
|
|
4207
|
+
|
|
4208
|
+
def runtime_profile(outputs, vars_json):
|
|
4209
|
+
def output_value(name):
|
|
4210
|
+
return outputs.get(name, {}).get("value")
|
|
4211
|
+
|
|
4212
|
+
api_endpoint = output_value("api_endpoint") or ""
|
|
4213
|
+
app_url = output_value("app_url") or ""
|
|
4214
|
+
region = vars_json["region"]
|
|
4215
|
+
auth_domain = output_value("auth_domain") or ""
|
|
4216
|
+
cognito_domain = (
|
|
4217
|
+
auth_domain
|
|
4218
|
+
if auth_domain.startswith("https://")
|
|
4219
|
+
else f"https://{auth_domain}.auth.{region}.amazoncognito.com"
|
|
4220
|
+
if auth_domain
|
|
4221
|
+
else ""
|
|
4222
|
+
)
|
|
4223
|
+
graphql_http_url = f"{api_endpoint.rstrip('/')}/graphql" if api_endpoint else ""
|
|
4224
|
+
profile = {
|
|
4225
|
+
"stage": vars_json["stage"],
|
|
4226
|
+
"region": region,
|
|
4227
|
+
"accountId": vars_json["account_id"],
|
|
4228
|
+
"releaseVersion": os.environ.get("THINKWORK_RELEASE_VERSION"),
|
|
4229
|
+
"releaseManifestUrl": os.environ.get("THINKWORK_RELEASE_MANIFEST_URL"),
|
|
4230
|
+
"releaseManifestSha256": os.environ.get("THINKWORK_RELEASE_MANIFEST_SHA256"),
|
|
4231
|
+
"deploymentId": f"thinkwork-{vars_json['stage']}",
|
|
4232
|
+
"displayName": "ThinkWork",
|
|
4233
|
+
"appUrl": app_url,
|
|
4234
|
+
"apiEndpoint": api_endpoint,
|
|
4235
|
+
"graphqlHttpUrl": graphql_http_url,
|
|
4236
|
+
"appsyncUrl": output_value("appsync_api_url"),
|
|
4237
|
+
"appsyncRealtimeUrl": output_value("appsync_realtime_url"),
|
|
4238
|
+
"appsyncApiKey": output_value("appsync_api_key"),
|
|
4239
|
+
"cognitoDomain": cognito_domain,
|
|
4240
|
+
"cognitoUserPoolId": output_value("user_pool_id"),
|
|
4241
|
+
"cognitoClientId": output_value("admin_client_id"),
|
|
4242
|
+
"controller": {
|
|
4243
|
+
"stateMachineArn": output_value("deployment_state_machine_arn")
|
|
4244
|
+
or os.environ.get("THINKWORK_DEPLOYMENT_STATE_MACHINE_ARN"),
|
|
4245
|
+
"stateMachineName": output_value("deployment_state_machine_name")
|
|
4246
|
+
or os.environ.get("THINKWORK_DEPLOYMENT_STATE_MACHINE_NAME"),
|
|
4247
|
+
"codebuildProjectName": output_value("deployment_runner_project_name")
|
|
4248
|
+
or os.environ.get("THINKWORK_DEPLOYMENT_RUNNER_PROJECT_NAME"),
|
|
4249
|
+
"codebuildProjectArn": output_value("deployment_runner_project_arn")
|
|
4250
|
+
or os.environ.get("THINKWORK_DEPLOYMENT_RUNNER_PROJECT_ARN"),
|
|
4251
|
+
"evidenceBucketName": output_value("deployment_evidence_bucket_name")
|
|
4252
|
+
or os.environ.get("THINKWORK_EVIDENCE_BUCKET"),
|
|
4253
|
+
"ssmPrefix": output_value("deployment_ssm_prefix")
|
|
4254
|
+
or os.environ.get("THINKWORK_SSM_PREFIX"),
|
|
4255
|
+
"appconfigApplicationId": output_value("deployment_appconfig_application_id"),
|
|
4256
|
+
"appconfigEnvironmentId": output_value("deployment_appconfig_environment_id"),
|
|
4257
|
+
"appconfigConfigurationProfileId": output_value(
|
|
4258
|
+
"deployment_appconfig_configuration_profile_id"
|
|
4259
|
+
),
|
|
4260
|
+
"verifiedAt": datetime.now(UTC).isoformat(),
|
|
4261
|
+
},
|
|
4262
|
+
"issuedAt": datetime.now(UTC).isoformat(),
|
|
4263
|
+
}
|
|
4264
|
+
vite_env = {
|
|
4265
|
+
"VITE_API_URL": profile["apiEndpoint"],
|
|
4266
|
+
"VITE_GRAPHQL_HTTP_URL": profile["graphqlHttpUrl"],
|
|
4267
|
+
"VITE_GRAPHQL_URL": profile["appsyncUrl"],
|
|
4268
|
+
"VITE_GRAPHQL_WS_URL": profile["appsyncRealtimeUrl"],
|
|
4269
|
+
"VITE_GRAPHQL_API_KEY": profile["appsyncApiKey"],
|
|
4270
|
+
"VITE_COGNITO_DOMAIN": profile["cognitoDomain"],
|
|
4271
|
+
"VITE_COGNITO_USER_POOL_ID": profile["cognitoUserPoolId"],
|
|
4272
|
+
"VITE_COGNITO_CLIENT_ID": profile["cognitoClientId"],
|
|
4273
|
+
"VITE_DEPLOYMENT_ID": profile["deploymentId"],
|
|
4274
|
+
"VITE_DEPLOYMENT_DISPLAY_NAME": profile["displayName"],
|
|
4275
|
+
"VITE_DEPLOYMENT_PROFILE_ISSUED_AT": profile["issuedAt"],
|
|
4276
|
+
"VITE_SPACES_URL": profile["appUrl"],
|
|
4277
|
+
"VITE_STAGE": profile["stage"],
|
|
4278
|
+
"VITE_AWS_REGION": profile["region"],
|
|
4279
|
+
"VITE_AWS_ACCOUNT_ID": profile["accountId"],
|
|
4280
|
+
"VITE_RELEASE_VERSION": profile["releaseVersion"],
|
|
4281
|
+
"VITE_RELEASE_MANIFEST_URL": profile["releaseManifestUrl"],
|
|
4282
|
+
"VITE_RELEASE_MANIFEST_SHA256": profile["releaseManifestSha256"],
|
|
4283
|
+
"VITE_DEPLOYMENT_CONTROLLER_ARN": profile["controller"]["stateMachineArn"],
|
|
4284
|
+
"VITE_DEPLOYMENT_CONTROLLER_NAME": profile["controller"]["stateMachineName"],
|
|
4285
|
+
"VITE_DEPLOYMENT_RUNNER_PROJECT_NAME": profile["controller"]["codebuildProjectName"],
|
|
4286
|
+
"VITE_DEPLOYMENT_RUNNER_PROJECT_ARN": profile["controller"]["codebuildProjectArn"],
|
|
4287
|
+
"VITE_DEPLOYMENT_EVIDENCE_BUCKET": profile["controller"]["evidenceBucketName"],
|
|
4288
|
+
"VITE_DEPLOYMENT_SSM_PREFIX": profile["controller"]["ssmPrefix"],
|
|
4289
|
+
}
|
|
4290
|
+
profile["viteEnv"] = vite_env
|
|
4291
|
+
web_env = "\n".join(f"{key}={value or ''}" for key, value in sorted(vite_env.items()))
|
|
4292
|
+
return profile, web_env + "\n"
|
|
4293
|
+
|
|
4294
|
+
|
|
4295
|
+
def sync_static(outputs_path, static_files, vars_json, artifact_names=None):
|
|
4296
|
+
outputs = json.loads(outputs_path.read_text(encoding="utf-8"))
|
|
4297
|
+
artifact_names = set(artifact_names or [])
|
|
4298
|
+
syncs = [
|
|
4299
|
+
("web", "app_bucket_name", "app_distribution_id"),
|
|
4300
|
+
("docs", "docs_bucket_name", "docs_distribution_id"),
|
|
4301
|
+
]
|
|
4302
|
+
for artifact_name, bucket_output, distribution_output in syncs:
|
|
4303
|
+
if artifact_names and artifact_name not in artifact_names:
|
|
4304
|
+
continue
|
|
4305
|
+
archive = static_files.get(artifact_name)
|
|
4306
|
+
bucket = outputs.get(bucket_output, {}).get("value")
|
|
4307
|
+
if not archive or not bucket:
|
|
4308
|
+
continue
|
|
4309
|
+
target = RELEASE / f"extract-{artifact_name}"
|
|
4310
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
4311
|
+
safe_extract_tar_file(archive, target)
|
|
4312
|
+
run(["aws", "s3", "sync", "--delete", str(target), f"s3://{bucket}/"])
|
|
4313
|
+
if artifact_name == "web":
|
|
4314
|
+
index_path = target / "index.html"
|
|
4315
|
+
if index_path.exists():
|
|
4316
|
+
run(
|
|
4317
|
+
[
|
|
4318
|
+
"aws",
|
|
4319
|
+
"s3",
|
|
4320
|
+
"cp",
|
|
4321
|
+
str(index_path),
|
|
4322
|
+
f"s3://{bucket}/index.html",
|
|
4323
|
+
"--content-type",
|
|
4324
|
+
"text/html",
|
|
4325
|
+
"--cache-control",
|
|
4326
|
+
"no-store",
|
|
4327
|
+
]
|
|
4328
|
+
)
|
|
4329
|
+
profile, _ = runtime_profile(outputs, vars_json)
|
|
4330
|
+
runtime_config_path = RELEASE / "thinkwork-runtime-config.json"
|
|
4331
|
+
runtime_config_path.write_text(
|
|
4332
|
+
json.dumps(profile, indent=2, sort_keys=True) + "\n",
|
|
4333
|
+
encoding="utf-8",
|
|
4334
|
+
)
|
|
4335
|
+
run(
|
|
4336
|
+
[
|
|
4337
|
+
"aws",
|
|
4338
|
+
"s3",
|
|
4339
|
+
"cp",
|
|
4340
|
+
str(runtime_config_path),
|
|
4341
|
+
f"s3://{bucket}/thinkwork-runtime-config.json",
|
|
4342
|
+
"--content-type",
|
|
4343
|
+
"application/json",
|
|
4344
|
+
"--cache-control",
|
|
4345
|
+
"no-store",
|
|
4346
|
+
]
|
|
4347
|
+
)
|
|
4348
|
+
distribution_id = outputs.get(distribution_output, {}).get("value")
|
|
4349
|
+
if distribution_id:
|
|
4350
|
+
run(
|
|
4351
|
+
[
|
|
4352
|
+
"aws",
|
|
4353
|
+
"cloudfront",
|
|
4354
|
+
"create-invalidation",
|
|
4355
|
+
"--distribution-id",
|
|
4356
|
+
str(distribution_id),
|
|
4357
|
+
"--paths",
|
|
4358
|
+
"/*",
|
|
4359
|
+
]
|
|
4360
|
+
)
|
|
4361
|
+
|
|
4362
|
+
|
|
4363
|
+
def read_current_status_pointer(bucket):
|
|
4364
|
+
try:
|
|
4365
|
+
return json.loads(
|
|
4366
|
+
output(["aws", "s3", "cp", f"s3://{bucket}/deployment/status/current.json", "-"])
|
|
4367
|
+
)
|
|
4368
|
+
except Exception:
|
|
4369
|
+
return {}
|
|
4370
|
+
|
|
4371
|
+
|
|
4372
|
+
def build_deployment_status_pointer(
|
|
4373
|
+
status,
|
|
4374
|
+
*,
|
|
4375
|
+
action,
|
|
4376
|
+
release,
|
|
4377
|
+
previous,
|
|
4378
|
+
controller,
|
|
4379
|
+
environment_url,
|
|
4380
|
+
stage,
|
|
4381
|
+
region,
|
|
4382
|
+
account_id,
|
|
4383
|
+
started_at,
|
|
4384
|
+
recorded_at,
|
|
4385
|
+
terraform_exit_code=None,
|
|
4386
|
+
error=None,
|
|
4387
|
+
evidence_bucket=None,
|
|
4388
|
+
evidence_key=None,
|
|
4389
|
+
):
|
|
4390
|
+
pointer = {
|
|
4391
|
+
"schemaVersion": 1,
|
|
4392
|
+
"contract": "thinkwork.deployment.status.v1",
|
|
4393
|
+
"stage": stage,
|
|
4394
|
+
"region": region,
|
|
4395
|
+
"accountId": account_id,
|
|
4396
|
+
"environmentUrl": environment_url or previous.get("environmentUrl"),
|
|
4397
|
+
"status": status,
|
|
4398
|
+
"action": action,
|
|
4399
|
+
"source": "deployment-controller",
|
|
4400
|
+
"recordedAt": recorded_at,
|
|
4401
|
+
"controller": {key: value for key, value in controller.items() if value},
|
|
4402
|
+
}
|
|
4403
|
+
if status == "succeeded":
|
|
4404
|
+
pointer["activeRelease"] = release
|
|
4405
|
+
pointer["lastSuccessfulDeployment"] = {
|
|
4406
|
+
"sessionId": controller.get("sessionId"),
|
|
4407
|
+
"startedAt": started_at,
|
|
4408
|
+
"finishedAt": recorded_at,
|
|
4409
|
+
"terraformExitCode": terraform_exit_code,
|
|
4410
|
+
"evidenceBucket": evidence_bucket,
|
|
4411
|
+
"evidenceKey": evidence_key,
|
|
4412
|
+
}
|
|
4413
|
+
else:
|
|
4414
|
+
pointer["targetRelease"] = release
|
|
4415
|
+
for carried in ("activeRelease", "lastSuccessfulDeployment", "historyKey"):
|
|
4416
|
+
if previous.get(carried):
|
|
4417
|
+
pointer[carried] = previous[carried]
|
|
4418
|
+
if error:
|
|
4419
|
+
pointer["error"] = str(error)
|
|
4420
|
+
return pointer
|
|
4421
|
+
|
|
4422
|
+
|
|
4423
|
+
def write_deployment_status_pointer(status, vars_json=None, terraform_exit_code=None, error=None):
|
|
4424
|
+
"""Publish environment-owned deployed-release state. Best-effort: a status
|
|
4425
|
+
write must never change the deploy result."""
|
|
4426
|
+
bucket = os.environ.get("THINKWORK_EVIDENCE_BUCKET")
|
|
4427
|
+
action = os.environ.get("THINKWORK_DEPLOYMENT_ACTION")
|
|
4428
|
+
if os.environ.get("THINKWORK_MANAGED_APP_OPERATION") == "true":
|
|
4429
|
+
return
|
|
4430
|
+
if not bucket or action not in {"deploy", "update", "web"}:
|
|
4431
|
+
return
|
|
4432
|
+
vars_json = vars_json or {}
|
|
4433
|
+
previous = read_current_status_pointer(bucket)
|
|
4434
|
+
environment_url = None
|
|
4435
|
+
outputs_path = TF / "outputs.json"
|
|
4436
|
+
if outputs_path.is_file():
|
|
4437
|
+
try:
|
|
4438
|
+
outputs = json.loads(outputs_path.read_text(encoding="utf-8"))
|
|
4439
|
+
environment_url = outputs.get("app_url", {}).get("value")
|
|
4440
|
+
except Exception:
|
|
4441
|
+
environment_url = None
|
|
4442
|
+
recorded_at = datetime.now(UTC).isoformat()
|
|
4443
|
+
prefix = os.environ.get("THINKWORK_EVIDENCE_PREFIX")
|
|
4444
|
+
pointer = build_deployment_status_pointer(
|
|
4445
|
+
status,
|
|
4446
|
+
action=action,
|
|
4447
|
+
release={
|
|
4448
|
+
"version": os.environ.get("THINKWORK_RELEASE_VERSION"),
|
|
4449
|
+
"manifestUrl": os.environ.get("THINKWORK_RELEASE_MANIFEST_URL"),
|
|
4450
|
+
"manifestSha256": os.environ.get("THINKWORK_RELEASE_MANIFEST_SHA256"),
|
|
4451
|
+
},
|
|
4452
|
+
previous=previous,
|
|
4453
|
+
controller={
|
|
4454
|
+
"stateMachineArn": os.environ.get("THINKWORK_DEPLOYMENT_STATE_MACHINE_ARN"),
|
|
4455
|
+
"codebuildProjectName": os.environ.get("THINKWORK_DEPLOYMENT_RUNNER_PROJECT_NAME"),
|
|
4456
|
+
"codebuildBuildId": os.environ.get("CODEBUILD_BUILD_ID"),
|
|
4457
|
+
"sessionId": os.environ.get("THINKWORK_DEPLOYMENT_SESSION_ID"),
|
|
4458
|
+
},
|
|
4459
|
+
environment_url=environment_url,
|
|
4460
|
+
stage=os.environ.get("THINKWORK_STAGE"),
|
|
4461
|
+
region=vars_json.get("region") or os.environ.get("AWS_REGION"),
|
|
4462
|
+
account_id=vars_json.get("account_id") or previous.get("accountId"),
|
|
4463
|
+
started_at=STARTED_AT,
|
|
4464
|
+
recorded_at=recorded_at,
|
|
4465
|
+
terraform_exit_code=terraform_exit_code,
|
|
4466
|
+
error=error,
|
|
4467
|
+
evidence_bucket=bucket,
|
|
4468
|
+
evidence_key=f"{prefix}/deployment-evidence.json" if prefix else None,
|
|
4469
|
+
)
|
|
4470
|
+
if status in {"succeeded", "failed"}:
|
|
4471
|
+
timestamp = recorded_at.replace("-", "").replace(":", "").split(".")[0] + "Z"
|
|
4472
|
+
version = (pointer.get("activeRelease") or pointer.get("targetRelease") or {}).get(
|
|
4473
|
+
"version"
|
|
4474
|
+
) or "unknown"
|
|
4475
|
+
pointer["historyKey"] = f"deployment/status/history/{timestamp}-{version}.json"
|
|
4476
|
+
body = Path("deployment-status-pointer.json")
|
|
4477
|
+
body.write_text(json.dumps(pointer, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
4478
|
+
if status in {"succeeded", "failed"}:
|
|
4479
|
+
run(["aws", "s3", "cp", str(body), f"s3://{bucket}/{pointer['historyKey']}"])
|
|
4480
|
+
run(["aws", "s3", "cp", str(body), f"s3://{bucket}/deployment/status/current.json"])
|
|
4481
|
+
|
|
4482
|
+
|
|
4483
|
+
def self_update_runner_script():
|
|
4484
|
+
"""Refresh the controller's runner script from the release source just
|
|
4485
|
+
deployed. The customer-update terraform root provisions with
|
|
4486
|
+
enable_deployment_control_plane = false (the controller cannot manage
|
|
4487
|
+
itself mid-run), so nothing else ever updates the script the next build
|
|
4488
|
+
downloads — without this step it stays frozen at provision time."""
|
|
4489
|
+
script_uri = os.environ.get("THINKWORK_RUNNER_SCRIPT_S3_URI")
|
|
4490
|
+
if not script_uri:
|
|
4491
|
+
return
|
|
4492
|
+
source_script = SOURCE / "terraform/modules/app/deployment-control-plane/runner.py"
|
|
4493
|
+
if not source_script.is_file():
|
|
4494
|
+
print("[runner] release source has no runner.py; skipping self-update")
|
|
4495
|
+
return
|
|
4496
|
+
run(["aws", "s3", "cp", str(source_script), script_uri])
|
|
4497
|
+
print(f"[runner] self-updated runner script at {script_uri}")
|
|
4498
|
+
|
|
4499
|
+
|
|
4500
|
+
def write_evidence(status, vars_json=None, terraform_exit_code=None, error=None):
|
|
4501
|
+
vars_json = vars_json or {}
|
|
4502
|
+
evidence = {
|
|
4503
|
+
"status": status,
|
|
4504
|
+
"stage": os.environ.get("THINKWORK_STAGE"),
|
|
4505
|
+
"release": os.environ.get("THINKWORK_RELEASE_VERSION"),
|
|
4506
|
+
"action": os.environ.get("THINKWORK_DEPLOYMENT_ACTION"),
|
|
4507
|
+
"sessionId": os.environ.get("THINKWORK_DEPLOYMENT_SESSION_ID"),
|
|
4508
|
+
"environmentName": vars_json.get("stage"),
|
|
4509
|
+
"awsAccountId": vars_json.get("account_id"),
|
|
4510
|
+
"awsRegion": vars_json.get("region"),
|
|
4511
|
+
"managedApps": {"cognee": False, "twenty": False},
|
|
4512
|
+
"codebuildBuildId": os.environ.get("CODEBUILD_BUILD_ID"),
|
|
4513
|
+
"terraformExitCode": terraform_exit_code,
|
|
4514
|
+
"startedAt": STARTED_AT,
|
|
4515
|
+
"recordedAt": datetime.now(UTC).isoformat(),
|
|
4516
|
+
}
|
|
4517
|
+
if "customer_domain" in vars_json:
|
|
4518
|
+
# Echoed-fields guard (KTD5): record the domain fields this runner
|
|
4519
|
+
# version actually consumed so the controller can detect an outdated
|
|
4520
|
+
# runner that silently dropped them. Booleans must stay booleans.
|
|
4521
|
+
evidence["consumedDomainFields"] = {
|
|
4522
|
+
"customerDomain": vars_json.get("customer_domain", ""),
|
|
4523
|
+
"customerDomainDelegated": bool(vars_json.get("customer_domain_delegated", False)),
|
|
4524
|
+
"customerDomainLegacyRetired": bool(
|
|
4525
|
+
vars_json.get("customer_domain_legacy_retired", False)
|
|
4526
|
+
),
|
|
4527
|
+
}
|
|
4528
|
+
if error:
|
|
4529
|
+
evidence["error"] = str(error)
|
|
4530
|
+
if FIRST_ADMIN_EVIDENCE:
|
|
4531
|
+
evidence["firstAdminBootstrap"] = FIRST_ADMIN_EVIDENCE
|
|
4532
|
+
if RELEASE_EVIDENCE:
|
|
4533
|
+
evidence["releaseArtifacts"] = RELEASE_EVIDENCE
|
|
4534
|
+
if CONTROLLER_EVIDENCE:
|
|
4535
|
+
evidence["controller"] = CONTROLLER_EVIDENCE
|
|
4536
|
+
if TERRAFORM_EVIDENCE:
|
|
4537
|
+
evidence["terraform"] = TERRAFORM_EVIDENCE
|
|
4538
|
+
if MANAGED_APP_EVIDENCE:
|
|
4539
|
+
evidence["managedAppPostInstall"] = MANAGED_APP_EVIDENCE
|
|
4540
|
+
Path("deployment-evidence.json").write_text(
|
|
4541
|
+
json.dumps(evidence, indent=2, sort_keys=True) + "\n",
|
|
4542
|
+
encoding="utf-8",
|
|
4543
|
+
)
|
|
4544
|
+
prefix = os.environ.get("THINKWORK_EVIDENCE_PREFIX")
|
|
4545
|
+
bucket = os.environ.get("THINKWORK_EVIDENCE_BUCKET")
|
|
4546
|
+
if prefix and bucket:
|
|
4547
|
+
run(
|
|
4548
|
+
[
|
|
4549
|
+
"aws",
|
|
4550
|
+
"s3",
|
|
4551
|
+
"cp",
|
|
4552
|
+
"deployment-evidence.json",
|
|
4553
|
+
f"s3://{bucket}/{prefix}/deployment-evidence.json",
|
|
4554
|
+
]
|
|
4555
|
+
)
|
|
4556
|
+
try:
|
|
4557
|
+
write_deployment_status_pointer(status, vars_json, terraform_exit_code, error)
|
|
4558
|
+
except Exception as status_error:
|
|
4559
|
+
print(f"[status] failed to write deployment status pointer: {status_error}")
|
|
4560
|
+
|
|
4561
|
+
|
|
4562
|
+
def main():
|
|
4563
|
+
global CONTROLLER_EVIDENCE, TERRAFORM_EVIDENCE
|
|
4564
|
+
WORK.mkdir(parents=True, exist_ok=True)
|
|
4565
|
+
payload = read_json_env("THINKWORK_DEPLOYMENT_INPUT", {})
|
|
4566
|
+
apply_release_selection(payload)
|
|
4567
|
+
action = os.environ.get("THINKWORK_DEPLOYMENT_ACTION") or payload.get("action") or "deploy"
|
|
4568
|
+
if action == "teardown":
|
|
4569
|
+
action = "destroy"
|
|
4570
|
+
if action not in {"deploy", "update", "destroy", "plan", "status", "web"}:
|
|
4571
|
+
raise RuntimeError(f"Unsupported deployment action: {action}")
|
|
4572
|
+
os.environ["THINKWORK_DEPLOYMENT_ACTION"] = action
|
|
4573
|
+
configure_managed_app_evidence_prefix(payload)
|
|
4574
|
+
|
|
4575
|
+
if action == "status":
|
|
4576
|
+
CONTROLLER_EVIDENCE = {
|
|
4577
|
+
"status": write_controller_status_evidence(payload),
|
|
4578
|
+
}
|
|
4579
|
+
write_evidence(
|
|
4580
|
+
"succeeded",
|
|
4581
|
+
{
|
|
4582
|
+
"stage": safe_get(payload, "stage", "environmentName", default=""),
|
|
4583
|
+
"account_id": safe_get(payload, "awsAccountId", "accountId", default=""),
|
|
4584
|
+
"region": safe_get(payload, "awsRegion", "region", default=""),
|
|
4585
|
+
},
|
|
4586
|
+
0,
|
|
4587
|
+
)
|
|
4588
|
+
return 0
|
|
4589
|
+
|
|
4590
|
+
runner_secrets = secret_payload(payload)
|
|
4591
|
+
web_only = is_web_only_operation(payload, action)
|
|
4592
|
+
static_files = {}
|
|
4593
|
+
if action in {"deploy", "update", "web"} and not is_managed_app_operation(payload):
|
|
4594
|
+
static_files = sync_release_artifacts(
|
|
4595
|
+
artifact_types={"static-site"} if web_only else None,
|
|
4596
|
+
artifact_names={"web"} if web_only else None,
|
|
4597
|
+
)
|
|
4598
|
+
managed_app_artifacts = stage_managed_app_release_artifacts(action, payload)
|
|
4599
|
+
vars_json = write_runner_files(payload, runner_secrets)
|
|
4600
|
+
controller_summary = controller_input_summary(payload)
|
|
4601
|
+
CONTROLLER_EVIDENCE = {
|
|
4602
|
+
"inputSummary": controller_summary,
|
|
4603
|
+
"artifact": write_json_evidence_artifact(
|
|
4604
|
+
"controller-input-summary.json",
|
|
4605
|
+
controller_summary,
|
|
4606
|
+
),
|
|
4607
|
+
}
|
|
4608
|
+
TERRAFORM_EVIDENCE = {
|
|
4609
|
+
"redactedVariables": write_json_evidence_artifact(
|
|
4610
|
+
"redacted-terraform-vars.json",
|
|
4611
|
+
redacted_tfvars(vars_json),
|
|
4612
|
+
)
|
|
4613
|
+
}
|
|
4614
|
+
write_evidence("running", vars_json)
|
|
4615
|
+
|
|
4616
|
+
if web_only:
|
|
4617
|
+
outputs_path = TF / "outputs.json"
|
|
4618
|
+
write_current_outputs_from_state(vars_json["stage"], outputs_path)
|
|
4619
|
+
TERRAFORM_EVIDENCE["outputs"] = {
|
|
4620
|
+
"fileName": "terraform-outputs.json",
|
|
4621
|
+
"sha256": sha256_file(outputs_path),
|
|
4622
|
+
"s3Uri": upload_evidence_artifact(outputs_path, "terraform-outputs.json"),
|
|
4623
|
+
"source": "state",
|
|
4624
|
+
}
|
|
4625
|
+
sync_static(outputs_path, static_files, vars_json, artifact_names={"web"})
|
|
4626
|
+
selected_controller_release = write_controller_release_selection_to_ssm(vars_json)
|
|
4627
|
+
if selected_controller_release:
|
|
4628
|
+
CONTROLLER_EVIDENCE["releaseSelection"] = write_json_evidence_artifact(
|
|
4629
|
+
"controller-release-selection.json",
|
|
4630
|
+
selected_controller_release,
|
|
4631
|
+
)
|
|
4632
|
+
write_evidence("succeeded", vars_json, 0)
|
|
4633
|
+
return 0
|
|
4634
|
+
|
|
4635
|
+
configure_cloudflare_provider_auth(vars_json["stage"])
|
|
4636
|
+
configure_terraform_provider_mirror()
|
|
4637
|
+
run(["terraform", "init", "-backend-config=backend.hcl", "-no-color"], cwd=TF)
|
|
4638
|
+
patch_downloaded_customer_domain_module()
|
|
4639
|
+
workspace = terraform_workspace_name(vars_json["stage"], payload)
|
|
4640
|
+
if workspace != "default":
|
|
4641
|
+
selected = subprocess.run(
|
|
4642
|
+
["terraform", "workspace", "select", workspace, "-no-color"],
|
|
4643
|
+
cwd=TF,
|
|
4644
|
+
text=True,
|
|
4645
|
+
)
|
|
4646
|
+
if selected.returncode != 0:
|
|
4647
|
+
run(["terraform", "workspace", "new", workspace, "-no-color"], cwd=TF)
|
|
4648
|
+
target_args = managed_app_terraform_target_args(payload)
|
|
4649
|
+
if action == "destroy":
|
|
4650
|
+
plan = subprocess.run(
|
|
4651
|
+
["terraform", "plan", "-destroy", *target_args, "-out=tfplan", "-no-color"],
|
|
4652
|
+
cwd=TF,
|
|
4653
|
+
text=True,
|
|
4654
|
+
)
|
|
4655
|
+
if plan.returncode == 0:
|
|
4656
|
+
TERRAFORM_EVIDENCE["plan"] = write_terraform_plan_evidence(payload)
|
|
4657
|
+
result = subprocess.run(
|
|
4658
|
+
["terraform", "apply", "-auto-approve", "-no-color", "tfplan"],
|
|
4659
|
+
cwd=TF,
|
|
4660
|
+
text=True,
|
|
4661
|
+
)
|
|
4662
|
+
else:
|
|
4663
|
+
result = plan
|
|
4664
|
+
else:
|
|
4665
|
+
plan = subprocess.run(
|
|
4666
|
+
["terraform", "plan", *target_args, "-out=tfplan", "-no-color"],
|
|
4667
|
+
cwd=TF,
|
|
4668
|
+
text=True,
|
|
4669
|
+
)
|
|
4670
|
+
if plan.returncode == 0:
|
|
4671
|
+
TERRAFORM_EVIDENCE["plan"] = write_terraform_plan_evidence(payload)
|
|
4672
|
+
if action == "plan" or plan.returncode != 0:
|
|
4673
|
+
result = plan
|
|
4674
|
+
else:
|
|
4675
|
+
result = subprocess.run(
|
|
4676
|
+
["terraform", "apply", "-auto-approve", "-no-color", "tfplan"],
|
|
4677
|
+
cwd=TF,
|
|
4678
|
+
text=True,
|
|
4679
|
+
)
|
|
4680
|
+
|
|
4681
|
+
outputs_path = TF / "outputs.json"
|
|
4682
|
+
if result.returncode == 0 and action in {"deploy", "update"}:
|
|
4683
|
+
write_outputs_after_apply(payload, vars_json, outputs_path)
|
|
4684
|
+
if is_managed_app_operation(payload):
|
|
4685
|
+
sync_twenty_thinkwork_app(
|
|
4686
|
+
outputs_path,
|
|
4687
|
+
vars_json,
|
|
4688
|
+
payload,
|
|
4689
|
+
runner_secrets,
|
|
4690
|
+
managed_app_artifacts,
|
|
4691
|
+
)
|
|
4692
|
+
else:
|
|
4693
|
+
push_database_schema(outputs_path, vars_json)
|
|
4694
|
+
ensure_first_admin(outputs_path, vars_json, payload, runner_secrets)
|
|
4695
|
+
write_outputs_to_ssm(outputs_path, vars_json)
|
|
4696
|
+
selected_controller_release = write_controller_release_selection_to_ssm(vars_json)
|
|
4697
|
+
if selected_controller_release:
|
|
4698
|
+
CONTROLLER_EVIDENCE["releaseSelection"] = write_json_evidence_artifact(
|
|
4699
|
+
"controller-release-selection.json",
|
|
4700
|
+
selected_controller_release,
|
|
4701
|
+
)
|
|
4702
|
+
sync_static(outputs_path, static_files, vars_json)
|
|
4703
|
+
try:
|
|
4704
|
+
self_update_runner_script()
|
|
4705
|
+
except Exception as self_update_error:
|
|
4706
|
+
print(f"[runner] self-update failed (non-fatal): {self_update_error}")
|
|
4707
|
+
write_evidence(
|
|
4708
|
+
"succeeded" if result.returncode == 0 else "failed",
|
|
4709
|
+
vars_json,
|
|
4710
|
+
result.returncode,
|
|
4711
|
+
)
|
|
4712
|
+
return result.returncode
|
|
4713
|
+
|
|
4714
|
+
|
|
4715
|
+
if __name__ == "__main__":
|
|
4716
|
+
try:
|
|
4717
|
+
raise SystemExit(main())
|
|
4718
|
+
except Exception as exc:
|
|
4719
|
+
write_evidence("failed", error=exc)
|
|
4720
|
+
raise
|