@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 +11 -1
- package/package.json +1 -1
- package/src/migrations/latest/py-agent-ag-ui-hooks/metadata.json +3 -0
- package/src/migrations/latest/py-agent-ag-ui-hooks/migration.d.ts +6 -0
- package/src/migrations/latest/py-agent-ag-ui-hooks/migration.js +113 -0
- package/src/migrations/latest/py-agent-ag-ui-hooks/migration.js.map +1 -0
- package/src/migrations/latest/static-website-configurable-waf-encryption/metadata.json +3 -0
- package/src/migrations/latest/static-website-configurable-waf-encryption/migration.d.ts +6 -0
- package/src/migrations/latest/static-website-configurable-waf-encryption/migration.js +418 -0
- package/src/migrations/latest/static-website-configurable-waf-encryption/migration.js.map +1 -0
- package/src/py/agent/__snapshots__/generator.constructs.spec.ts.snap +5 -1
- package/src/py/agent/files/strands/ag-ui/main.py.template +5 -1
- package/src/py/agent/files/strands/common/agent.py.template +5 -1
- package/src/ts/react-website/app/__snapshots__/generator.spec.ts.snap +259 -48
- package/src/utils/website-constructs/files/cdk/app/static-websites/__websiteNameKebabCase__.ts.template +11 -2
- package/src/utils/website-constructs/files/cdk/core/static-website.ts.template +47 -10
- package/src/utils/website-constructs/files/terraform/app/static-websites/__websiteNameKebabCase__/__websiteNameKebabCase__.tf.template +55 -0
- package/src/utils/website-constructs/files/terraform/core/static-website/static-website.tf.template +57 -15
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.
|
|
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
|
@@ -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,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
|