@aws/nx-plugin 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +31 -29
  2. package/generators.json +16 -0
  3. package/migrations.json +6 -1
  4. package/package.json +1 -1
  5. package/src/agentcore-harness/generator.d.ts +1 -1
  6. package/src/greengrass/component/common.d.ts +60 -0
  7. package/src/greengrass/component/common.js +191 -0
  8. package/src/greengrass/component/common.js.map +1 -0
  9. package/src/greengrass/component/dependencies.d.ts +101 -0
  10. package/src/greengrass/component/dependencies.js +37 -0
  11. package/src/greengrass/component/dependencies.js.map +1 -0
  12. package/src/greengrass/component/generator.d.ts +174 -0
  13. package/src/greengrass/component/generator.js +32 -0
  14. package/src/greengrass/component/generator.js.map +1 -0
  15. package/src/greengrass/component/metadata.d.ts +2 -0
  16. package/src/greengrass/component/metadata.js +14 -0
  17. package/src/greengrass/component/metadata.js.map +1 -0
  18. package/src/greengrass/component/python/__snapshots__/generator.spec.ts.snap +450 -0
  19. package/src/greengrass/component/python/files/component/main.py.template +60 -0
  20. package/src/greengrass/component/python/files/component/recipe.yaml.template +30 -0
  21. package/src/greengrass/component/python/files/gdk/gdk-config.json.template +13 -0
  22. package/src/greengrass/component/python/files/tests/test___componentSnakeCase__.py.template +37 -0
  23. package/src/greengrass/component/python/generator.d.ts +15 -0
  24. package/src/greengrass/component/python/generator.js +192 -0
  25. package/src/greengrass/component/python/generator.js.map +1 -0
  26. package/src/greengrass/component/schema.d.js +6 -0
  27. package/src/greengrass/component/schema.d.js.map +1 -0
  28. package/src/greengrass/component/schema.d.ts +18 -0
  29. package/src/greengrass/component/schema.json +82 -0
  30. package/src/greengrass/component/typescript/__snapshots__/generator.spec.ts.snap +275 -0
  31. package/src/greengrass/component/typescript/files/component/recipe.yaml.template +41 -0
  32. package/src/greengrass/component/typescript/files/entrypoint/main.ts.template +58 -0
  33. package/src/greengrass/component/typescript/files/gdk/gdk-config.json.template +13 -0
  34. package/src/greengrass/component/typescript/files/scripts/vendor-crt-addon.ts.template +115 -0
  35. package/src/greengrass/component/typescript/generator.d.ts +16 -0
  36. package/src/greengrass/component/typescript/generator.js +194 -0
  37. package/src/greengrass/component/typescript/generator.js.map +1 -0
  38. package/src/infra/app/generator.d.ts +1 -1
  39. package/src/init/generator.d.ts +1 -1
  40. package/src/internal/test-matrix/generator.js +48 -1
  41. package/src/internal/test-matrix/generator.js.map +1 -1
  42. package/src/license/dependency-check/pre-approved.js +2 -1
  43. package/src/license/dependency-check/pre-approved.js.map +1 -1
  44. package/src/migrations/latest/ts-agent-guardrails-terraform-version-retention/metadata.json +3 -0
  45. package/src/migrations/latest/ts-agent-guardrails-terraform-version-retention/migration.d.ts +6 -0
  46. package/src/migrations/latest/ts-agent-guardrails-terraform-version-retention/migration.js +118 -0
  47. package/src/migrations/latest/ts-agent-guardrails-terraform-version-retention/migration.js.map +1 -0
  48. package/src/open-api/py-client/generator.d.ts +1 -1
  49. package/src/preset/__snapshots__/generator.spec.ts.snap +4 -0
  50. package/src/preset/generator.d.ts +1 -1
  51. package/src/py/agent/a2a-connection/generator.d.ts +1 -1
  52. package/src/py/agent/gateway-connection/generator.d.ts +1 -1
  53. package/src/py/agent/guardrails/__snapshots__/generator.spec.ts.snap +3813 -0
  54. package/src/py/agent/guardrails/files/langchain/guardrail.py.template +135 -0
  55. package/src/py/agent/guardrails/files/strands/guardrail.py.template +93 -0
  56. package/src/py/agent/guardrails/generator.d.ts +10 -0
  57. package/src/py/agent/guardrails/generator.js +175 -0
  58. package/src/py/agent/guardrails/generator.js.map +1 -0
  59. package/src/py/agent/guardrails/runtime-check/run_checks.py +250 -0
  60. package/src/py/agent/guardrails/schema.d.js +6 -0
  61. package/src/py/agent/guardrails/schema.d.js.map +1 -0
  62. package/src/py/agent/guardrails/schema.d.ts +9 -0
  63. package/src/py/agent/guardrails/schema.json +25 -0
  64. package/src/py/agent/mcp-connection/generator.d.ts +1 -1
  65. package/src/py/agent/react-connection/generator.d.ts +1 -1
  66. package/src/py/fast-api/react/generator.d.ts +1 -1
  67. package/src/py/project/generator.js +2 -0
  68. package/src/py/project/generator.js.map +1 -1
  69. package/src/sdk/greengrass.d.ts +6 -0
  70. package/src/sdk/greengrass.js +6 -0
  71. package/src/sdk/greengrass.js.map +1 -0
  72. package/src/sdk/py.d.ts +2 -0
  73. package/src/sdk/py.js +1 -0
  74. package/src/sdk/py.js.map +1 -1
  75. package/src/smithy/project/generator.d.ts +1 -1
  76. package/src/smithy/react-connection/generator.d.ts +1 -1
  77. package/src/terraform/project/generator.d.ts +1 -1
  78. package/src/trpc/react/generator.d.ts +1 -1
  79. package/src/ts/agent/a2a-connection/generator.d.ts +1 -1
  80. package/src/ts/agent/gateway-connection/generator.d.ts +1 -1
  81. package/src/ts/agent/guardrails/__snapshots__/generator.spec.ts.snap +54 -3
  82. package/src/ts/agent/guardrails/generator.js +2 -1
  83. package/src/ts/agent/guardrails/generator.js.map +1 -1
  84. package/src/ts/agent/mcp-connection/generator.d.ts +1 -1
  85. package/src/ts/agent/react-connection/generator.d.ts +1 -1
  86. package/src/ts/astro-docs/generator.d.ts +1 -1
  87. package/src/ts/dcr-proxy/generator.d.ts +1 -1
  88. package/src/ts/dynamodb/generator.d.ts +1 -1
  89. package/src/ts/lambda-function/generator.d.ts +1 -1
  90. package/src/ts/lib/generator.d.ts +1 -1
  91. package/src/ts/nx-generator/generator.d.ts +1 -1
  92. package/src/ts/nx-migration/generator.d.ts +1 -1
  93. package/src/ts/nx-plugin/generator.d.ts +1 -1
  94. package/src/ts/rdb/generator.d.ts +1 -1
  95. package/src/ts/react-website/agui/generator.d.ts +1 -1
  96. package/src/utils/__snapshots__/shared-greengrass-scripts.spec.ts.snap +1279 -0
  97. package/src/utils/files/common/scripts/src/greengrass/build-artifact.ts.template +236 -0
  98. package/src/utils/files/common/scripts/src/greengrass/component-logs.ts.template +48 -0
  99. package/src/utils/files/common/scripts/src/greengrass/deploy-local.ts.template +497 -0
  100. package/src/utils/files/common/scripts/src/greengrass/recipe-utils.ts.template +356 -0
  101. package/src/utils/files/common/scripts/src/greengrass/zip-writer.ts.template +121 -0
  102. package/src/utils/files/terraform/src/core/runtime-config/read/read.tf.template +3 -3
  103. package/src/utils/files/terraform/src/metrics/metrics.tf.template +1 -1
  104. package/src/utils/greengrass/constants.d.ts +58 -0
  105. package/src/utils/greengrass/constants.js +63 -0
  106. package/src/utils/greengrass/constants.js.map +1 -0
  107. package/src/utils/greengrass/naming.d.ts +46 -0
  108. package/src/utils/greengrass/naming.js +67 -0
  109. package/src/utils/greengrass/naming.js.map +1 -0
  110. package/src/utils/greengrass/recipe.d.ts +143 -0
  111. package/src/utils/greengrass/recipe.js +119 -0
  112. package/src/utils/greengrass/recipe.js.map +1 -0
  113. package/src/utils/greengrass-constructs/files/cdk/app/greengrass-component/__componentDirName__.ts.template +42 -0
  114. package/src/utils/greengrass-constructs/files/cdk/core/greengrass/artifact-bucket.ts.template +82 -0
  115. package/src/utils/greengrass-constructs/files/cdk/core/greengrass/component-version.ts.template +370 -0
  116. package/src/utils/greengrass-constructs/files/cdk/core/greengrass/deployment.ts.template +276 -0
  117. package/src/utils/greengrass-constructs/files/cdk/core/greengrass/index.ts.template +5 -0
  118. package/src/utils/greengrass-constructs/files/cdk/core/greengrass/next-version.ts.template +119 -0
  119. package/src/utils/greengrass-constructs/files/cdk/core/greengrass/recipe.ts.template +90 -0
  120. package/src/utils/greengrass-constructs/files/next-version/__nextVersionFileName__.template +195 -0
  121. package/src/utils/greengrass-constructs/files/terraform/app/greengrass-component/__componentDirName__/__componentDirName__.tf.template +26 -0
  122. package/src/utils/greengrass-constructs/files/terraform/core/greengrass/artifact-bucket/main.tf.template +204 -0
  123. package/src/utils/greengrass-constructs/files/terraform/core/greengrass/component-version/main.tf.template +156 -0
  124. package/src/utils/greengrass-constructs/files/terraform/core/greengrass/deployment/main.tf.template +186 -0
  125. package/src/utils/greengrass-constructs/greengrass-constructs.d.ts +57 -0
  126. package/src/utils/greengrass-constructs/greengrass-constructs.js +154 -0
  127. package/src/utils/greengrass-constructs/greengrass-constructs.js.map +1 -0
  128. package/src/utils/guardrail-constructs/files/terraform/app/guardrails/__nameKebabCase__/__nameKebabCase__-guardrail.tf.template +18 -1
  129. package/src/utils/guardrail-constructs/guardrail-constructs.d.ts +5 -0
  130. package/src/utils/guardrail-constructs/guardrail-constructs.js +8 -0
  131. package/src/utils/guardrail-constructs/guardrail-constructs.js.map +1 -1
  132. package/src/utils/guardrail-constructs/guardrail-wiring.d.ts +7 -0
  133. package/src/utils/guardrail-constructs/guardrail-wiring.js +6 -5
  134. package/src/utils/guardrail-constructs/guardrail-wiring.js.map +1 -1
  135. package/src/utils/nx.d.ts +15 -0
  136. package/src/utils/nx.js +32 -0
  137. package/src/utils/nx.js.map +1 -1
  138. package/src/utils/shared-greengrass-scripts.d.ts +21 -0
  139. package/src/utils/shared-greengrass-scripts.js +43 -0
  140. package/src/utils/shared-greengrass-scripts.js.map +1 -0
  141. package/src/utils/test/fake-aws/fake_aws.py +205 -0
  142. package/src/utils/test/python-dependencies.d.ts +6 -0
  143. package/src/utils/test/python-dependencies.js +19 -0
  144. package/src/utils/test/python-dependencies.js.map +1 -1
  145. package/src/utils/test/warm-python-cache.js +5 -1
  146. package/src/utils/test/warm-python-cache.js.map +1 -1
  147. package/src/utils/versions.d.ts +7 -0
  148. package/src/utils/versions.js +7 -0
  149. package/src/utils/versions.js.map +1 -1
  150. /package/src/{ts/agent/guardrails/files/agent → utils/guardrail-constructs/files/policy}/guardrail.json.template +0 -0
@@ -0,0 +1,370 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { RemovalPolicy, Token } from 'aws-cdk-lib';
5
+ import { CfnComponentVersion } from 'aws-cdk-lib/aws-greengrassv2';
6
+ import { BucketDeployment, Source } from 'aws-cdk-lib/aws-s3-deployment';
7
+ import { Construct } from 'constructs';
8
+ import yaml from 'js-yaml';
9
+ import type { GreengrassArtifactBucket } from './artifact-bucket<% if (esm) { %>.js<% } %>';
10
+ import { GreengrassNextVersion } from './next-version<% if (esm) { %>.js<% } %>';
11
+ import {
12
+ readRecipeSummary,
13
+ substituteArtifactUris,
14
+ } from './recipe<% if (esm) { %>.js<% } %>';
15
+
16
+ const NEXT_VERSION_STRATEGIES = {
17
+ NEXT_PATCH: 'patch',
18
+ NEXT_MINOR: 'minor',
19
+ NEXT_MAJOR: 'major',
20
+ } as const;
21
+ const NEXT_VERSION_TAG_KEY = 'nx:greengrass:content-sha256';
22
+ const VERSIONING_TAG_KEY = 'nx:greengrass:versioning';
23
+
24
+ /**
25
+ * Publication inputs; NEXT_* versions resolve at deploy time.
26
+ */
27
+ export interface GreengrassComponentVersionProps {
28
+ /**
29
+ * Directory holding the resolved recipe YAML, read at synth time.
30
+ */
31
+ readonly recipesDir: string;
32
+
33
+ /**
34
+ * Built archives under <artifactsDir>/<ComponentName>/<ComponentVersion>/.
35
+ * Sentinel versions keep their literal directory name.
36
+ */
37
+ readonly artifactsDir: string;
38
+
39
+ /** The shared artifact bucket this version's artifact is uploaded to. */
40
+ readonly bucket: GreengrassArtifactBucket;
41
+
42
+ /**
43
+ * Retention for the published version and uploaded artifacts.
44
+ * @default RemovalPolicy.RETAIN
45
+ */
46
+ readonly removalPolicy?: RemovalPolicy;
47
+ }
48
+
49
+ /** Reads `dir`, returning the single file whose name passes `predicate`. */
50
+ const findSingleFile = (
51
+ dir: string,
52
+ description: string,
53
+ predicate: (name: string) => boolean = () => true,
54
+ ): string => {
55
+ let entries: string[];
56
+ try {
57
+ entries = readdirSync(dir).filter(predicate);
58
+ } catch {
59
+ throw new Error(
60
+ `No ${description} found at "${dir}" - run the component's "-artifact" target first.`,
61
+ );
62
+ }
63
+ if (entries.length !== 1) {
64
+ throw new Error(
65
+ `Expected exactly one ${description} in "${dir}", found ${entries.length}.`,
66
+ );
67
+ }
68
+ return join(dir, entries[0]);
69
+ };
70
+
71
+ /** Reads `dir`, returning every file name sorted, throwing when it holds none. */
72
+ const listFilesSorted = (dir: string, description: string): string[] => {
73
+ let entries: string[];
74
+ try {
75
+ entries = readdirSync(dir);
76
+ } catch {
77
+ throw new Error(
78
+ `No ${description} found at "${dir}" - run the component's "-artifact" target first.`,
79
+ );
80
+ }
81
+ if (entries.length === 0) {
82
+ throw new Error(
83
+ `Expected at least one ${description} in "${dir}", found 0 - run the component's "-artifact" target first.`,
84
+ );
85
+ }
86
+ return entries.sort();
87
+ };
88
+
89
+ /**
90
+ * Reject different content claiming the same component and version in one synth.
91
+ */
92
+ const publishedHashesByVersion = new Map<string, string>();
93
+
94
+ const assertNoSynthTimeVersionDrift = (
95
+ componentName: string,
96
+ componentVersion: string,
97
+ sha256: string,
98
+ recipePath: string,
99
+ ): void => {
100
+ const key = `${componentName}@${componentVersion}`;
101
+ const previousHash = publishedHashesByVersion.get(key);
102
+ if (previousHash !== undefined && previousHash !== sha256) {
103
+ const sentinelMode = componentVersion in NEXT_VERSION_STRATEGIES;
104
+ throw new Error(
105
+ sentinelMode
106
+ ? `Two constructs publish the same sentinel-versioned component ${key} with different content in one synth ` +
107
+ `("${recipePath}" hashes to ${sha256}, a previous construct hashed to ${previousHash}).`
108
+ : `Two different artifacts both claim to be ${key} in this synth ` +
109
+ `("${recipePath}" hashes to ${sha256}, a previous construct hashed to ${previousHash}). ` +
110
+ `Component versions are immutable - bump ComponentVersion in recipe.yaml before rebuilding.`,
111
+ );
112
+ }
113
+ publishedHashesByVersion.set(key, sha256);
114
+ };
115
+
116
+ /** Maximum prefix length allowed by BucketDeployment's bucket ownership tag. */
117
+ const MAX_DESTINATION_KEY_PREFIX_LENGTH = 102;
118
+
119
+ /** Report oversized artifact prefixes before BucketDeployment creates its tag. */
120
+ const assertArtifactKeyPrefixFits = (
121
+ componentName: string,
122
+ destinationKeyPrefix: string,
123
+ ): void => {
124
+ if (destinationKeyPrefix.length <= MAX_DESTINATION_KEY_PREFIX_LENGTH) {
125
+ return;
126
+ }
127
+ const overBy =
128
+ destinationKeyPrefix.length - MAX_DESTINATION_KEY_PREFIX_LENGTH;
129
+ throw new Error(
130
+ `ComponentName "${componentName}" (${componentName.length} characters) produces an artifact key prefix "${destinationKeyPrefix}" that is ${destinationKeyPrefix.length} ` +
131
+ `characters long, exceeding the ${MAX_DESTINATION_KEY_PREFIX_LENGTH} character limit CDK's ` +
132
+ `BucketDeployment enforces on it (the prefix always carries a fixed 32-character content hash ` +
133
+ `plus two "/" separators). Shorten ComponentName ` +
134
+ `by at least ${overBy} character(s).`,
135
+ );
136
+ };
137
+
138
+ /** Maximum serialized InlineRecipe size accepted by CreateComponentVersion. */
139
+ const MAX_INLINE_RECIPE_BYTES = 16 * 1024;
140
+
141
+ /** Shortest bucket name S3 accepts, and so the shortest a token can become. */
142
+ const MIN_S3_BUCKET_NAME_LENGTH = 3;
143
+
144
+ /** Reject oversized recipes using minimum resolved lengths for deploy-time tokens. */
145
+ const assertInlineRecipeFits = (
146
+ componentName: string,
147
+ componentVersion: string,
148
+ recipePath: string,
149
+ inlineRecipe: string,
150
+ bucketName: string,
151
+ versionToken?: string,
152
+ ): void => {
153
+ const bytes = Buffer.byteLength(inlineRecipe, 'utf-8');
154
+ let minimumInlineRecipe = inlineRecipe;
155
+ const unresolvedInputs: string[] = [];
156
+ if (Token.isUnresolved(bucketName)) {
157
+ minimumInlineRecipe = minimumInlineRecipe
158
+ .split(bucketName)
159
+ .join('x'.repeat(MIN_S3_BUCKET_NAME_LENGTH));
160
+ unresolvedInputs.push(
161
+ `the ${MIN_S3_BUCKET_NAME_LENGTH}-character minimum S3 allows for the artifact bucket name`,
162
+ );
163
+ }
164
+ if (versionToken !== undefined && Token.isUnresolved(versionToken)) {
165
+ minimumInlineRecipe = minimumInlineRecipe
166
+ .split(versionToken)
167
+ .join('0.0.0');
168
+ unresolvedInputs.push('0.0.0 for the resolved ComponentVersion');
169
+ }
170
+ const minimumBytes = Buffer.byteLength(minimumInlineRecipe, 'utf-8');
171
+ if (minimumBytes <= MAX_INLINE_RECIPE_BYTES) {
172
+ return;
173
+ }
174
+ const overBy = minimumBytes - MAX_INLINE_RECIPE_BYTES;
175
+ const sizeClause =
176
+ minimumBytes === bytes
177
+ ? `serializes to ${bytes} bytes`
178
+ : unresolvedInputs.length === 1 &&
179
+ Token.isUnresolved(bucketName) &&
180
+ versionToken === undefined
181
+ ? `serializes to ${bytes} bytes, of which ${minimumBytes} do not depend on the artifact ` +
182
+ `bucket's name (still an unresolved token here, so this counts it as the ` +
183
+ `${MIN_S3_BUCKET_NAME_LENGTH}-character minimum S3 allows)`
184
+ : `serializes to ${bytes} bytes, of which ${minimumBytes} count unresolved values as ` +
185
+ `${unresolvedInputs.join(' and ')}`;
186
+ throw new Error(
187
+ `The resolved recipe for ${componentName}@${componentVersion} ("${recipePath}") ${sizeClause}, ` +
188
+ `exceeding the ${MAX_INLINE_RECIPE_BYTES} byte limit CreateComponentVersion ` +
189
+ `enforces on InlineRecipe, by ${overBy} byte(s). The whole recipe crosses the API as one ` +
190
+ `document, so the total counts rather than any one field. Two things realistically consume ` +
191
+ `that budget: a large ComponentConfiguration.DefaultConfiguration block, which is yours to ` +
192
+ `author and which nothing here bounds, and one manifest per architecture under ` +
193
+ `"platform: linux-amd64-arm64". Shrink DefaultConfiguration (move bulk configuration into ` +
194
+ `an artifact the component reads at runtime) by at least ${overBy} byte(s), or split the ` +
195
+ `component so each architecture publishes its own single-manifest version.`,
196
+ );
197
+ };
198
+
199
+ /**
200
+ * Upload built artifacts and publish the recipe, resolving NEXT_* at deploy time.
201
+ */
202
+ export class GreengrassComponentVersion extends Construct {
203
+ public readonly componentName: string;
204
+ /**
205
+ * Recipe version, or the published resource's version token in NEXT_* mode.
206
+ */
207
+ public readonly componentVersion: string;
208
+ public readonly cfnComponentVersion: CfnComponentVersion;
209
+
210
+ constructor(
211
+ scope: Construct,
212
+ id: string,
213
+ props: GreengrassComponentVersionProps,
214
+ ) {
215
+ super(scope, id);
216
+
217
+ const removalPolicy = props.removalPolicy ?? RemovalPolicy.RETAIN;
218
+
219
+ const recipePath = findSingleFile(
220
+ props.recipesDir,
221
+ 'resolved recipe',
222
+ (name) => name.endsWith('.yaml') || name.endsWith('.yml'),
223
+ );
224
+ const { componentName, componentVersion, raw } = readRecipeSummary(
225
+ readFileSync(recipePath, 'utf-8'),
226
+ recipePath,
227
+ );
228
+ const strategy =
229
+ NEXT_VERSION_STRATEGIES[
230
+ componentVersion as keyof typeof NEXT_VERSION_STRATEGIES
231
+ ];
232
+
233
+ const artifactDir = join(
234
+ props.artifactsDir,
235
+ componentName,
236
+ componentVersion,
237
+ );
238
+ const artifactFileNames = listFilesSorted(
239
+ artifactDir,
240
+ `artifact for ${componentName}@${componentVersion}`,
241
+ );
242
+ const sha256ByName = new Map(
243
+ artifactFileNames.map((name) => [
244
+ name,
245
+ createHash('sha256')
246
+ .update(readFileSync(join(artifactDir, name)))
247
+ .digest('hex'),
248
+ ]),
249
+ );
250
+ // Hash the sorted artifact names and their content digests on both IaC paths.
251
+ const sha256 = createHash('sha256')
252
+ .update(
253
+ artifactFileNames
254
+ .map((name) => `${name}:${sha256ByName.get(name)!}`)
255
+ .join('\n'),
256
+ )
257
+ .digest('hex');
258
+ const rawWithoutVersion = { ...raw };
259
+ delete rawWithoutVersion.ComponentVersion;
260
+ const contentSha256 = strategy
261
+ ? createHash('sha256')
262
+ .update(
263
+ `${yaml.dump(rawWithoutVersion, { lineWidth: -1 })}\n${sha256}`,
264
+ )
265
+ .digest('hex')
266
+ : undefined;
267
+
268
+ assertNoSynthTimeVersionDrift(
269
+ componentName,
270
+ componentVersion,
271
+ strategy ? contentSha256! : sha256,
272
+ recipePath,
273
+ );
274
+
275
+ const destinationKeyPrefix = `${componentName}/${sha256.slice(0, 32)}/`;
276
+
277
+ assertArtifactKeyPrefixFits(
278
+ componentName,
279
+ destinationKeyPrefix,
280
+ );
281
+
282
+ // Sync the artifact directory to preserve archive basenames. Retain uploads
283
+ // with retained versions and leave other prefixes untouched.
284
+ const artifactUpload = new BucketDeployment(this, 'ArtifactUpload', {
285
+ sources: [Source.asset(artifactDir)],
286
+ destinationBucket: props.bucket.bucket,
287
+ destinationKeyPrefix,
288
+ prune: false,
289
+ retainOnDelete: removalPolicy === RemovalPolicy.RETAIN,
290
+ });
291
+
292
+ const next = strategy
293
+ ? new GreengrassNextVersion(this, 'NextVersion', {
294
+ componentName,
295
+ strategy,
296
+ contentSha256: contentSha256!,
297
+ bucketName: props.bucket.bucket.bucketName,
298
+ })
299
+ : undefined;
300
+ if (next) {
301
+ // CDK resolves this version token inside the serialized recipe at deployment.
302
+ raw.ComponentVersion = next.version;
303
+ }
304
+
305
+ // CDK resolves the bucket name inside the serialized recipe at deployment.
306
+ const substitutedBasenames = substituteArtifactUris(raw, {
307
+ bucketName: props.bucket.bucket.bucketName,
308
+ keyPrefix: destinationKeyPrefix,
309
+ });
310
+
311
+ // Require every managed artifact URI and built file to match.
312
+ const builtNames = new Set(artifactFileNames);
313
+ // Several manifests can reference the same architecture-independent archive.
314
+ const referencedNames = new Set(substitutedBasenames);
315
+ const missing = [...referencedNames].filter((name) => !builtNames.has(name));
316
+ if (missing.length > 0) {
317
+ throw new Error(
318
+ `"${recipePath}" references artifact ${missing
319
+ .map((name) => `"${name}"`)
320
+ .join(', ')}, but the files built for ${componentName}@${componentVersion} are ${artifactFileNames
321
+ .map((name) => `"${name}"`)
322
+ .join(', ')}. Make the recipe's artifact URI end in one of those names.`,
323
+ );
324
+ }
325
+ const unreferenced = artifactFileNames.filter(
326
+ (name) => !referencedNames.has(name),
327
+ );
328
+ if (unreferenced.length > 0) {
329
+ throw new Error(
330
+ `The build produced ${unreferenced
331
+ .map((name) => `"${name}"`)
332
+ .join(', ')} for ${componentName}@${componentVersion}, which no manifest in "${recipePath}" references. Add a manifest whose artifact URI ends in each name, then bump ComponentVersion before re-deploying.`,
333
+ );
334
+ }
335
+
336
+ // Serialized once and measured, so the string the guard sizes is byte for
337
+ // byte the string submitted as `InlineRecipe`.
338
+ const inlineRecipe = yaml.dump(raw, { lineWidth: -1 });
339
+ assertInlineRecipeFits(
340
+ componentName,
341
+ componentVersion,
342
+ recipePath,
343
+ inlineRecipe,
344
+ props.bucket.bucket.bucketName,
345
+ next?.version,
346
+ );
347
+
348
+ this.cfnComponentVersion = new CfnComponentVersion(this, 'Resource', {
349
+ inlineRecipe,
350
+ tags: strategy
351
+ ? {
352
+ [NEXT_VERSION_TAG_KEY]: next!.contentKey,
353
+ [VERSIONING_TAG_KEY]: componentVersion,
354
+ }
355
+ : undefined,
356
+ });
357
+
358
+ // Publish only after the artifact upload completes.
359
+ this.cfnComponentVersion.node.addDependency(artifactUpload);
360
+
361
+ // Apply retention to deletions and recipe-triggered replacements.
362
+ this.cfnComponentVersion.applyRemovalPolicy(removalPolicy);
363
+
364
+ this.componentName = componentName;
365
+ // Reference the published resource so deployments wait for version creation.
366
+ this.componentVersion = next
367
+ ? this.cfnComponentVersion.attrComponentVersion
368
+ : componentVersion;
369
+ }
370
+ }
@@ -0,0 +1,276 @@
1
+ import { Arn, ArnFormat, RemovalPolicy, Stack, Token } from 'aws-cdk-lib';
2
+ import { CfnDeployment } from 'aws-cdk-lib/aws-greengrassv2';
3
+ import { CfnThingGroup } from 'aws-cdk-lib/aws-iot';
4
+ import { Construct } from 'constructs';
5
+ import type { GreengrassComponentVersion } from './component-version<% if (esm) { %>.js<% } %>';
6
+
7
+ /**
8
+ * A component entry; configurationUpdate.merge is JSON-encoded for CloudFormation.
9
+ */
10
+ export interface GreengrassDeploymentComponent {
11
+ readonly componentVersion?: string;
12
+ readonly configurationUpdate?: {
13
+ readonly merge?: Record<string, unknown>;
14
+ readonly reset?: readonly string[];
15
+ };
16
+ readonly runWith?: CfnDeployment.ComponentRunWithProperty;
17
+ }
18
+
19
+ export interface GreengrassDeploymentProps {
20
+ /**
21
+ * Creates a new `AWS::IoT::ThingGroup` and deploys to it. Mutually
22
+ * exclusive with {@link thingName} and {@link targetArn}.
23
+ */
24
+ readonly thingGroupName?: string;
25
+
26
+ /**
27
+ * Deploys to an existing thing, imported by name (not created). Mutually
28
+ * exclusive with {@link thingGroupName} and {@link targetArn}.
29
+ */
30
+ readonly thingName?: string;
31
+
32
+ /**
33
+ * Deploy to an existing target ARN, exclusive with thingGroupName and thingName.
34
+ * Literal partition, account and region values must match the stack.
35
+ */
36
+ readonly targetArn?: string;
37
+
38
+ /** The parent deployment's ARN, for a subdeployment onto a nested group. */
39
+ readonly parentTargetArn?: string;
40
+
41
+ readonly deploymentName?: string;
42
+
43
+ /** Components to deploy, keyed by `ComponentName`. */
44
+ readonly components?: Record<string, GreengrassDeploymentComponent>;
45
+
46
+ readonly deploymentPolicies?: CfnDeployment.DeploymentPoliciesProperty;
47
+
48
+ readonly iotJobConfiguration?: CfnDeployment.DeploymentIoTJobConfigurationProperty;
49
+
50
+ /**
51
+ * Retention for deployment management state; deletion does not uninstall devices.
52
+ * @default RemovalPolicy.RETAIN
53
+ */
54
+ readonly removalPolicy?: RemovalPolicy;
55
+
56
+ readonly tags?: Record<string, string>;
57
+ }
58
+
59
+ const NEXT_VERSION_SENTINELS = ['NEXT_PATCH', 'NEXT_MINOR', 'NEXT_MAJOR'];
60
+
61
+ const toCfnComponents = (
62
+ components: Record<string, GreengrassDeploymentComponent> | undefined,
63
+ ): Record<string, CfnDeployment.ComponentDeploymentSpecificationProperty> | undefined => {
64
+ if (!components) {
65
+ return undefined;
66
+ }
67
+ return Object.fromEntries(
68
+ Object.entries(components).map(([componentName, component]) => {
69
+ return [
70
+ componentName,
71
+ {
72
+ componentVersion: component.componentVersion,
73
+ runWith: component.runWith,
74
+ ...(component.configurationUpdate
75
+ ? {
76
+ configurationUpdate: {
77
+ reset: component.configurationUpdate.reset as
78
+ | string[]
79
+ | undefined,
80
+ ...(component.configurationUpdate.merge
81
+ ? {
82
+ merge: JSON.stringify(
83
+ component.configurationUpdate.merge,
84
+ ),
85
+ }
86
+ : {}),
87
+ },
88
+ }
89
+ : {}),
90
+ },
91
+ ];
92
+ }),
93
+ );
94
+ };
95
+
96
+ /** Throws unless exactly one of the three target props is set. */
97
+ const resolveTargetInput = (
98
+ props: GreengrassDeploymentProps,
99
+ ): 'thingGroupName' | 'thingName' | 'targetArn' => {
100
+ const provided = (
101
+ ['thingGroupName', 'thingName', 'targetArn'] as const
102
+ ).filter((key) => props[key] !== undefined);
103
+ if (provided.length !== 1) {
104
+ throw new Error(
105
+ `GreengrassDeployment requires exactly one of thingGroupName, thingName or targetArn, got: ${
106
+ provided.length === 0 ? '(none)' : provided.join(', ')
107
+ }.`,
108
+ );
109
+ }
110
+ return provided[0];
111
+ };
112
+
113
+ /**
114
+ * Validate literal target ARNs against the stack's known environment values.
115
+ */
116
+ const assertArnMatchesStack = (stack: Stack, arn: string): void => {
117
+ if (Token.isUnresolved(arn)) {
118
+ return;
119
+ }
120
+ const parsed = Arn.split(arn, ArnFormat.SLASH_RESOURCE_NAME);
121
+ const mismatches: string[] = [];
122
+ if (parsed.service !== 'iot') {
123
+ mismatches.push(`service "${parsed.service}" is not "iot"`);
124
+ }
125
+ // `stack.partition` is the `AWS::Partition` pseudo parameter, so it is
126
+ // unresolved even on a stack with an explicit `env` - partition isn't
127
+ // derivable from an account number alone.
128
+ if (
129
+ parsed.partition &&
130
+ !Token.isUnresolved(stack.partition) &&
131
+ parsed.partition !== stack.partition
132
+ ) {
133
+ mismatches.push(
134
+ `partition "${parsed.partition}" does not match stack partition "${stack.partition}"`,
135
+ );
136
+ }
137
+ if (
138
+ parsed.account &&
139
+ !Token.isUnresolved(stack.account) &&
140
+ parsed.account !== stack.account
141
+ ) {
142
+ mismatches.push(
143
+ `account "${parsed.account}" does not match stack account "${stack.account}"`,
144
+ );
145
+ }
146
+ if (
147
+ parsed.region &&
148
+ !Token.isUnresolved(stack.region) &&
149
+ parsed.region !== stack.region
150
+ ) {
151
+ mismatches.push(
152
+ `region "${parsed.region}" does not match stack region "${stack.region}"`,
153
+ );
154
+ }
155
+ if (mismatches.length > 0) {
156
+ throw new Error(
157
+ `targetArn "${arn}" looks like it belongs to a different environment than this stack: ${mismatches.join(', ')}.`,
158
+ );
159
+ }
160
+ };
161
+
162
+ /**
163
+ * Deploy components to one thing or thing group, optionally creating the group.
164
+ */
165
+ export class GreengrassDeployment extends Construct {
166
+ public readonly thingGroup?: CfnThingGroup;
167
+ public readonly targetArn: string;
168
+ public readonly deployment: CfnDeployment;
169
+ private components:
170
+ | Record<string, GreengrassDeploymentComponent>
171
+ | undefined;
172
+ private readonly registeredComponents = new Map<
173
+ string,
174
+ GreengrassComponentVersion
175
+ >();
176
+
177
+ constructor(scope: Construct, id: string, props: GreengrassDeploymentProps) {
178
+ super(scope, id);
179
+
180
+ this.components = props.components
181
+ ? Object.fromEntries(
182
+ Object.entries(props.components).map(([name, component]) => [
183
+ name,
184
+ { ...component },
185
+ ]),
186
+ )
187
+ : undefined;
188
+
189
+ const target = resolveTargetInput(props);
190
+ const stack = Stack.of(this);
191
+
192
+ if (target === 'thingGroupName') {
193
+ this.thingGroup = new CfnThingGroup(this, 'ThingGroup', {
194
+ thingGroupName: props.thingGroupName,
195
+ });
196
+ this.targetArn = this.thingGroup.attrArn;
197
+ } else if (target === 'thingName') {
198
+ this.targetArn = stack.formatArn({
199
+ service: 'iot',
200
+ resource: 'thing',
201
+ resourceName: props.thingName,
202
+ });
203
+ } else {
204
+ assertArnMatchesStack(stack, props.targetArn!);
205
+ this.targetArn = props.targetArn!;
206
+ }
207
+
208
+ this.deployment = new CfnDeployment(this, 'Resource', {
209
+ targetArn: this.targetArn,
210
+ deploymentName: props.deploymentName,
211
+ components: this.renderComponents(),
212
+ deploymentPolicies: props.deploymentPolicies,
213
+ iotJobConfiguration: props.iotJobConfiguration,
214
+ parentTargetArn: props.parentTargetArn,
215
+ tags: props.tags,
216
+ });
217
+
218
+ this.deployment.applyRemovalPolicy(props.removalPolicy ?? RemovalPolicy.RETAIN);
219
+
220
+ this.node.addValidation({
221
+ validate: () =>
222
+ Object.entries(this.components ?? {}).flatMap(
223
+ ([componentName, component]) => {
224
+ const componentVersion = component.componentVersion;
225
+ if (
226
+ componentVersion === undefined ||
227
+ Token.isUnresolved(componentVersion) ||
228
+ !NEXT_VERSION_SENTINELS.includes(componentVersion)
229
+ ) {
230
+ return [];
231
+ }
232
+ return [
233
+ `Component "${componentName}" has sentinel version "${componentVersion}". ` +
234
+ `Register its component construct with deployment.addComponent(myComponent).`,
235
+ ];
236
+ },
237
+ ),
238
+ });
239
+ }
240
+
241
+ private renderComponents():
242
+ | Record<string, CfnDeployment.ComponentDeploymentSpecificationProperty>
243
+ | undefined {
244
+ return toCfnComponents(this.components);
245
+ }
246
+
247
+ /**
248
+ * Register a component version and wait for its publication before deployment.
249
+ * Raw versions created in this stack need an explicit dependency.
250
+ */
251
+ public addComponent(
252
+ component: GreengrassComponentVersion,
253
+ overrides?: Omit<GreengrassDeploymentComponent, 'componentVersion'>,
254
+ ): this {
255
+ const existing = this.registeredComponents.get(component.componentName);
256
+ if (existing && existing !== component) {
257
+ throw new Error(
258
+ `Component "${component.componentName}" is already registered with a different GreengrassComponentVersion.`,
259
+ );
260
+ }
261
+
262
+ this.components ??= {};
263
+ // An initial raw entry keeps its configuration and runWith; an override
264
+ // replaces that top-level field. The construct's version comes last, so
265
+ // an untyped caller cannot substitute one through `overrides`.
266
+ this.components[component.componentName] = {
267
+ ...this.components[component.componentName],
268
+ ...overrides,
269
+ componentVersion: component.componentVersion,
270
+ };
271
+ this.registeredComponents.set(component.componentName, component);
272
+ this.deployment.components = this.renderComponents();
273
+ this.deployment.node.addDependency(component);
274
+ return this;
275
+ }
276
+ }
@@ -0,0 +1,5 @@
1
+ export * from './artifact-bucket<% if (esm) { %>.js<% } %>';
2
+ export * from './component-version<% if (esm) { %>.js<% } %>';
3
+ export * from './deployment<% if (esm) { %>.js<% } %>';
4
+ export * from './next-version<% if (esm) { %>.js<% } %>';
5
+ export * from './recipe<% if (esm) { %>.js<% } %>';