@aws/nx-plugin 1.0.0-rc.77 → 1.0.0-rc.79

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/migrations.json CHANGED
@@ -32,6 +32,16 @@
32
32
  "description": "Surface mfa and mfaSecondFactor on the vended UserIdentity construct/module",
33
33
  "implementation": "./src/migrations/latest/user-identity-configurable-mfa/migration"
34
34
  },
35
+ "latest-static-website-configurable-waf-encryption": {
36
+ "version": "1.0.0-rc.78",
37
+ "description": "Surface enableWaf, encryption, encryptionKey and enableKeyRotation on the vended StaticWebsite construct/module",
38
+ "implementation": "./src/migrations/latest/static-website-configurable-waf-encryption/migration"
39
+ },
40
+ "latest-py-agent-ag-ui-hooks": {
41
+ "version": "1.0.0-rc.79",
42
+ "description": "Forward the py#agent Strands hooks to the AG-UI adapter so model and tool errors are reported",
43
+ "implementation": "./src/migrations/latest/py-agent-ag-ui-hooks/migration"
44
+ },
35
45
  "v1.0.0-rc.50-0001-modernize-function-props-cast": {
36
46
  "version": "1.0.0-rc.50",
37
47
  "description": "Replace the legacy angle-bracket FunctionProps type assertion with the modern as syntax in generated API constructs",
@@ -153,7 +163,7 @@
153
163
  "implementation": "./src/migrations/v1.0.0-rc.72/0001-py-agent-session-management-support/migration"
154
164
  },
155
165
  "sync-vended-versions": {
156
- "version": "1.0.0-rc.77",
166
+ "version": "1.0.0-rc.79",
157
167
  "description": "Sync vended dependency versions and the tracked plugin version to those vended by this release",
158
168
  "implementation": "./src/utils/version-upgrade-migration/migration"
159
169
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aws/nx-plugin",
3
- "version": "1.0.0-rc.77",
3
+ "version": "1.0.0-rc.79",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/awslabs/nx-plugin-for-aws.git",
@@ -0,0 +1,3 @@
1
+ {
2
+ "description": "Forward the py#agent Strands hooks to the AG-UI adapter so model and tool errors are reported"
3
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import { type MigrationReturnObject, type Tree } from '@nx/devkit';
6
+ export default function migration(tree: Tree): Promise<MigrationReturnObject>;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */ import { getProjects, joinPathFragments } from "@nx/devkit";
5
+ import { PY_AGENT_GENERATOR_INFO } from "../../../py/agent/generator.js";
6
+ import { addPythonDestructuredImport, applyGritQL, captureGritQLVariable, GRIT_INSERT_PLACEHOLDER, insertViaGritQL, matchGritQL } from "../../../utils/ast.js";
7
+ import { formatFilesInSubtree } from "../../../utils/format.js";
8
+ /**
9
+ * Forward a Strands py#agent's hooks to the AG-UI adapter.
10
+ *
11
+ * `StrandsAgent` builds a fresh `Agent` per `thread_id` by copying the template
12
+ * Agent's kwargs, but deliberately skips `hooks` - Strands keeps only the built
13
+ * `HookRegistry`, so the providers can't be read back off it. An AG-UI agent's
14
+ * `log_model_errors` / `log_tool_errors` therefore never fired for served
15
+ * requests, and model/tool failures were reported as successful runs.
16
+ *
17
+ * The fix hoists the hooks list out of the `Agent(...)` call into an
18
+ * `AGENT_HOOKS` module constant in `agent.py` - whatever the user has put in it,
19
+ * so custom hooks come along - and passes that to `StrandsAgent(...)` in
20
+ * `main.py` as well. `agent.py` is hoisted for every protocol so the constant
21
+ * matches what the generator vends today; only AG-UI needs the `main.py` half.
22
+ */ // `Agent`'s `hooks` list is invariant, so the hoisted constant has to declare
23
+ // the parameter's own type - an inline literal was inferred from it instead.
24
+ const HOOKS_TYPE = 'list[HookProvider | HookCallback]';
25
+ const HOOKS_MODULE = 'strands.hooks';
26
+ // Anchored on the `get_agent` context manager - the one shape shared by every
27
+ // generated protocol and connection combination - so the constant lands
28
+ // directly above its only use, where the generator vends it.
29
+ const HOIST_HOOKS_PATTERN = `language python
30
+ \`@contextmanager
31
+ def get_agent($params):
32
+ $body\` => raw\`AGENT_HOOKS: ${HOOKS_TYPE} = ${GRIT_INSERT_PLACEHOLDER}
33
+
34
+
35
+ @contextmanager
36
+ def get_agent($params):
37
+ $body\` where { $program <: not contains \`AGENT_HOOKS\` }`;
38
+ // Scoped to the Agent constructor so a hooks list the user passes elsewhere in
39
+ // agent.py is left alone.
40
+ const HOOKS_LIST_CAPTURE_PATTERN = 'language python\n`hooks=[$hooks]` where { $hooks <: within `Agent($_)` }';
41
+ const HOOKS_LIST_REWRITE_PATTERN = 'language python\n`hooks=[$_]` as $kwarg where { $kwarg <: within `Agent($_)`, $kwarg => `hooks=AGENT_HOOKS` }';
42
+ const AGUI_CONSTRUCTOR_MATCH_PATTERN = 'language python\n`StrandsAgent($args)` where { $args <: contains `agent=$_` }';
43
+ const AGUI_HOOKS_WIRED_PATTERN = 'language python\n`StrandsAgent($args)` where { $args <: contains `agent=$_`, $args <: contains `hooks=$_` }';
44
+ // Appends after the last argument rather than re-emitting the argument list,
45
+ // both to land `hooks=` where the template puts it and to leave the other
46
+ // arguments' formatting and comments untouched.
47
+ const AGUI_HOOKS_APPEND_PATTERN = 'language python\n`StrandsAgent($args)` where { $args <: contains `agent=$_`, $args <: not contains `hooks=$_`, $args <: [$..., $last], $last += `, hooks=AGENT_HOOKS` }';
48
+ const findAgentComponents = (components)=>(components ?? []).filter((component)=>component.generator === PY_AGENT_GENERATOR_INFO.id);
49
+ /**
50
+ * Hoists `agent.py`'s `hooks=[...]` list into an `AGENT_HOOKS` module constant,
51
+ * returning whether the file ends up exporting one.
52
+ */ const hoistAgentHooks = async (tree, agentPath)=>{
53
+ if ((tree.read(agentPath, 'utf-8') ?? '').includes('AGENT_HOOKS')) {
54
+ return true;
55
+ }
56
+ // Nothing to hoist unless the Agent is constructed with an inline hooks list.
57
+ const hooks = await captureGritQLVariable(tree, agentPath, HOOKS_LIST_CAPTURE_PATTERN, 'hooks');
58
+ if (!hooks) return false;
59
+ if (!await insertViaGritQL(tree, agentPath, HOIST_HOOKS_PATTERN, `[${hooks}]`)) {
60
+ return false;
61
+ }
62
+ await applyGritQL(tree, agentPath, HOOKS_LIST_REWRITE_PATTERN);
63
+ await addPythonDestructuredImport(tree, agentPath, [
64
+ 'HookCallback',
65
+ 'HookProvider'
66
+ ], HOOKS_MODULE);
67
+ return true;
68
+ };
69
+ const migrateAgent = async (tree, agentDir, component, nextSteps)=>{
70
+ // LangChain agents pass no hooks - their AG-UI adapter is handed the graph
71
+ // itself rather than rebuilding one, so nothing is dropped.
72
+ if (component.framework === 'langchain') return;
73
+ const agentPath = joinPathFragments(agentDir, 'agent.py');
74
+ const mainPath = joinPathFragments(agentDir, 'main.py');
75
+ const isAgUi = component.protocol === 'ag-ui';
76
+ if (!tree.exists(agentPath)) return;
77
+ if (!await hoistAgentHooks(tree, agentPath)) {
78
+ if (isAgUi) {
79
+ nextSteps.push(`${agentPath}: the hooks passed to \`Agent(...)\` have diverged from the generated shape - left untouched. Hoist them into an \`AGENT_HOOKS\` module constant and pass it to \`StrandsAgent(...)\` in ${mainPath} too, or AG-UI will not register them (see the py#agent generator's template).`);
80
+ }
81
+ return;
82
+ }
83
+ // Only AG-UI rebuilds the Agent, so only its main.py needs the hooks.
84
+ if (!isAgUi || !tree.exists(mainPath)) return;
85
+ // Already passing hooks, whether from a previous run or the user's own wiring.
86
+ if (await matchGritQL(tree, mainPath, AGUI_HOOKS_WIRED_PATTERN)) return;
87
+ if (!await matchGritQL(tree, mainPath, AGUI_CONSTRUCTOR_MATCH_PATTERN)) {
88
+ nextSteps.push(`${mainPath}: the \`StrandsAgent(...)\` constructor has diverged from the generated shape - left untouched. Pass \`hooks=AGENT_HOOKS\` to it, importing \`AGENT_HOOKS\` from \`.agent\`, or AG-UI will not register the agent's hooks (see the py#agent generator's template).`);
89
+ return;
90
+ }
91
+ await applyGritQL(tree, mainPath, AGUI_HOOKS_APPEND_PATTERN);
92
+ await addPythonDestructuredImport(tree, mainPath, [
93
+ 'AGENT_HOOKS'
94
+ ], '.agent');
95
+ };
96
+ export default async function migration(tree) {
97
+ const nextSteps = [];
98
+ for (const project of getProjects(tree).values()){
99
+ const components = findAgentComponents(project.metadata?.components);
100
+ for (const component of components){
101
+ if (!component.path) continue;
102
+ // Legacy component paths point at agent.py rather than its directory.
103
+ const componentDir = component.path.endsWith('/agent.py') ? component.path.slice(0, -'/agent.py'.length) : component.path;
104
+ await migrateAgent(tree, joinPathFragments(project.root, componentDir), component, nextSteps);
105
+ }
106
+ }
107
+ await formatFilesInSubtree(tree);
108
+ return {
109
+ nextSteps
110
+ };
111
+ }
112
+
113
+ //# sourceMappingURL=migration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../packages/nx-plugin/src/migrations/latest/py-agent-ag-ui-hooks/migration.ts"],"sourcesContent":["/**\n * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\n * SPDX-License-Identifier: Apache-2.0\n */\nimport {\n getProjects,\n joinPathFragments,\n type MigrationReturnObject,\n type Tree,\n} from '@nx/devkit';\nimport { PY_AGENT_GENERATOR_INFO } from '../../../py/agent/generator';\nimport {\n addPythonDestructuredImport,\n applyGritQL,\n captureGritQLVariable,\n GRIT_INSERT_PLACEHOLDER,\n insertViaGritQL,\n matchGritQL,\n} from '../../../utils/ast';\nimport { formatFilesInSubtree } from '../../../utils/format';\nimport type { ComponentMetadata } from '../../../utils/nx';\n\n/**\n * Forward a Strands py#agent's hooks to the AG-UI adapter.\n *\n * `StrandsAgent` builds a fresh `Agent` per `thread_id` by copying the template\n * Agent's kwargs, but deliberately skips `hooks` - Strands keeps only the built\n * `HookRegistry`, so the providers can't be read back off it. An AG-UI agent's\n * `log_model_errors` / `log_tool_errors` therefore never fired for served\n * requests, and model/tool failures were reported as successful runs.\n *\n * The fix hoists the hooks list out of the `Agent(...)` call into an\n * `AGENT_HOOKS` module constant in `agent.py` - whatever the user has put in it,\n * so custom hooks come along - and passes that to `StrandsAgent(...)` in\n * `main.py` as well. `agent.py` is hoisted for every protocol so the constant\n * matches what the generator vends today; only AG-UI needs the `main.py` half.\n */\n\n// `Agent`'s `hooks` list is invariant, so the hoisted constant has to declare\n// the parameter's own type - an inline literal was inferred from it instead.\nconst HOOKS_TYPE = 'list[HookProvider | HookCallback]';\nconst HOOKS_MODULE = 'strands.hooks';\n\n// Anchored on the `get_agent` context manager - the one shape shared by every\n// generated protocol and connection combination - so the constant lands\n// directly above its only use, where the generator vends it.\nconst HOIST_HOOKS_PATTERN = `language python\n\\`@contextmanager\ndef get_agent($params):\n $body\\` => raw\\`AGENT_HOOKS: ${HOOKS_TYPE} = ${GRIT_INSERT_PLACEHOLDER}\n\n\n@contextmanager\ndef get_agent($params):\n $body\\` where { $program <: not contains \\`AGENT_HOOKS\\` }`;\n\n// Scoped to the Agent constructor so a hooks list the user passes elsewhere in\n// agent.py is left alone.\nconst HOOKS_LIST_CAPTURE_PATTERN =\n 'language python\\n`hooks=[$hooks]` where { $hooks <: within `Agent($_)` }';\nconst HOOKS_LIST_REWRITE_PATTERN =\n 'language python\\n`hooks=[$_]` as $kwarg where { $kwarg <: within `Agent($_)`, $kwarg => `hooks=AGENT_HOOKS` }';\n\nconst AGUI_CONSTRUCTOR_MATCH_PATTERN =\n 'language python\\n`StrandsAgent($args)` where { $args <: contains `agent=$_` }';\nconst AGUI_HOOKS_WIRED_PATTERN =\n 'language python\\n`StrandsAgent($args)` where { $args <: contains `agent=$_`, $args <: contains `hooks=$_` }';\n// Appends after the last argument rather than re-emitting the argument list,\n// both to land `hooks=` where the template puts it and to leave the other\n// arguments' formatting and comments untouched.\nconst AGUI_HOOKS_APPEND_PATTERN =\n 'language python\\n`StrandsAgent($args)` where { $args <: contains `agent=$_`, $args <: not contains `hooks=$_`, $args <: [$..., $last], $last += `, hooks=AGENT_HOOKS` }';\n\nconst findAgentComponents = (\n components: ComponentMetadata[] | undefined,\n): ComponentMetadata[] =>\n (components ?? []).filter(\n (component) => component.generator === PY_AGENT_GENERATOR_INFO.id,\n );\n\n/**\n * Hoists `agent.py`'s `hooks=[...]` list into an `AGENT_HOOKS` module constant,\n * returning whether the file ends up exporting one.\n */\nconst hoistAgentHooks = async (\n tree: Tree,\n agentPath: string,\n): Promise<boolean> => {\n if ((tree.read(agentPath, 'utf-8') ?? '').includes('AGENT_HOOKS')) {\n return true;\n }\n\n // Nothing to hoist unless the Agent is constructed with an inline hooks list.\n const hooks = await captureGritQLVariable(\n tree,\n agentPath,\n HOOKS_LIST_CAPTURE_PATTERN,\n 'hooks',\n );\n if (!hooks) return false;\n\n if (\n !(await insertViaGritQL(tree, agentPath, HOIST_HOOKS_PATTERN, `[${hooks}]`))\n ) {\n return false;\n }\n await applyGritQL(tree, agentPath, HOOKS_LIST_REWRITE_PATTERN);\n await addPythonDestructuredImport(\n tree,\n agentPath,\n ['HookCallback', 'HookProvider'],\n HOOKS_MODULE,\n );\n return true;\n};\n\nconst migrateAgent = async (\n tree: Tree,\n agentDir: string,\n component: ComponentMetadata,\n nextSteps: string[],\n): Promise<void> => {\n // LangChain agents pass no hooks - their AG-UI adapter is handed the graph\n // itself rather than rebuilding one, so nothing is dropped.\n if (component.framework === 'langchain') return;\n\n const agentPath = joinPathFragments(agentDir, 'agent.py');\n const mainPath = joinPathFragments(agentDir, 'main.py');\n const isAgUi = component.protocol === 'ag-ui';\n\n if (!tree.exists(agentPath)) return;\n\n if (!(await hoistAgentHooks(tree, agentPath))) {\n if (isAgUi) {\n nextSteps.push(\n `${agentPath}: the hooks passed to \\`Agent(...)\\` have diverged from the generated shape - left untouched. Hoist them into an \\`AGENT_HOOKS\\` module constant and pass it to \\`StrandsAgent(...)\\` in ${mainPath} too, or AG-UI will not register them (see the py#agent generator's template).`,\n );\n }\n return;\n }\n\n // Only AG-UI rebuilds the Agent, so only its main.py needs the hooks.\n if (!isAgUi || !tree.exists(mainPath)) return;\n\n // Already passing hooks, whether from a previous run or the user's own wiring.\n if (await matchGritQL(tree, mainPath, AGUI_HOOKS_WIRED_PATTERN)) return;\n\n if (!(await matchGritQL(tree, mainPath, AGUI_CONSTRUCTOR_MATCH_PATTERN))) {\n nextSteps.push(\n `${mainPath}: the \\`StrandsAgent(...)\\` constructor has diverged from the generated shape - left untouched. Pass \\`hooks=AGENT_HOOKS\\` to it, importing \\`AGENT_HOOKS\\` from \\`.agent\\`, or AG-UI will not register the agent's hooks (see the py#agent generator's template).`,\n );\n return;\n }\n\n await applyGritQL(tree, mainPath, AGUI_HOOKS_APPEND_PATTERN);\n await addPythonDestructuredImport(tree, mainPath, ['AGENT_HOOKS'], '.agent');\n};\n\nexport default async function migration(\n tree: Tree,\n): Promise<MigrationReturnObject> {\n const nextSteps: string[] = [];\n\n for (const project of getProjects(tree).values()) {\n const components = findAgentComponents(\n (project.metadata as { components?: ComponentMetadata[] })?.components,\n );\n\n for (const component of components) {\n if (!component.path) continue;\n\n // Legacy component paths point at agent.py rather than its directory.\n const componentDir = component.path.endsWith('/agent.py')\n ? component.path.slice(0, -'/agent.py'.length)\n : component.path;\n\n await migrateAgent(\n tree,\n joinPathFragments(project.root, componentDir),\n component,\n nextSteps,\n );\n }\n }\n\n await formatFilesInSubtree(tree);\n\n return { nextSteps };\n}\n"],"names":["getProjects","joinPathFragments","PY_AGENT_GENERATOR_INFO","addPythonDestructuredImport","applyGritQL","captureGritQLVariable","GRIT_INSERT_PLACEHOLDER","insertViaGritQL","matchGritQL","formatFilesInSubtree","HOOKS_TYPE","HOOKS_MODULE","HOIST_HOOKS_PATTERN","HOOKS_LIST_CAPTURE_PATTERN","HOOKS_LIST_REWRITE_PATTERN","AGUI_CONSTRUCTOR_MATCH_PATTERN","AGUI_HOOKS_WIRED_PATTERN","AGUI_HOOKS_APPEND_PATTERN","findAgentComponents","components","filter","component","generator","id","hoistAgentHooks","tree","agentPath","read","includes","hooks","migrateAgent","agentDir","nextSteps","framework","mainPath","isAgUi","protocol","exists","push","migration","project","values","metadata","path","componentDir","endsWith","slice","length","root"],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ {
2
+ "description": "Surface enableWaf, encryption, encryptionKey and enableKeyRotation on the vended StaticWebsite construct/module"
3
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import { type MigrationReturnObject, type Tree } from '@nx/devkit';
6
+ export default function migration(tree: Tree): Promise<MigrationReturnObject>;
@@ -0,0 +1,418 @@
1
+ /**
2
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */ import { joinPathFragments } from "@nx/devkit";
5
+ import { addDestructuredImport, applyGritQL, captureGritQLVariable, GRIT_INSERT_PLACEHOLDER, insertViaGritQL, matchGritQL } from "../../../utils/ast.js";
6
+ import { formatFilesInSubtree } from "../../../utils/format.js";
7
+ import { PACKAGES_DIR, SHARED_CONSTRUCTS_DIR, SHARED_TERRAFORM_DIR } from "../../../utils/shared-constructs-constants.js";
8
+ /**
9
+ * Bring existing workspaces up to the generators' current StaticWebsite
10
+ * configurability:
11
+ *
12
+ * - The vended `StaticWebsite` CDK construct gains `enableWaf`, `encryption`,
13
+ * `encryptionKey` and `enableKeyRotation` props. The WAF Web ACL is now
14
+ * optional, the KMS key used to encrypt the website and distribution log
15
+ * buckets is now overridable (or skippable in favour of another
16
+ * `BucketEncryption`), and the auto-created key's rotation is configurable.
17
+ * - Each vended per-website app construct (`app/static-websites/*.ts`) gains
18
+ * an optional `props` constructor parameter so these can be configured per
19
+ * app, rather than only by hand-editing the vended construct.
20
+ * - The vended Terraform static-website core module gains the equivalent
21
+ * `enable_waf`, `encryption`, `kms_key_arn`, `create_kms_key` and
22
+ * `enable_key_rotation` variables. The CloudFront distribution's
23
+ * `lifecycle.replace_triggered_by` on the WAF ACL is also dropped - it
24
+ * can't resolve once the ACL has a count of 0 and no prior state to
25
+ * reference, which broke `terraform plan` on any greenfield deployment
26
+ * with `enable_waf = false`.
27
+ * - Each vended per-website Terraform app module (`app/static-websites/<name>/<name>.tf`)
28
+ * gains pass-through `custom_domain_names`, `acm_certificate_arn`,
29
+ * `enable_waf`, `encryption`, `kms_key_arn`, `create_kms_key` and
30
+ * `enable_key_rotation` variables forwarded to the core module call,
31
+ * matching the pass-through convention every other app-wraps-core
32
+ * Terraform module in this plugin already follows (`rdb`, `agent-core`,
33
+ * `dcr-proxies`, the REST/HTTP API).
34
+ *
35
+ * These files are generated with `KeepExisting`, so without this an upgraded
36
+ * workspace has generators that support this configuration but vended files
37
+ * that don't. Diverged files are left untouched and reported via `nextSteps`.
38
+ */ const CDK_STATIC_WEBSITE_FILE = `${PACKAGES_DIR}/${SHARED_CONSTRUCTS_DIR}/src/core/static-website.ts`;
39
+ const CDK_STATIC_WEBSITES_APP_DIR = `${PACKAGES_DIR}/${SHARED_CONSTRUCTS_DIR}/src/app/static-websites`;
40
+ const TERRAFORM_STATIC_WEBSITE_FILE = `${PACKAGES_DIR}/${SHARED_TERRAFORM_DIR}/src/core/static-website/static-website.tf`;
41
+ const TERRAFORM_STATIC_WEBSITES_APP_DIR = `${PACKAGES_DIR}/${SHARED_TERRAFORM_DIR}/src/app/static-websites`;
42
+ const CDK_DIVERGED_MESSAGE = `${CDK_STATIC_WEBSITE_FILE}: has diverged from the generated shape - left untouched. To pick up the enableWaf, encryption, encryptionKey and enableKeyRotation props, manually port them from the vended core/static-website.ts template (see the ts#react-website generator's static-website construct).`;
43
+ const TERRAFORM_DIVERGED_MESSAGE = `${TERRAFORM_STATIC_WEBSITE_FILE}: has diverged from the generated shape - left untouched. To pick up the enable_waf, encryption, kms_key_arn, create_kms_key and enable_key_rotation variables, manually port them from the vended static-website.tf template (see the ts#react-website generator's static-website module).`;
44
+ const terraformAppDivergedMessage = (filePath)=>`${filePath}: has diverged from the generated shape - left untouched. To make custom_domain_names, acm_certificate_arn, enable_waf, encryption, kms_key_arn, create_kms_key and enable_key_rotation configurable from your root Terraform configuration, add pass-through variables here and forward them to the static_website module call (see the ts#react-website generator's static-websites app template).`;
45
+ // The doc comments below carry backticked markdown, which must stay out of
46
+ // the GritQL pattern that inserts this text (see insertViaGritQL). It's routed
47
+ // in as plain text via the placeholder instead.
48
+ const STATIC_WEBSITE_PROPS_TEXT = `/**
49
+ * Whether to protect the CloudFront distribution with an AWS WAF Web ACL.
50
+ *
51
+ * @default true
52
+ */
53
+ readonly enableWaf?: boolean;
54
+ /**
55
+ * Server-side encryption for the website and distribution log buckets.
56
+ *
57
+ * @default BucketEncryption.KMS
58
+ */
59
+ readonly encryption?: BucketEncryption;
60
+ /**
61
+ * KMS key used to encrypt the website and distribution log buckets. Only used when \`encryption\` is
62
+ * \`BucketEncryption.KMS\`. When not provided, a new key is created. Note that a key imported via
63
+ * \`Key.fromKeyArn\` must already grant the CloudWatch Logs, S3 and CloudFront service principals the
64
+ * necessary permissions in its own key policy - \`addToResourcePolicy\` is a no-op on an imported key,
65
+ * so this construct cannot grant them on your behalf.
66
+ */
67
+ readonly encryptionKey?: IKey;
68
+ /**
69
+ * Whether the automatically created KMS key has rotation enabled. Only applies when \`encryption\` is
70
+ * \`BucketEncryption.KMS\` and no \`encryptionKey\` is supplied.
71
+ *
72
+ * @default true
73
+ */
74
+ readonly enableKeyRotation?: boolean`;
75
+ /**
76
+ * Surface enableWaf/encryption/encryptionKey/enableKeyRotation on the vended
77
+ * StaticWebsite CDK construct.
78
+ */ const migrateCdkConstruct = async (tree, nextSteps)=>{
79
+ if (!tree.exists(CDK_STATIC_WEBSITE_FILE)) {
80
+ return; // This workspace has no CDK static website construct.
81
+ }
82
+ if (await matchGritQL(tree, CDK_STATIC_WEBSITE_FILE, '`readonly enableWaf?: boolean`')) {
83
+ return; // Already migrated.
84
+ }
85
+ const CERTIFICATE_FIELD = '`readonly certificate?: ICertificate`';
86
+ const DESTRUCTURE_OLD = '`{ websiteFilePath, websiteName, domainNames, certificate }: StaticWebsiteProps`';
87
+ const KEY_CREATION_OLD = "`const websiteKey = new Key(this, 'WebsiteKey', { enableKeyRotation: true })`";
88
+ const ADD_TO_RESOURCE_POLICY_OLD = '`websiteKey.addToResourcePolicy($args)`';
89
+ const WEBSITE_BUCKET_ENCRYPTION_OLD = "`encryption: BucketEncryption.KMS` as $prop where { $prop <: within `new Bucket($_, 'WebsiteBucket', $_)` }";
90
+ const DISTRIBUTION_LOGS_BUCKET_ENCRYPTION_OLD = "`encryption: BucketEncryption.KMS` as $prop where { $prop <: within `new Bucket($_, 'DistributionLogBucket', $_)` }";
91
+ const WAF_STACK_OLD = "`const wafStack = new CloudfrontWebAcl(this, 'waf')`";
92
+ const WEB_ACL_ID_OLD = '`webAclId: wafStack.wafArn`';
93
+ const anchors = [
94
+ CERTIFICATE_FIELD,
95
+ DESTRUCTURE_OLD,
96
+ KEY_CREATION_OLD,
97
+ ADD_TO_RESOURCE_POLICY_OLD,
98
+ WEBSITE_BUCKET_ENCRYPTION_OLD,
99
+ DISTRIBUTION_LOGS_BUCKET_ENCRYPTION_OLD,
100
+ WAF_STACK_OLD,
101
+ WEB_ACL_ID_OLD
102
+ ];
103
+ const allPresent = (await Promise.all(anchors.map((pattern)=>matchGritQL(tree, CDK_STATIC_WEBSITE_FILE, pattern)))).every(Boolean);
104
+ if (!allPresent) {
105
+ nextSteps.push(CDK_DIVERGED_MESSAGE);
106
+ return;
107
+ }
108
+ await addDestructuredImport(tree, CDK_STATIC_WEBSITE_FILE, [
109
+ 'IKey'
110
+ ], 'aws-cdk-lib/aws-kms');
111
+ await insertViaGritQL(tree, CDK_STATIC_WEBSITE_FILE, `${CERTIFICATE_FIELD} as $field => \`$field;
112
+ ${GRIT_INSERT_PLACEHOLDER}\``, STATIC_WEBSITE_PROPS_TEXT);
113
+ await applyGritQL(tree, CDK_STATIC_WEBSITE_FILE, `${DESTRUCTURE_OLD} => \`{
114
+ websiteFilePath,
115
+ websiteName,
116
+ domainNames,
117
+ certificate,
118
+ enableWaf = true,
119
+ encryption = BucketEncryption.KMS,
120
+ encryptionKey,
121
+ enableKeyRotation = true,
122
+ }: StaticWebsiteProps\``);
123
+ await applyGritQL(tree, CDK_STATIC_WEBSITE_FILE, `${KEY_CREATION_OLD} => \`const websiteKey: IKey | undefined =
124
+ encryption === BucketEncryption.KMS
125
+ ? (encryptionKey ?? new Key(this, 'WebsiteKey', { enableKeyRotation }))
126
+ : undefined;\``);
127
+ await applyGritQL(tree, CDK_STATIC_WEBSITE_FILE, `${ADD_TO_RESOURCE_POLICY_OLD} => \`websiteKey?.addToResourcePolicy($args)\``);
128
+ await applyGritQL(tree, CDK_STATIC_WEBSITE_FILE, `${WEBSITE_BUCKET_ENCRYPTION_OLD} => \`encryption\``);
129
+ await applyGritQL(tree, CDK_STATIC_WEBSITE_FILE, `${DISTRIBUTION_LOGS_BUCKET_ENCRYPTION_OLD} => \`encryption\``);
130
+ await applyGritQL(tree, CDK_STATIC_WEBSITE_FILE, `${WAF_STACK_OLD} => \`const wafStack = enableWaf ? new CloudfrontWebAcl(this, 'waf') : undefined;\``);
131
+ await applyGritQL(tree, CDK_STATIC_WEBSITE_FILE, `${WEB_ACL_ID_OLD} => \`webAclId: wafStack?.wafArn\``);
132
+ };
133
+ /**
134
+ * Surface the same props on each vended per-website app construct, so they
135
+ * can be configured per app rather than only by editing the shared construct.
136
+ */ const migrateCdkAppConstructs = async (tree, nextSteps)=>{
137
+ if (!tree.exists(CDK_STATIC_WEBSITES_APP_DIR)) {
138
+ return;
139
+ }
140
+ for (const fileName of tree.children(CDK_STATIC_WEBSITES_APP_DIR)){
141
+ if (fileName === 'index.ts' || !fileName.endsWith('.ts')) {
142
+ continue;
143
+ }
144
+ const filePath = joinPathFragments(CDK_STATIC_WEBSITES_APP_DIR, fileName);
145
+ const contents = tree.read(filePath, 'utf-8') ?? '';
146
+ if (contents.includes('StaticWebsiteProps')) {
147
+ continue; // Already migrated.
148
+ }
149
+ const name = await captureGritQLVariable(tree, filePath, '`export class $name extends StaticWebsite { $_ }`', 'name');
150
+ if (!name) {
151
+ continue; // Not a StaticWebsite subclass, not ours to touch.
152
+ }
153
+ const importPattern = '`import { StaticWebsite } from $path`';
154
+ const ctorPattern = '`constructor(scope: Construct, id: string) { $body }`';
155
+ // Captures the whole property list rather than anchoring on websiteName
156
+ // being followed by exactly one more property ($rest binds a single
157
+ // trailing node, not a variadic list). This must still match when a
158
+ // workspace already customised the super() call with e.g. domainNames
159
+ // and certificate (the pre-existing documented customization point).
160
+ const superPattern = `\`super(scope, id, { $props })\` as $call where {
161
+ $props <: contains \`websiteName: '${name}'\`
162
+ }`;
163
+ const ready = (await Promise.all([
164
+ importPattern,
165
+ ctorPattern,
166
+ superPattern
167
+ ].map((pattern)=>matchGritQL(tree, filePath, pattern)))).every(Boolean);
168
+ if (!ready) {
169
+ nextSteps.push(`${filePath}: has diverged from the generated shape - left untouched. To make enableWaf, encryption, encryptionKey and enableKeyRotation configurable here, add an optional \`props?: ${name}Props\` constructor parameter (\`Omit<StaticWebsiteProps, 'websiteName' | 'websiteFilePath'>\`) and spread it into the super() call (see the ts#react-website generator's static-websites app template).`);
170
+ continue;
171
+ }
172
+ await applyGritQL(tree, filePath, `${importPattern} => \`import { StaticWebsite, StaticWebsiteProps } from $path\``);
173
+ await insertViaGritQL(tree, filePath, `\`export class ${name} extends StaticWebsite { $body }\` as $cls => \`${GRIT_INSERT_PLACEHOLDER}
174
+
175
+ $cls\``, `export type ${name}Props = Omit<\n StaticWebsiteProps,\n 'websiteName' | 'websiteFilePath'\n>;`);
176
+ await applyGritQL(tree, filePath, `${ctorPattern} => \`constructor(scope: Construct, id: string, props?: ${name}Props) { $body }\``);
177
+ await applyGritQL(tree, filePath, `${superPattern} => \`super(scope, id, { ...props, $props })\``);
178
+ }
179
+ };
180
+ // Terraform (HCL) patterns are built from plain strings rather than JS
181
+ // template literals, since the HCL text itself is full of `${...}`
182
+ // interpolations that would otherwise be parsed as JS interpolation.
183
+ const hcl = (pattern)=>'language hcl\n' + pattern;
184
+ const withinResource = (linePattern, resourceType, resourceLabel)=>hcl('`' + linePattern + '` as $line where {\n' + ' $line <: within `resource "' + resourceType + '" "' + resourceLabel + '" { $_ }`\n' + '}');
185
+ const NEW_VARIABLES_TEXT = [
186
+ 'variable "enable_waf" {',
187
+ ' description = "Whether to protect the CloudFront distribution with an AWS WAF Web ACL."',
188
+ ' type = bool',
189
+ ' default = true',
190
+ '}',
191
+ '',
192
+ 'variable "encryption" {',
193
+ ' description = "Server-side encryption for the website and distribution log buckets. One of KMS or S3_MANAGED."',
194
+ ' type = string',
195
+ ' default = "KMS"',
196
+ '',
197
+ ' validation {',
198
+ ' condition = contains(["KMS", "S3_MANAGED"], var.encryption)',
199
+ ' error_message = "encryption must be one of KMS or S3_MANAGED."',
200
+ ' }',
201
+ '}',
202
+ '',
203
+ 'variable "kms_key_arn" {',
204
+ ' description = "ARN of an existing KMS key used to encrypt the website and distribution log buckets when encryption is KMS. When not provided and create_kms_key is true, a new key is created. Note that a customer-supplied key must already grant the CloudWatch Logs, S3 and CloudFront service principals the necessary permissions in its own key policy."',
205
+ ' type = string',
206
+ ' default = null',
207
+ '}',
208
+ '',
209
+ 'variable "create_kms_key" {',
210
+ ' description = "Whether to create a KMS key for the website. Only applies when encryption is KMS. Set to false when supplying kms_key_arn."',
211
+ ' type = bool',
212
+ ' default = true',
213
+ '}',
214
+ '',
215
+ 'variable "enable_key_rotation" {',
216
+ ' description = "Whether the automatically created KMS key has rotation enabled. Only applies when encryption is KMS and create_kms_key is true."',
217
+ ' type = bool',
218
+ ' default = true',
219
+ '}'
220
+ ].join('\n');
221
+ const NEW_LOCALS_TEXT = [
222
+ '',
223
+ ' create_website_key = var.encryption == "KMS" && var.create_kms_key',
224
+ ' website_kms_key_arn = (',
225
+ ' var.encryption != "KMS" ? null :',
226
+ ' local.create_website_key ? aws_kms_key.website_key[0].arn :',
227
+ ' var.kms_key_arn',
228
+ ' )'
229
+ ].join('\n');
230
+ /**
231
+ * Surface the same configuration on the vended Terraform static-website
232
+ * module.
233
+ */ const migrateTerraformModule = async (tree, nextSteps)=>{
234
+ if (!tree.exists(TERRAFORM_STATIC_WEBSITE_FILE)) {
235
+ return; // This workspace has no Terraform static website module.
236
+ }
237
+ if (await matchGritQL(tree, TERRAFORM_STATIC_WEBSITE_FILE, hcl('`variable "enable_waf" { $_ }`'))) {
238
+ return; // Already migrated.
239
+ }
240
+ const ACM_CERT_VARIABLE = hcl('`variable "acm_certificate_arn" { $_ }`');
241
+ const ACCESS_LOGS_LOCAL = hcl('`access_logs_name_prefix = substr(lower(var.website_name), 0, 16)`');
242
+ const KMS_KEY_BLOCK = hcl('`resource "aws_kms_key" "website_key" { $_ }`');
243
+ const KMS_KEY_ROTATION_LINE = withinResource('enable_key_rotation = true', 'aws_kms_key', 'website_key');
244
+ const KMS_ALIAS_BLOCK = hcl('`resource "aws_kms_alias" "website_key_alias" { $_ }`');
245
+ const KMS_ALIAS_TARGET_LINE = hcl('`target_key_id = aws_kms_key.website_key.key_id`');
246
+ const LOG_GROUP_KMS_LINE = hcl('`kms_key_id = aws_kms_key.website_key.arn`');
247
+ const WEBSITE_ENCRYPTION_KEY_LINE = withinResource('kms_master_key_id = aws_kms_key.website_key.arn', 'aws_s3_bucket_server_side_encryption_configuration', 'website_encryption');
248
+ const WEBSITE_ENCRYPTION_ALGO_LINE = withinResource('sse_algorithm = "aws:kms"', 'aws_s3_bucket_server_side_encryption_configuration', 'website_encryption');
249
+ const DISTRIBUTION_LOGS_ENCRYPTION_KEY_LINE = withinResource('kms_master_key_id = aws_kms_key.website_key.arn', 'aws_s3_bucket_server_side_encryption_configuration', 'distribution_logs_encryption');
250
+ const DISTRIBUTION_LOGS_ENCRYPTION_ALGO_LINE = withinResource('sse_algorithm = "aws:kms"', 'aws_s3_bucket_server_side_encryption_configuration', 'distribution_logs_encryption');
251
+ const WAF_BLOCK = hcl('`resource "aws_wafv2_web_acl" "cloudfront_waf" { $_ }`');
252
+ const WEB_ACL_ID_LINE = hcl('`web_acl_id = aws_wafv2_web_acl.cloudfront_waf.arn`');
253
+ const WAF_OUTPUT_VALUE_LINE = hcl('`value = aws_wafv2_web_acl.cloudfront_waf.arn` as $line where {\n' + ' $line <: within `output "waf_web_acl_arn" { $_ }`\n' + '}');
254
+ // Forces distribution replacement whenever the WAF ACL changes. Once the
255
+ // ACL gains `count`, this can't be resolved on a greenfield apply where
256
+ // count is 0 and there's no prior state to reference - see
257
+ // https://github.com/awslabs/nx-plugin-for-aws/pull/1107. It's also
258
+ // redundant: web_acl_id already references the ACL ARN directly, so a
259
+ // replaced ACL still drives a plain distribution update.
260
+ const LIFECYCLE_REPLACE_TRIGGERED_BY_BLOCK = hcl('`lifecycle {\n replace_triggered_by = [\n aws_wafv2_web_acl.cloudfront_waf\n ]\n }`');
261
+ const anchors = [
262
+ ACM_CERT_VARIABLE,
263
+ ACCESS_LOGS_LOCAL,
264
+ KMS_KEY_BLOCK,
265
+ KMS_KEY_ROTATION_LINE,
266
+ KMS_ALIAS_BLOCK,
267
+ KMS_ALIAS_TARGET_LINE,
268
+ LOG_GROUP_KMS_LINE,
269
+ WEBSITE_ENCRYPTION_KEY_LINE,
270
+ WEBSITE_ENCRYPTION_ALGO_LINE,
271
+ DISTRIBUTION_LOGS_ENCRYPTION_KEY_LINE,
272
+ DISTRIBUTION_LOGS_ENCRYPTION_ALGO_LINE,
273
+ WAF_BLOCK,
274
+ WEB_ACL_ID_LINE,
275
+ WAF_OUTPUT_VALUE_LINE,
276
+ LIFECYCLE_REPLACE_TRIGGERED_BY_BLOCK
277
+ ];
278
+ const allPresent = (await Promise.all(anchors.map((pattern)=>matchGritQL(tree, TERRAFORM_STATIC_WEBSITE_FILE, pattern)))).every(Boolean);
279
+ if (!allPresent) {
280
+ nextSteps.push(TERRAFORM_DIVERGED_MESSAGE);
281
+ return;
282
+ }
283
+ // 1. New variables, after acm_certificate_arn. Routed through the
284
+ // placeholder since the variable descriptions contain double-quoted
285
+ // HCL string values.
286
+ await insertViaGritQL(tree, TERRAFORM_STATIC_WEBSITE_FILE, hcl('`variable "acm_certificate_arn" { $body }` as $var => `$var\n\n' + GRIT_INSERT_PLACEHOLDER + '`'), NEW_VARIABLES_TEXT);
287
+ // 2. New locals, after access_logs_name_prefix.
288
+ await insertViaGritQL(tree, TERRAFORM_STATIC_WEBSITE_FILE, hcl('`access_logs_name_prefix = substr(lower(var.website_name), 0, 16)` as $line => `$line\n' + GRIT_INSERT_PLACEHOLDER + '`'), NEW_LOCALS_TEXT);
289
+ // 3. Make the KMS key and alias conditional on encryption/kms_key_arn.
290
+ await applyGritQL(tree, TERRAFORM_STATIC_WEBSITE_FILE, hcl('`resource "aws_kms_key" "website_key" { $body }` => `resource "aws_kms_key" "website_key" {\n' + ' count = local.create_website_key ? 1 : 0\n\n' + ' $body\n' + '}`'));
291
+ await applyGritQL(tree, TERRAFORM_STATIC_WEBSITE_FILE, KMS_KEY_ROTATION_LINE + ' => `enable_key_rotation = var.enable_key_rotation`');
292
+ await applyGritQL(tree, TERRAFORM_STATIC_WEBSITE_FILE, hcl('`resource "aws_kms_alias" "website_key_alias" { $body }` => `resource "aws_kms_alias" "website_key_alias" {\n' + ' count = local.create_website_key ? 1 : 0\n\n' + ' $body\n' + '}`'));
293
+ await applyGritQL(tree, TERRAFORM_STATIC_WEBSITE_FILE, KMS_ALIAS_TARGET_LINE + ' => `target_key_id = aws_kms_key.website_key[0].key_id`');
294
+ // 4. Resolve the key ARN through the new local everywhere it's consumed.
295
+ await applyGritQL(tree, TERRAFORM_STATIC_WEBSITE_FILE, LOG_GROUP_KMS_LINE + ' => `kms_key_id = local.website_kms_key_arn`');
296
+ await applyGritQL(tree, TERRAFORM_STATIC_WEBSITE_FILE, WEBSITE_ENCRYPTION_KEY_LINE + ' => `kms_master_key_id = var.encryption == "KMS" ? local.website_kms_key_arn : null`');
297
+ await applyGritQL(tree, TERRAFORM_STATIC_WEBSITE_FILE, WEBSITE_ENCRYPTION_ALGO_LINE + ' => `sse_algorithm = var.encryption == "KMS" ? "aws:kms" : "AES256"`');
298
+ await applyGritQL(tree, TERRAFORM_STATIC_WEBSITE_FILE, DISTRIBUTION_LOGS_ENCRYPTION_KEY_LINE + ' => `kms_master_key_id = var.encryption == "KMS" ? local.website_kms_key_arn : null`');
299
+ await applyGritQL(tree, TERRAFORM_STATIC_WEBSITE_FILE, DISTRIBUTION_LOGS_ENCRYPTION_ALGO_LINE + ' => `sse_algorithm = var.encryption == "KMS" ? "aws:kms" : "AES256"`');
300
+ // 5. Make the WAF Web ACL conditional on enable_waf.
301
+ await applyGritQL(tree, TERRAFORM_STATIC_WEBSITE_FILE, hcl('`resource "aws_wafv2_web_acl" "cloudfront_waf" { $body }` => `resource "aws_wafv2_web_acl" "cloudfront_waf" {\n' + ' count = var.enable_waf ? 1 : 0\n\n' + ' $body\n' + '}`'));
302
+ await applyGritQL(tree, TERRAFORM_STATIC_WEBSITE_FILE, WEB_ACL_ID_LINE + ' => `web_acl_id = var.enable_waf ? aws_wafv2_web_acl.cloudfront_waf[0].arn : null`');
303
+ await applyGritQL(tree, TERRAFORM_STATIC_WEBSITE_FILE, WAF_OUTPUT_VALUE_LINE + ' => `value = var.enable_waf ? aws_wafv2_web_acl.cloudfront_waf[0].arn : null`');
304
+ // 6. Drop the lifecycle block that can no longer resolve once the WAF ACL
305
+ // has a count of 0 and no prior state to reference.
306
+ await applyGritQL(tree, TERRAFORM_STATIC_WEBSITE_FILE, LIFECYCLE_REPLACE_TRIGGERED_BY_BLOCK + ' => ``');
307
+ };
308
+ const NEW_APP_MODULE_VARIABLES_TEXT = [
309
+ 'variable "custom_domain_names" {',
310
+ ' description = "Custom domain names (aliases) for the CloudFront distribution. Requires acm_certificate_arn."',
311
+ ' type = list(string)',
312
+ ' default = []',
313
+ '}',
314
+ '',
315
+ 'variable "acm_certificate_arn" {',
316
+ ' description = "ARN of an ACM certificate (in us-east-1) for the custom domain names. When set, viewers are required to use TLS 1.2 or later."',
317
+ ' type = string',
318
+ ' default = null',
319
+ '}',
320
+ '',
321
+ 'variable "enable_waf" {',
322
+ ' description = "Whether to protect the CloudFront distribution with an AWS WAF Web ACL."',
323
+ ' type = bool',
324
+ ' default = true',
325
+ '}',
326
+ '',
327
+ 'variable "encryption" {',
328
+ ' description = "Server-side encryption for the website and distribution log buckets. One of KMS or S3_MANAGED."',
329
+ ' type = string',
330
+ ' default = "KMS"',
331
+ '',
332
+ ' validation {',
333
+ ' condition = contains(["KMS", "S3_MANAGED"], var.encryption)',
334
+ ' error_message = "encryption must be one of KMS or S3_MANAGED."',
335
+ ' }',
336
+ '}',
337
+ '',
338
+ 'variable "kms_key_arn" {',
339
+ ' description = "ARN of an existing KMS key used to encrypt the website and distribution log buckets when encryption is KMS. When not provided and create_kms_key is true, a new key is created. Note that a customer-supplied key must already grant the CloudWatch Logs, S3 and CloudFront service principals the necessary permissions in its own key policy."',
340
+ ' type = string',
341
+ ' default = null',
342
+ '}',
343
+ '',
344
+ 'variable "create_kms_key" {',
345
+ ' description = "Whether to create a KMS key for the website. Only applies when encryption is KMS. Set to false when supplying kms_key_arn."',
346
+ ' type = bool',
347
+ ' default = true',
348
+ '}',
349
+ '',
350
+ 'variable "enable_key_rotation" {',
351
+ ' description = "Whether the automatically created KMS key has rotation enabled. Only applies when encryption is KMS and create_kms_key is true."',
352
+ ' type = bool',
353
+ ' default = true',
354
+ '}'
355
+ ].join('\n');
356
+ const NEW_APP_MODULE_FORWARD_TEXT = [
357
+ 'custom_domain_names = var.custom_domain_names',
358
+ 'acm_certificate_arn = var.acm_certificate_arn',
359
+ 'enable_waf = var.enable_waf',
360
+ 'encryption = var.encryption',
361
+ 'kms_key_arn = var.kms_key_arn',
362
+ 'create_kms_key = var.create_kms_key',
363
+ 'enable_key_rotation = var.enable_key_rotation'
364
+ ].join('\n ');
365
+ /**
366
+ * Surface the same configuration as pass-through variables on each vended
367
+ * per-website Terraform app module, matching the pass-through convention
368
+ * every other app-wraps-core Terraform module already follows.
369
+ */ const migrateTerraformAppModules = async (tree, nextSteps)=>{
370
+ if (!tree.exists(TERRAFORM_STATIC_WEBSITES_APP_DIR)) {
371
+ return;
372
+ }
373
+ for (const dirName of tree.children(TERRAFORM_STATIC_WEBSITES_APP_DIR)){
374
+ const filePath = joinPathFragments(TERRAFORM_STATIC_WEBSITES_APP_DIR, dirName, `${dirName}.tf`);
375
+ if (!tree.exists(filePath)) {
376
+ continue; // Not a website app module directory.
377
+ }
378
+ if (await matchGritQL(tree, filePath, hcl('`variable "enable_waf" { $_ }`'))) {
379
+ continue; // Already migrated.
380
+ }
381
+ const TERRAFORM_BLOCK = hcl('`terraform { $_ }`');
382
+ const MODULE_BLOCK = hcl('`module "static_website" { $_ }`');
383
+ const FILE_PATH_LINE = hcl('`website_file_path = $val` as $line where {\n' + ' $line <: within `module "static_website" { $_ }`\n' + '}');
384
+ // custom_domain_names/acm_certificate_arn predate this migration as a
385
+ // documented customization point (hand-editing the module block). If
386
+ // either is already present as a literal argument, blindly forwarding
387
+ // our own `= var.x` line would produce a duplicate argument, which is
388
+ // invalid HCL. Treat that as diverged instead of silently corrupting the file.
389
+ const hasExistingCustomDomainArg = async (name)=>matchGritQL(tree, filePath, hcl(`\`${name} = $_\` as $line where {\n` + ' $line <: within `module "static_website" { $_ }`\n' + '}'));
390
+ const ready = (await Promise.all([
391
+ TERRAFORM_BLOCK,
392
+ MODULE_BLOCK,
393
+ FILE_PATH_LINE
394
+ ].map((pattern)=>matchGritQL(tree, filePath, pattern)))).every(Boolean) && !await hasExistingCustomDomainArg('custom_domain_names') && !await hasExistingCustomDomainArg('acm_certificate_arn');
395
+ if (!ready) {
396
+ nextSteps.push(terraformAppDivergedMessage(filePath));
397
+ continue;
398
+ }
399
+ // Inserted right after the terraform/required_providers block (and
400
+ // before the "Static website module" comment), matching where the
401
+ // vended template puts these variables.
402
+ await insertViaGritQL(tree, filePath, hcl('`terraform { $body }` as $tf') + ` => \`$tf\n\n${GRIT_INSERT_PLACEHOLDER}\``, NEW_APP_MODULE_VARIABLES_TEXT);
403
+ await insertViaGritQL(tree, filePath, FILE_PATH_LINE + ` => \`$line\n\n ${GRIT_INSERT_PLACEHOLDER}\``, NEW_APP_MODULE_FORWARD_TEXT);
404
+ }
405
+ };
406
+ export default async function migration(tree) {
407
+ const nextSteps = [];
408
+ await migrateCdkConstruct(tree, nextSteps);
409
+ await migrateCdkAppConstructs(tree, nextSteps);
410
+ await migrateTerraformModule(tree, nextSteps);
411
+ await migrateTerraformAppModules(tree, nextSteps);
412
+ await formatFilesInSubtree(tree);
413
+ return {
414
+ nextSteps
415
+ };
416
+ }
417
+
418
+ //# sourceMappingURL=migration.js.map