@aws/nx-plugin 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -29
- package/generators.json +8 -0
- package/migrations.json +6 -1
- package/package.json +1 -1
- package/src/agentcore-harness/generator.d.ts +1 -1
- package/src/greengrass/component/common.d.ts +60 -0
- package/src/greengrass/component/common.js +191 -0
- package/src/greengrass/component/common.js.map +1 -0
- package/src/greengrass/component/dependencies.d.ts +101 -0
- package/src/greengrass/component/dependencies.js +37 -0
- package/src/greengrass/component/dependencies.js.map +1 -0
- package/src/greengrass/component/generator.d.ts +174 -0
- package/src/greengrass/component/generator.js +32 -0
- package/src/greengrass/component/generator.js.map +1 -0
- package/src/greengrass/component/metadata.d.ts +2 -0
- package/src/greengrass/component/metadata.js +14 -0
- package/src/greengrass/component/metadata.js.map +1 -0
- package/src/greengrass/component/python/__snapshots__/generator.spec.ts.snap +450 -0
- package/src/greengrass/component/python/files/component/main.py.template +60 -0
- package/src/greengrass/component/python/files/component/recipe.yaml.template +30 -0
- package/src/greengrass/component/python/files/gdk/gdk-config.json.template +13 -0
- package/src/greengrass/component/python/files/tests/test___componentSnakeCase__.py.template +37 -0
- package/src/greengrass/component/python/generator.d.ts +15 -0
- package/src/greengrass/component/python/generator.js +192 -0
- package/src/greengrass/component/python/generator.js.map +1 -0
- package/src/greengrass/component/schema.d.js +6 -0
- package/src/greengrass/component/schema.d.js.map +1 -0
- package/src/greengrass/component/schema.d.ts +18 -0
- package/src/greengrass/component/schema.json +82 -0
- package/src/greengrass/component/typescript/__snapshots__/generator.spec.ts.snap +275 -0
- package/src/greengrass/component/typescript/files/component/recipe.yaml.template +41 -0
- package/src/greengrass/component/typescript/files/entrypoint/main.ts.template +58 -0
- package/src/greengrass/component/typescript/files/gdk/gdk-config.json.template +13 -0
- package/src/greengrass/component/typescript/files/scripts/vendor-crt-addon.ts.template +115 -0
- package/src/greengrass/component/typescript/generator.d.ts +16 -0
- package/src/greengrass/component/typescript/generator.js +194 -0
- package/src/greengrass/component/typescript/generator.js.map +1 -0
- package/src/infra/app/generator.d.ts +1 -1
- package/src/init/generator.d.ts +1 -1
- package/src/internal/test-matrix/generator.js +42 -0
- package/src/internal/test-matrix/generator.js.map +1 -1
- package/src/license/dependency-check/pre-approved.js +2 -1
- package/src/license/dependency-check/pre-approved.js.map +1 -1
- package/src/migrations/latest/ts-agent-guardrails-synth-input/metadata.json +3 -0
- package/src/migrations/latest/ts-agent-guardrails-synth-input/migration.d.ts +22 -0
- package/src/migrations/latest/ts-agent-guardrails-synth-input/migration.js +42 -0
- package/src/migrations/latest/ts-agent-guardrails-synth-input/migration.js.map +1 -0
- package/src/open-api/py-client/generator.d.ts +1 -1
- package/src/preset/__snapshots__/generator.spec.ts.snap +2 -0
- package/src/preset/generator.d.ts +1 -1
- package/src/py/agent/a2a-connection/generator.d.ts +1 -1
- package/src/py/agent/gateway-connection/generator.d.ts +1 -1
- package/src/py/agent/mcp-connection/generator.d.ts +1 -1
- package/src/py/agent/react-connection/generator.d.ts +1 -1
- package/src/py/fast-api/react/generator.d.ts +1 -1
- package/src/py/project/generator.js +2 -0
- package/src/py/project/generator.js.map +1 -1
- package/src/sdk/greengrass.d.ts +6 -0
- package/src/sdk/greengrass.js +6 -0
- package/src/sdk/greengrass.js.map +1 -0
- package/src/smithy/project/generator.d.ts +1 -1
- package/src/smithy/react-connection/generator.d.ts +1 -1
- package/src/terraform/project/generator.d.ts +1 -1
- package/src/trpc/react/generator.d.ts +1 -1
- package/src/ts/agent/a2a-connection/generator.d.ts +1 -1
- package/src/ts/agent/gateway-connection/generator.d.ts +1 -1
- package/src/ts/agent/guardrails/generator.js +10 -1
- package/src/ts/agent/guardrails/generator.js.map +1 -1
- package/src/ts/agent/guardrails/synth-inputs.d.ts +19 -0
- package/src/ts/agent/guardrails/synth-inputs.js +50 -0
- package/src/ts/agent/guardrails/synth-inputs.js.map +1 -0
- package/src/ts/agent/mcp-connection/generator.d.ts +1 -1
- package/src/ts/agent/react-connection/generator.d.ts +1 -1
- package/src/ts/astro-docs/generator.d.ts +1 -1
- package/src/ts/dcr-proxy/generator.d.ts +1 -1
- package/src/ts/dynamodb/generator.d.ts +1 -1
- package/src/ts/lambda-function/generator.d.ts +1 -1
- package/src/ts/lib/generator.d.ts +1 -1
- package/src/ts/nx-generator/generator.d.ts +1 -1
- package/src/ts/nx-migration/generator.d.ts +1 -1
- package/src/ts/nx-plugin/generator.d.ts +1 -1
- package/src/ts/rdb/generator.d.ts +1 -1
- package/src/ts/react-website/agui/generator.d.ts +1 -1
- package/src/utils/__snapshots__/shared-greengrass-scripts.spec.ts.snap +1279 -0
- package/src/utils/files/common/scripts/src/greengrass/build-artifact.ts.template +236 -0
- package/src/utils/files/common/scripts/src/greengrass/component-logs.ts.template +48 -0
- package/src/utils/files/common/scripts/src/greengrass/deploy-local.ts.template +497 -0
- package/src/utils/files/common/scripts/src/greengrass/recipe-utils.ts.template +356 -0
- package/src/utils/files/common/scripts/src/greengrass/zip-writer.ts.template +121 -0
- package/src/utils/files/terraform/src/core/runtime-config/read/read.tf.template +3 -3
- package/src/utils/files/terraform/src/metrics/metrics.tf.template +1 -1
- package/src/utils/greengrass/constants.d.ts +58 -0
- package/src/utils/greengrass/constants.js +63 -0
- package/src/utils/greengrass/constants.js.map +1 -0
- package/src/utils/greengrass/naming.d.ts +46 -0
- package/src/utils/greengrass/naming.js +67 -0
- package/src/utils/greengrass/naming.js.map +1 -0
- package/src/utils/greengrass/recipe.d.ts +143 -0
- package/src/utils/greengrass/recipe.js +119 -0
- package/src/utils/greengrass/recipe.js.map +1 -0
- package/src/utils/greengrass-constructs/files/cdk/app/greengrass-component/__componentDirName__.ts.template +42 -0
- package/src/utils/greengrass-constructs/files/cdk/core/greengrass/artifact-bucket.ts.template +82 -0
- package/src/utils/greengrass-constructs/files/cdk/core/greengrass/component-version.ts.template +370 -0
- package/src/utils/greengrass-constructs/files/cdk/core/greengrass/deployment.ts.template +276 -0
- package/src/utils/greengrass-constructs/files/cdk/core/greengrass/index.ts.template +5 -0
- package/src/utils/greengrass-constructs/files/cdk/core/greengrass/next-version.ts.template +119 -0
- package/src/utils/greengrass-constructs/files/cdk/core/greengrass/recipe.ts.template +90 -0
- package/src/utils/greengrass-constructs/files/next-version/__nextVersionFileName__.template +195 -0
- package/src/utils/greengrass-constructs/files/terraform/app/greengrass-component/__componentDirName__/__componentDirName__.tf.template +26 -0
- package/src/utils/greengrass-constructs/files/terraform/core/greengrass/artifact-bucket/main.tf.template +204 -0
- package/src/utils/greengrass-constructs/files/terraform/core/greengrass/component-version/main.tf.template +156 -0
- package/src/utils/greengrass-constructs/files/terraform/core/greengrass/deployment/main.tf.template +186 -0
- package/src/utils/greengrass-constructs/greengrass-constructs.d.ts +57 -0
- package/src/utils/greengrass-constructs/greengrass-constructs.js +154 -0
- package/src/utils/greengrass-constructs/greengrass-constructs.js.map +1 -0
- package/src/utils/guardrail-constructs/guardrail-constructs.d.ts +3 -1
- package/src/utils/guardrail-constructs/guardrail-constructs.js +3 -1
- package/src/utils/guardrail-constructs/guardrail-constructs.js.map +1 -1
- package/src/utils/nx.d.ts +15 -0
- package/src/utils/nx.js +32 -0
- package/src/utils/nx.js.map +1 -1
- package/src/utils/shared-greengrass-scripts.d.ts +21 -0
- package/src/utils/shared-greengrass-scripts.js +43 -0
- package/src/utils/shared-greengrass-scripts.js.map +1 -0
- package/src/utils/versions.d.ts +7 -0
- package/src/utils/versions.js +7 -0
- package/src/utils/versions.js.map +1 -1
|
@@ -0,0 +1,1279 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`sharedGreengrassScriptsGenerator > should vend the greengrass scripts into packages/common/scripts > packages/common/scripts/src/greengrass/build-artifact.ts 1`] = `
|
|
4
|
+
"import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join, relative } from 'node:path';
|
|
6
|
+
import yaml from 'js-yaml';
|
|
7
|
+
import { loadRecipe, validateManifestArchitectures, validateRecipe } from './recipe-utils.js';
|
|
8
|
+
import { writeZip, type ZipEntry } from './zip-writer.js';
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* Usage: build-artifact.ts <project-root> <component-dir> <dist> [bundle-dir] [--platforms=<arch>,<arch>]
|
|
12
|
+
*
|
|
13
|
+
* <project-root> path to the host project (e.g. a Python project root)
|
|
14
|
+
* <component-dir> the component's directory name under <project-root>/greengrass/,
|
|
15
|
+
* which also becomes the artifact zip's base name
|
|
16
|
+
* <dist> the component's dist root: reads <dist>/vendor (produced by
|
|
17
|
+
* the "-vendor" target this target depends on) and writes
|
|
18
|
+
* <dist>/greengrass-build/{recipes,artifacts}
|
|
19
|
+
* <bundle-dir> optional: a directory of already-bundled deployable code
|
|
20
|
+
* (e.g. a TypeScript rolldown bundle output). When given and
|
|
21
|
+
* it exists, its contents are zipped at the archive root
|
|
22
|
+
* INSTEAD of <project-root>/greengrass/<component-dir>/ and
|
|
23
|
+
* <dist>/vendor - a compiled bundle is the deployable
|
|
24
|
+
* content, so the TypeScript source that produced it must
|
|
25
|
+
* not also ship. <project-root>/greengrass/<component-dir>/package.json,
|
|
26
|
+
* when present, is included too (the minimal manifest an
|
|
27
|
+
* Install lifecycle step's \`npm install\` needs for a native
|
|
28
|
+
* dependency the bundle marked external).
|
|
29
|
+
* --platforms optional: a comma-separated list of manifest architecture
|
|
30
|
+
* tokens (e.g. "amd64,aarch64"). When given, <dist>/vendor/<arch>
|
|
31
|
+
* is read per architecture and one <component-dir>-<arch>.zip
|
|
32
|
+
* is written per architecture, and every recipe manifest
|
|
33
|
+
* declaring one of these architectures must pair with that
|
|
34
|
+
* architecture's own zip. Cannot be combined with
|
|
35
|
+
* <bundle-dir>. Without it, behaviour is exactly as before:
|
|
36
|
+
* flat <dist>/vendor, one <component-dir>.zip.
|
|
37
|
+
*
|
|
38
|
+
* Without <bundle-dir>, everything under <project-root>/greengrass/<component-dir>/
|
|
39
|
+
* is packaged except recipe.yaml and gdk-config.json, which are framework
|
|
40
|
+
* metadata, not deployed code, and __pycache__ and .lock, which are tool
|
|
41
|
+
* staging output.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const log = (msg: string): void => console.error(\`[build-artifact] \${msg}\`);
|
|
45
|
+
|
|
46
|
+
function usage(): never {
|
|
47
|
+
log(
|
|
48
|
+
'Usage: build-artifact.ts <project-root> <component-dir> <dist> [bundle-dir] [--platforms=<arch>,<arch>]',
|
|
49
|
+
);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every file under \`dir\`, recursively, excluding top-level names in \`exclude\`
|
|
55
|
+
* and \`__pycache__\` directories at any depth.
|
|
56
|
+
*/
|
|
57
|
+
function collectFiles(dir: string, exclude: ReadonlySet<string>): string[] {
|
|
58
|
+
const results: string[] = [];
|
|
59
|
+
const walk = (current: string, isRoot: boolean): void => {
|
|
60
|
+
for (const entry of readdirSync(current)) {
|
|
61
|
+
if (entry === '__pycache__' || (isRoot && exclude.has(entry))) continue;
|
|
62
|
+
const fullPath = join(current, entry);
|
|
63
|
+
if (statSync(fullPath).isDirectory()) {
|
|
64
|
+
walk(fullPath, false);
|
|
65
|
+
} else {
|
|
66
|
+
results.push(fullPath);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
walk(dir, true);
|
|
71
|
+
return results;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Zip entries for every file under \`dir\`, named relative to \`dir\` with forward slashes. */
|
|
75
|
+
function entriesFrom(dir: string, exclude: ReadonlySet<string>): ZipEntry[] {
|
|
76
|
+
return collectFiles(dir, exclude).map((filePath) => ({
|
|
77
|
+
name: relative(dir, filePath).split('\\\\').join('/'),
|
|
78
|
+
data: readFileSync(filePath),
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function main(): void {
|
|
83
|
+
const argv = process.argv.slice(2);
|
|
84
|
+
const PLATFORMS_FLAG = '--platforms=';
|
|
85
|
+
const platforms = argv
|
|
86
|
+
.filter((a) => a.startsWith(PLATFORMS_FLAG))
|
|
87
|
+
.flatMap((a) => a.slice(PLATFORMS_FLAG.length).split(','))
|
|
88
|
+
.map((a) => a.trim())
|
|
89
|
+
.filter((a) => a.length > 0);
|
|
90
|
+
const [projectRoot, componentDirName, dist, bundleDir] = argv.filter(
|
|
91
|
+
(a) => !a.startsWith(PLATFORMS_FLAG),
|
|
92
|
+
);
|
|
93
|
+
if (!projectRoot || !componentDirName || !dist) {
|
|
94
|
+
usage();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (platforms.length > 0 && bundleDir) {
|
|
98
|
+
log(
|
|
99
|
+
'--platforms cannot be combined with a bundle-dir: per-architecture copies of one bundle would all hold identical bytes. Drop --platforms and serve both architectures from a single artifact with two manifests.',
|
|
100
|
+
);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const componentDir = join(projectRoot, 'greengrass', componentDirName);
|
|
106
|
+
const recipePath = join(componentDir, 'recipe.yaml');
|
|
107
|
+
const baseNameByArchitecture = Object.fromEntries(
|
|
108
|
+
platforms.map((arch) => [arch, \`\${componentDirName}-\${arch}\`]),
|
|
109
|
+
);
|
|
110
|
+
const artifactBaseNames =
|
|
111
|
+
platforms.length === 0
|
|
112
|
+
? [componentDirName]
|
|
113
|
+
: platforms.map((arch) => baseNameByArchitecture[arch]);
|
|
114
|
+
|
|
115
|
+
let componentName: string;
|
|
116
|
+
let componentVersion: string;
|
|
117
|
+
let recipe: ReturnType<typeof loadRecipe>;
|
|
118
|
+
try {
|
|
119
|
+
recipe = loadRecipe(recipePath);
|
|
120
|
+
({ componentName, componentVersion } = validateRecipe(
|
|
121
|
+
recipe,
|
|
122
|
+
artifactBaseNames,
|
|
123
|
+
));
|
|
124
|
+
// Only meaningful per architecture, so only checked when this build knows
|
|
125
|
+
// which architecture each zip is for.
|
|
126
|
+
if (platforms.length > 0) {
|
|
127
|
+
validateManifestArchitectures(recipe, baseNameByArchitecture);
|
|
128
|
+
}
|
|
129
|
+
} catch (e) {
|
|
130
|
+
log((e as Error).message);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const vendorDir = join(dist, 'vendor');
|
|
136
|
+
// Recipe and artifact paths are named by version, so a rebuild after a
|
|
137
|
+
// version bump would otherwise accumulate stale siblings - and the
|
|
138
|
+
// GreengrassComponentVersion construct requires exactly one resolved
|
|
139
|
+
// recipe. Start from an empty greengrass-build every run.
|
|
140
|
+
rmSync(join(dist, 'greengrass-build'), { recursive: true, force: true });
|
|
141
|
+
const artifactDir = join(
|
|
142
|
+
dist,
|
|
143
|
+
'greengrass-build',
|
|
144
|
+
'artifacts',
|
|
145
|
+
componentName,
|
|
146
|
+
componentVersion,
|
|
147
|
+
);
|
|
148
|
+
const recipesDir = join(dist, 'greengrass-build', 'recipes');
|
|
149
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
150
|
+
mkdirSync(recipesDir, { recursive: true });
|
|
151
|
+
|
|
152
|
+
if (platforms.length > 0) {
|
|
153
|
+
// Multi-architecture: one zip per architecture, each holding the
|
|
154
|
+
// component's own files plus that architecture's own vendored wheels.
|
|
155
|
+
for (const arch of platforms) {
|
|
156
|
+
const archVendorDir = join(vendorDir, arch);
|
|
157
|
+
if (!existsSync(archVendorDir)) {
|
|
158
|
+
log(
|
|
159
|
+
\`Missing vendored dependencies for "\${arch}" at "\${archVendorDir}" - run the component's "-vendor" target first (it writes one directory per architecture when the component targets more than one).\`,
|
|
160
|
+
);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const entries = [
|
|
165
|
+
...entriesFrom(
|
|
166
|
+
componentDir,
|
|
167
|
+
new Set(['recipe.yaml', 'gdk-config.json', '.lock']),
|
|
168
|
+
),
|
|
169
|
+
...entriesFrom(archVendorDir, new Set(['.lock'])),
|
|
170
|
+
];
|
|
171
|
+
if (entries.length === 0) {
|
|
172
|
+
log(
|
|
173
|
+
\`Nothing to package for "\${arch}": "\${componentDir}" has no deployable files and "\${archVendorDir}" has no vendored dependencies\`,
|
|
174
|
+
);
|
|
175
|
+
process.exit(1);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const zipPath = join(artifactDir, \`\${componentDirName}-\${arch}.zip\`);
|
|
179
|
+
writeZip(entries, zipPath);
|
|
180
|
+
log(\`wrote \${zipPath}\`);
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
let entries: ZipEntry[];
|
|
184
|
+
if (bundleDir && existsSync(bundleDir)) {
|
|
185
|
+
// A pre-bundled language (e.g. TypeScript via rolldown): the bundle
|
|
186
|
+
// output is the deployable content, so the source that produced it is
|
|
187
|
+
// never packaged.
|
|
188
|
+
entries = entriesFrom(bundleDir, new Set(['.lock']));
|
|
189
|
+
const packageJsonPath = join(componentDir, 'package.json');
|
|
190
|
+
if (existsSync(packageJsonPath)) {
|
|
191
|
+
// Ships alongside the bundle only when an Install lifecycle step needs
|
|
192
|
+
// it (e.g. \`npm install\` for a native dependency the bundle marked
|
|
193
|
+
// external) - absent for a fully self-contained bundle.
|
|
194
|
+
entries.push({
|
|
195
|
+
name: 'package.json',
|
|
196
|
+
data: readFileSync(packageJsonPath),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
// Framework metadata that ships alongside the recipe but never inside
|
|
201
|
+
// the deployed artifact. A package.json here is the user's own, so it is
|
|
202
|
+
// packaged like any other source file - only bundle mode treats one as
|
|
203
|
+
// framework metadata.
|
|
204
|
+
entries = entriesFrom(
|
|
205
|
+
componentDir,
|
|
206
|
+
new Set(['recipe.yaml', 'gdk-config.json', '.lock']),
|
|
207
|
+
);
|
|
208
|
+
if (existsSync(vendorDir)) {
|
|
209
|
+
// requirements.txt is the "-vendor" target's own intermediate, not
|
|
210
|
+
// something the device needs.
|
|
211
|
+
entries.push(
|
|
212
|
+
...entriesFrom(vendorDir, new Set(['requirements.txt', '.lock'])),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (entries.length === 0) {
|
|
217
|
+
log(
|
|
218
|
+
\`Nothing to package: "\${bundleDir && existsSync(bundleDir) ? bundleDir : componentDir}" has no deployable files and "\${vendorDir}" does not exist\`,
|
|
219
|
+
);
|
|
220
|
+
process.exit(1);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const zipPath = join(artifactDir, \`\${componentDirName}.zip\`);
|
|
225
|
+
writeZip(entries, zipPath);
|
|
226
|
+
log(\`wrote \${zipPath}\`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The BUCKET_NAME/COMPONENT_NAME/COMPONENT_VERSION artifact URI placeholders
|
|
230
|
+
// are left exactly as authored - a later IaC step substitutes them.
|
|
231
|
+
const recipeOutPath = join(
|
|
232
|
+
recipesDir,
|
|
233
|
+
\`\${componentName}-\${componentVersion}.yaml\`,
|
|
234
|
+
);
|
|
235
|
+
writeFileSync(recipeOutPath, yaml.dump(recipe, { lineWidth: -1 }));
|
|
236
|
+
log(\`wrote \${recipeOutPath}\`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
main();
|
|
240
|
+
"
|
|
241
|
+
`;
|
|
242
|
+
|
|
243
|
+
exports[`sharedGreengrassScriptsGenerator > should vend the greengrass scripts into packages/common/scripts > packages/common/scripts/src/greengrass/component-logs.ts 1`] = `
|
|
244
|
+
"import { spawn } from 'node:child_process';
|
|
245
|
+
import { join } from 'node:path';
|
|
246
|
+
|
|
247
|
+
/*
|
|
248
|
+
* Usage: component-logs.ts <component-name>
|
|
249
|
+
*
|
|
250
|
+
* <component-name> the recipe's ComponentName, e.g. "com.example.MyComponent"
|
|
251
|
+
*
|
|
252
|
+
* Tails <GREENGRASS_ROOT>/logs/<component-name>.log in follow mode. Ctrl-C
|
|
253
|
+
* (SIGINT) stops it - this target never exits on its own.
|
|
254
|
+
*/
|
|
255
|
+
|
|
256
|
+
const log = (msg: string): void => console.error(\`[component-logs] \${msg}\`);
|
|
257
|
+
|
|
258
|
+
function usage(): never {
|
|
259
|
+
log('Usage: component-logs.ts <component-name>');
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function main(): void {
|
|
264
|
+
const [componentName] = process.argv.slice(2);
|
|
265
|
+
if (!componentName) {
|
|
266
|
+
usage();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const greengrassRoot = process.env.GREENGRASS_ROOT || '/greengrass/v2';
|
|
270
|
+
const logPath = join(greengrassRoot, 'logs', \`\${componentName}.log\`);
|
|
271
|
+
|
|
272
|
+
log(\`Tailing \${logPath} - Ctrl-C to stop\`);
|
|
273
|
+
const tail = spawn('tail', ['-n', '200', '-f', logPath], {
|
|
274
|
+
stdio: 'inherit',
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const stop = (): void => {
|
|
278
|
+
tail.kill();
|
|
279
|
+
process.exit(0);
|
|
280
|
+
};
|
|
281
|
+
process.on('SIGINT', stop);
|
|
282
|
+
process.on('SIGTERM', stop);
|
|
283
|
+
|
|
284
|
+
tail.on('exit', (code) => process.exit(code ?? 0));
|
|
285
|
+
tail.on('error', (err) => {
|
|
286
|
+
log(\`Failed to start tail: \${err.message}\`);
|
|
287
|
+
process.exit(1);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
main();
|
|
292
|
+
"
|
|
293
|
+
`;
|
|
294
|
+
|
|
295
|
+
exports[`sharedGreengrassScriptsGenerator > should vend the greengrass scripts into packages/common/scripts > packages/common/scripts/src/greengrass/deploy-local.ts 1`] = `
|
|
296
|
+
"import { type SpawnSyncReturns, spawnSync } from 'node:child_process';
|
|
297
|
+
import {
|
|
298
|
+
closeSync,
|
|
299
|
+
cpSync,
|
|
300
|
+
fstatSync,
|
|
301
|
+
mkdirSync,
|
|
302
|
+
openSync,
|
|
303
|
+
readSync,
|
|
304
|
+
readdirSync,
|
|
305
|
+
rmSync,
|
|
306
|
+
writeFileSync,
|
|
307
|
+
} from 'node:fs';
|
|
308
|
+
import { join } from 'node:path';
|
|
309
|
+
import yaml from 'js-yaml';
|
|
310
|
+
import {
|
|
311
|
+
isNextVersionSentinel,
|
|
312
|
+
readResolvedRecipe,
|
|
313
|
+
resolveLocalNextVersion,
|
|
314
|
+
} from './recipe-utils.js';
|
|
315
|
+
|
|
316
|
+
/*
|
|
317
|
+
* Usage: deploy-local.ts <dist>
|
|
318
|
+
*
|
|
319
|
+
* <dist> the component's dist root written by the "-artifact" target. Reads
|
|
320
|
+
* <dist>/greengrass-build/{recipes,artifacts}. A NEXT_* recipe stages
|
|
321
|
+
* the resolved recipe and artifact under greengrass-build/local/.
|
|
322
|
+
*
|
|
323
|
+
* Creates one local deployment via greengrass-cli, then polls the component's
|
|
324
|
+
* own lifecycle state until it settles. A deployment the nucleus reports as
|
|
325
|
+
* SUCCEEDED is not evidence the component runs, so this exits non-zero when the
|
|
326
|
+
* component ends up BROKEN or ERRORED, or never settles inside the timeout -
|
|
327
|
+
* printing the state it reached and the tail of its log.
|
|
328
|
+
*
|
|
329
|
+
* GREENGRASS_ROOT overrides the /greengrass/v2 nucleus root, and
|
|
330
|
+
* GREENGRASS_DEPLOY_TIMEOUT_SECONDS the 120 second poll bound.
|
|
331
|
+
*
|
|
332
|
+
* A NEXT_* ComponentVersion resolves on the device, not against the cloud
|
|
333
|
+
* registry: the next version above the highest of the installed version and
|
|
334
|
+
* the version directories the nucleus keeps under
|
|
335
|
+
* <GREENGRASS_ROOT>/packages/{artifacts,artifacts-unarchived}/<ComponentName>/.
|
|
336
|
+
*
|
|
337
|
+
* Re-deploying a concrete ComponentVersion already RUNNING on this device can
|
|
338
|
+
* read the outgoing instance's state, so bump that version in recipe.yaml to
|
|
339
|
+
* keep this check meaningful. A NEXT_* version resolves a new local version,
|
|
340
|
+
* so this warning does not apply to it.
|
|
341
|
+
*/
|
|
342
|
+
|
|
343
|
+
const log = (msg: string): void => console.error(\`[deploy-local] \${msg}\`);
|
|
344
|
+
|
|
345
|
+
const POLL_INTERVAL_MS = 2000;
|
|
346
|
+
const DEFAULT_TIMEOUT_SECONDS = 120;
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* How many consecutive checks a running state must survive before this script
|
|
350
|
+
* calls the deployment good. The nucleus reports RUNNING the moment it starts
|
|
351
|
+
* the Run lifecycle, so a component that dies seconds later - the usual shape
|
|
352
|
+
* of a missing dependency or a failed IPC connect - is RUNNING at the first
|
|
353
|
+
* check and BROKEN by the third. One check is not evidence.
|
|
354
|
+
*/
|
|
355
|
+
const REQUIRED_STABLE_CHECKS = 3;
|
|
356
|
+
|
|
357
|
+
const LOG_TAIL_LINES = 40;
|
|
358
|
+
/** Read from the END of the log file, so a large log cannot exhaust memory. */
|
|
359
|
+
const LOG_TAIL_BYTES = 64 * 1024;
|
|
360
|
+
/** Room for \`component list\` on a device carrying many components. */
|
|
361
|
+
const CLI_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* A component that started. FINISHED counts: a one-shot component that ran to
|
|
365
|
+
* completion is a success, and the nucleus never moves it to RUNNING. It is
|
|
366
|
+
* reported explicitly, because a generated component is not one-shot.
|
|
367
|
+
*/
|
|
368
|
+
const SETTLED_RUNNING_STATES = ['RUNNING', 'FINISHED'];
|
|
369
|
+
/** BROKEN follows three failed restarts, ERRORED a single failed one. */
|
|
370
|
+
const SETTLED_FAILED_STATES = ['BROKEN', 'ERRORED'];
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Log lines a component leaves when the device's \`sudo\` is sudo-rs, which
|
|
374
|
+
* accepts the nucleus's \`sudo -E\` and then discards the environment.
|
|
375
|
+
*/
|
|
376
|
+
const SUDO_RS_LOG_SIGNATURES = [
|
|
377
|
+
'AWS_GG_NUCLEUS_DOMAIN_SOCKET_FILEPATH_FOR_COMPONENT',
|
|
378
|
+
'preserving the entire environment is not supported',
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
interface ComponentStatus {
|
|
382
|
+
version?: string;
|
|
383
|
+
state?: string;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function usage(): never {
|
|
387
|
+
log('Usage: deploy-local.ts <dist>');
|
|
388
|
+
process.exit(1);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const sleep = (ms: number): Promise<void> =>
|
|
392
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
393
|
+
|
|
394
|
+
/** Seconds to wait for the component to settle, from the environment. */
|
|
395
|
+
const resolveTimeoutSeconds = (): number => {
|
|
396
|
+
const raw = process.env.GREENGRASS_DEPLOY_TIMEOUT_SECONDS;
|
|
397
|
+
if (raw === undefined || raw.trim() === '') {
|
|
398
|
+
return DEFAULT_TIMEOUT_SECONDS;
|
|
399
|
+
}
|
|
400
|
+
const parsed = Number(raw);
|
|
401
|
+
// Rejects Infinity (which would poll forever) and anything <= 0 (which would
|
|
402
|
+
// skip polling and report a timeout the deployment never had).
|
|
403
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
404
|
+
log(
|
|
405
|
+
\`Ignoring GREENGRASS_DEPLOY_TIMEOUT_SECONDS="\${raw}": it must be a \` +
|
|
406
|
+
\`finite number of seconds greater than 0. Waiting \` +
|
|
407
|
+
\`\${DEFAULT_TIMEOUT_SECONDS}s instead.\`,
|
|
408
|
+
);
|
|
409
|
+
return DEFAULT_TIMEOUT_SECONDS;
|
|
410
|
+
}
|
|
411
|
+
return parsed;
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Runs greengrass-cli, bounded: a wedged nucleus must not outlive the caller's
|
|
416
|
+
* deadline, and its output must not overflow the default 1 MB pipe buffer.
|
|
417
|
+
*/
|
|
418
|
+
const runCli = (
|
|
419
|
+
cli: string,
|
|
420
|
+
args: string[],
|
|
421
|
+
timeoutMs: number,
|
|
422
|
+
): SpawnSyncReturns<string> =>
|
|
423
|
+
spawnSync(cli, args, {
|
|
424
|
+
encoding: 'utf-8',
|
|
425
|
+
timeout: Math.max(1000, Math.trunc(timeoutMs)),
|
|
426
|
+
maxBuffer: CLI_MAX_BUFFER_BYTES,
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Pulls one component's Version and State out of \`greengrass-cli component
|
|
431
|
+
* list\` output. Deliberately tolerant of indentation and of extra fields: the
|
|
432
|
+
* CLI's report is human-facing text with no documented layout, so anything
|
|
433
|
+
* unrecognised is skipped rather than parsed. First value wins for each field,
|
|
434
|
+
* so a later \`State:\` line inside the same block's serialized Configuration
|
|
435
|
+
* cannot overwrite the component's own state.
|
|
436
|
+
*/
|
|
437
|
+
const parseComponentStatus = (
|
|
438
|
+
output: string,
|
|
439
|
+
componentName: string,
|
|
440
|
+
): ComponentStatus | undefined => {
|
|
441
|
+
let found: ComponentStatus | undefined;
|
|
442
|
+
let inTargetBlock = false;
|
|
443
|
+
for (const line of output.split('\\n')) {
|
|
444
|
+
const name = /^\\s*Component Name:\\s*(\\S+)\\s*$/.exec(line);
|
|
445
|
+
if (name) {
|
|
446
|
+
inTargetBlock = name[1] === componentName;
|
|
447
|
+
if (inTargetBlock) {
|
|
448
|
+
found = found ?? {};
|
|
449
|
+
}
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (!inTargetBlock || !found) {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const version = /^\\s*Version:\\s*(\\S+)\\s*$/.exec(line);
|
|
456
|
+
if (version && found.version === undefined) {
|
|
457
|
+
found.version = version[1];
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
const state = /^\\s*State:\\s*(\\S+)\\s*$/.exec(line);
|
|
461
|
+
if (state && found.state === undefined) {
|
|
462
|
+
found.state = state[1];
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return found;
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
/** The nucleus package stores that keep one directory per component version. */
|
|
469
|
+
const LOCAL_VERSION_STORES = ['artifacts', 'artifacts-unarchived'];
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Versions this device has held for the component: the directory names under
|
|
473
|
+
* <GREENGRASS_ROOT>/packages/{artifacts,artifacts-unarchived}/<ComponentName>/.
|
|
474
|
+
* The recipe store (packages/recipes) is not read - it is root-only and names
|
|
475
|
+
* its files by a hashed component name. The nucleus garbage-collects unused
|
|
476
|
+
* versions, so this history is best-effort; the caller always counts the
|
|
477
|
+
* installed version too. A store that is missing or unreadable is skipped with
|
|
478
|
+
* one log line.
|
|
479
|
+
*/
|
|
480
|
+
const readLocalVersionHistory = (
|
|
481
|
+
greengrassRoot: string,
|
|
482
|
+
componentName: string,
|
|
483
|
+
): string[] => {
|
|
484
|
+
const versions: string[] = [];
|
|
485
|
+
for (const store of LOCAL_VERSION_STORES) {
|
|
486
|
+
const dir = join(greengrassRoot, 'packages', store, componentName);
|
|
487
|
+
try {
|
|
488
|
+
versions.push(
|
|
489
|
+
...readdirSync(dir, { withFileTypes: true })
|
|
490
|
+
.filter((entry) => entry.isDirectory())
|
|
491
|
+
.map((entry) => entry.name),
|
|
492
|
+
);
|
|
493
|
+
} catch (e) {
|
|
494
|
+
log(\`Skipping \${dir}: \${(e as Error).message}\`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return versions;
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Reads the last {@link LOG_TAIL_BYTES} of the component's log, returning
|
|
502
|
+
* undefined when it cannot be read.
|
|
503
|
+
*/
|
|
504
|
+
const readComponentLogTail = (logPath: string): string | undefined => {
|
|
505
|
+
let fd: number | undefined;
|
|
506
|
+
try {
|
|
507
|
+
fd = openSync(logPath, 'r');
|
|
508
|
+
const { size } = fstatSync(fd);
|
|
509
|
+
const length = Math.min(size, LOG_TAIL_BYTES);
|
|
510
|
+
const buffer = Buffer.alloc(length);
|
|
511
|
+
readSync(fd, buffer, 0, length, size - length);
|
|
512
|
+
const text = buffer.toString('utf-8');
|
|
513
|
+
// A truncated window starts mid-line, and possibly mid-character.
|
|
514
|
+
return size > length ? text.slice(text.indexOf('\\n') + 1) : text;
|
|
515
|
+
} catch (e) {
|
|
516
|
+
log(
|
|
517
|
+
\`Cannot read \${logPath}: \${(e as Error).message}. Run the component's \` +
|
|
518
|
+
'"-logs" target on this device to tail it.',
|
|
519
|
+
);
|
|
520
|
+
return undefined;
|
|
521
|
+
} finally {
|
|
522
|
+
if (fd !== undefined) {
|
|
523
|
+
closeSync(fd);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
/** Prints the last {@link LOG_TAIL_LINES} lines, plus any recognised remedy. */
|
|
529
|
+
const reportComponentLog = (
|
|
530
|
+
greengrassRoot: string,
|
|
531
|
+
componentName: string,
|
|
532
|
+
): void => {
|
|
533
|
+
const logPath = join(greengrassRoot, 'logs', \`\${componentName}.log\`);
|
|
534
|
+
const content = readComponentLogTail(logPath);
|
|
535
|
+
if (content === undefined) {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const lines = content.split('\\n').filter((line) => line.length > 0);
|
|
539
|
+
const tail = lines.slice(-LOG_TAIL_LINES);
|
|
540
|
+
log(\`Last \${tail.length} line(s) of \${logPath}:\`);
|
|
541
|
+
for (const line of tail) {
|
|
542
|
+
console.error(line);
|
|
543
|
+
}
|
|
544
|
+
if (SUDO_RS_LOG_SIGNATURES.some((signature) => content.includes(signature))) {
|
|
545
|
+
log(
|
|
546
|
+
'This log matches sudo-rs discarding the component environment. The ' +
|
|
547
|
+
'nucleus launches every component through "sudo -E" to pass ' +
|
|
548
|
+
'AWS_GG_NUCLEUS_DOMAIN_SOCKET_FILEPATH_FOR_COMPONENT and SVCUID, and ' +
|
|
549
|
+
'sudo-rs (the default sudo on Ubuntu 25.10 and later) accepts -E and ' +
|
|
550
|
+
'then discards the environment, so an IPC component never receives ' +
|
|
551
|
+
'them. Classic sudo is already installed - select it and restart the ' +
|
|
552
|
+
'nucleus: sudo update-alternatives --set sudo /usr/bin/sudo.ws && ' +
|
|
553
|
+
'sudo systemctl restart greengrass',
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
/** Reports a component that started, then exits 0. */
|
|
559
|
+
const exitStarted = (
|
|
560
|
+
componentName: string,
|
|
561
|
+
componentVersion: string,
|
|
562
|
+
state: string,
|
|
563
|
+
): never => {
|
|
564
|
+
log(\`\${componentName}=\${componentVersion} is \${state}.\`);
|
|
565
|
+
if (state === 'FINISHED') {
|
|
566
|
+
log(
|
|
567
|
+
'FINISHED means the Run lifecycle exited 0 rather than staying up. A ' +
|
|
568
|
+
'generated component subscribes to IPC and runs until it is stopped, ' +
|
|
569
|
+
'so unless you made this one deliberately one-shot, read its log with ' +
|
|
570
|
+
"the component's \\"-logs\\" target.",
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
process.exit(0);
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
async function main(): Promise<void> {
|
|
577
|
+
const [dist] = process.argv.slice(2);
|
|
578
|
+
if (!dist) {
|
|
579
|
+
usage();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const greengrassRoot = process.env.GREENGRASS_ROOT || '/greengrass/v2';
|
|
583
|
+
let recipeDir = join(dist, 'greengrass-build', 'recipes');
|
|
584
|
+
let artifactDir = join(dist, 'greengrass-build', 'artifacts');
|
|
585
|
+
|
|
586
|
+
let componentName: string;
|
|
587
|
+
let componentVersion: string;
|
|
588
|
+
let recipe: Record<string, unknown>;
|
|
589
|
+
try {
|
|
590
|
+
({ componentName, componentVersion, recipe } = readResolvedRecipe(recipeDir));
|
|
591
|
+
} catch (e) {
|
|
592
|
+
log((e as Error).message);
|
|
593
|
+
process.exit(1);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const timeoutSeconds = resolveTimeoutSeconds();
|
|
598
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
599
|
+
const cli = join(greengrassRoot, 'bin', 'greengrass-cli');
|
|
600
|
+
|
|
601
|
+
// Read BEFORE submitting. The state read afterwards can only be attributed
|
|
602
|
+
// to this deployment if the device was not already running this exact
|
|
603
|
+
// version - \`component list\` reports no instance identity to tell the
|
|
604
|
+
// incoming instance from the outgoing one.
|
|
605
|
+
const before = runCli(cli, ['component', 'list'], timeoutMs);
|
|
606
|
+
const installed =
|
|
607
|
+
!before.error && before.status === 0
|
|
608
|
+
? parseComponentStatus(before.stdout ?? '', componentName)
|
|
609
|
+
: undefined;
|
|
610
|
+
|
|
611
|
+
if (isNextVersionSentinel(componentVersion)) {
|
|
612
|
+
const sentinel = componentVersion;
|
|
613
|
+
if (before.error || before.status !== 0) {
|
|
614
|
+
const detail = (before.error?.message ?? before.stderr ?? '').trim();
|
|
615
|
+
log(
|
|
616
|
+
\`Cannot resolve \${sentinel} locally because "\${cli} component list" failed\${detail ? \`: \${detail}\` : ''}. Local NEXT_VERSION resolution needs the installed component version.\`,
|
|
617
|
+
);
|
|
618
|
+
process.exit(1);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const history = readLocalVersionHistory(greengrassRoot, componentName);
|
|
623
|
+
let resolved: string;
|
|
624
|
+
try {
|
|
625
|
+
resolved = resolveLocalNextVersion(installed?.version, history, sentinel);
|
|
626
|
+
} catch (e) {
|
|
627
|
+
log((e as Error).message);
|
|
628
|
+
process.exit(1);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const localDir = join(dist, 'greengrass-build', 'local');
|
|
633
|
+
const localRecipeDir = join(localDir, 'recipes');
|
|
634
|
+
const localArtifactDir = join(
|
|
635
|
+
localDir,
|
|
636
|
+
'artifacts',
|
|
637
|
+
componentName,
|
|
638
|
+
resolved,
|
|
639
|
+
);
|
|
640
|
+
const sourceArtifactDir = join(
|
|
641
|
+
artifactDir,
|
|
642
|
+
componentName,
|
|
643
|
+
componentVersion,
|
|
644
|
+
);
|
|
645
|
+
try {
|
|
646
|
+
rmSync(localDir, { recursive: true, force: true });
|
|
647
|
+
mkdirSync(localRecipeDir, { recursive: true });
|
|
648
|
+
mkdirSync(localArtifactDir, { recursive: true });
|
|
649
|
+
writeFileSync(
|
|
650
|
+
join(localRecipeDir, \`\${componentName}-\${resolved}.yaml\`),
|
|
651
|
+
yaml.dump(
|
|
652
|
+
{ ...recipe, ComponentVersion: resolved },
|
|
653
|
+
{ lineWidth: -1 },
|
|
654
|
+
),
|
|
655
|
+
);
|
|
656
|
+
for (const artifact of readdirSync(sourceArtifactDir)) {
|
|
657
|
+
cpSync(
|
|
658
|
+
join(sourceArtifactDir, artifact),
|
|
659
|
+
join(localArtifactDir, artifact),
|
|
660
|
+
{ recursive: true },
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
} catch (e) {
|
|
664
|
+
log(
|
|
665
|
+
\`Cannot stage \${sentinel} as \${resolved} for local deployment: \${(e as Error).message}\`,
|
|
666
|
+
);
|
|
667
|
+
process.exit(1);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
log(
|
|
672
|
+
\`Resolved \${sentinel} to \${resolved} (installed: \${installed?.version ?? 'none'}; package stores: \${history.length > 0 ? history.join(', ') : 'none'}).\`,
|
|
673
|
+
);
|
|
674
|
+
componentVersion = resolved;
|
|
675
|
+
recipeDir = localRecipeDir;
|
|
676
|
+
artifactDir = join(localDir, 'artifacts');
|
|
677
|
+
} else if (installed?.version === componentVersion) {
|
|
678
|
+
log(
|
|
679
|
+
\`This device already reports \${componentName}=\${componentVersion}. The \` +
|
|
680
|
+
'state check below can then read the outgoing instance and pass while ' +
|
|
681
|
+
'the incoming one fails - bump ComponentVersion in recipe.yaml before ' +
|
|
682
|
+
're-deploying.',
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const args = [
|
|
687
|
+
'deployment',
|
|
688
|
+
'create',
|
|
689
|
+
'--recipeDir',
|
|
690
|
+
recipeDir,
|
|
691
|
+
'--artifactDir',
|
|
692
|
+
artifactDir,
|
|
693
|
+
'--merge',
|
|
694
|
+
\`\${componentName}=\${componentVersion}\`,
|
|
695
|
+
];
|
|
696
|
+
|
|
697
|
+
log(\`Deploying \${componentName}=\${componentVersion} via \${cli}\`);
|
|
698
|
+
const result = runCli(cli, args, timeoutMs);
|
|
699
|
+
process.stdout.write(result.stdout ?? '');
|
|
700
|
+
process.stderr.write(result.stderr ?? '');
|
|
701
|
+
if (result.error) {
|
|
702
|
+
log(
|
|
703
|
+
(result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT'
|
|
704
|
+
? \`"\${cli} deployment create" did not return within \${timeoutSeconds}s.\`
|
|
705
|
+
: \`Cannot run "\${cli}": \${result.error.message}. Run this target on \` +
|
|
706
|
+
'the Greengrass core device, and set GREENGRASS_ROOT when the ' +
|
|
707
|
+
'nucleus is not installed at /greengrass/v2.',
|
|
708
|
+
);
|
|
709
|
+
process.exit(1);
|
|
710
|
+
}
|
|
711
|
+
if (result.status !== 0) {
|
|
712
|
+
log(\`greengrass-cli exited with \${result.status ?? 'no status'}.\`);
|
|
713
|
+
process.exit(result.status ?? 1);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
log(
|
|
717
|
+
\`Deployment submitted. Waiting up to \${timeoutSeconds}s for \` +
|
|
718
|
+
\`\${componentName} to hold \${SETTLED_RUNNING_STATES.join(' or ')} across \` +
|
|
719
|
+
\`\${REQUIRED_STABLE_CHECKS} checks.\`,
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
const deadline = Date.now() + timeoutMs;
|
|
723
|
+
let lastState: string | undefined;
|
|
724
|
+
let stableChecks = 0;
|
|
725
|
+
let listFailureReported = false;
|
|
726
|
+
while (Date.now() < deadline) {
|
|
727
|
+
const list = runCli(cli, ['component', 'list'], deadline - Date.now());
|
|
728
|
+
if (list.error || list.status !== 0) {
|
|
729
|
+
if (!listFailureReported) {
|
|
730
|
+
listFailureReported = true;
|
|
731
|
+
log(
|
|
732
|
+
\`"\${cli} component list" failed: \${(
|
|
733
|
+
list.error?.message ??
|
|
734
|
+
list.stderr ??
|
|
735
|
+
''
|
|
736
|
+
).trim()} - retrying until the timeout.\`,
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
} else {
|
|
740
|
+
const listed = parseComponentStatus(list.stdout ?? '', componentName);
|
|
741
|
+
// Only a report of the version just deployed counts. An entry still
|
|
742
|
+
// carrying the previous version means the nucleus has not applied the
|
|
743
|
+
// deployment yet.
|
|
744
|
+
if (listed?.version === componentVersion && listed.state) {
|
|
745
|
+
lastState = listed.state;
|
|
746
|
+
if (SETTLED_FAILED_STATES.includes(lastState)) {
|
|
747
|
+
log(
|
|
748
|
+
\`\${componentName}=\${componentVersion} is \${lastState} - the \` +
|
|
749
|
+
'component did not start. The local deployment itself can still ' +
|
|
750
|
+
'report success, so this state is the evidence that matters.',
|
|
751
|
+
);
|
|
752
|
+
reportComponentLog(greengrassRoot, componentName);
|
|
753
|
+
process.exit(1);
|
|
754
|
+
}
|
|
755
|
+
if (SETTLED_RUNNING_STATES.includes(lastState)) {
|
|
756
|
+
stableChecks += 1;
|
|
757
|
+
if (stableChecks >= REQUIRED_STABLE_CHECKS) {
|
|
758
|
+
exitStarted(componentName, componentVersion, lastState);
|
|
759
|
+
}
|
|
760
|
+
} else {
|
|
761
|
+
// STARTING, NEW, INSTALLED, STOPPING: on its way somewhere.
|
|
762
|
+
stableChecks = 0;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
await sleep(POLL_INTERVAL_MS);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// The deadline caught a component that started and never went bad. That is a
|
|
770
|
+
// pass: only the stability window is unfinished, and waiting longer is the
|
|
771
|
+
// caller's choice to make.
|
|
772
|
+
if (lastState !== undefined && SETTLED_RUNNING_STATES.includes(lastState)) {
|
|
773
|
+
log(
|
|
774
|
+
\`The \${timeoutSeconds}s timeout ended after \${stableChecks} of \` +
|
|
775
|
+
\`\${REQUIRED_STABLE_CHECKS} stable checks. Raise \` +
|
|
776
|
+
'GREENGRASS_DEPLOY_TIMEOUT_SECONDS to confirm the state holds.',
|
|
777
|
+
);
|
|
778
|
+
exitStarted(componentName, componentVersion, lastState);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
log(
|
|
782
|
+
\`\${componentName}=\${componentVersion} did not settle within \` +
|
|
783
|
+
\`\${timeoutSeconds}s - last reported state \${
|
|
784
|
+
lastState ?? 'none (the component was not listed at this version)'
|
|
785
|
+
}. Raise GREENGRASS_DEPLOY_TIMEOUT_SECONDS if this component is slow to \` +
|
|
786
|
+
'start.',
|
|
787
|
+
);
|
|
788
|
+
reportComponentLog(greengrassRoot, componentName);
|
|
789
|
+
process.exit(1);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
void main();
|
|
793
|
+
"
|
|
794
|
+
`;
|
|
795
|
+
|
|
796
|
+
exports[`sharedGreengrassScriptsGenerator > should vend the greengrass scripts into packages/common/scripts > packages/common/scripts/src/greengrass/recipe-utils.ts 1`] = `
|
|
797
|
+
"import { readFileSync, readdirSync } from 'node:fs';
|
|
798
|
+
import { join } from 'node:path';
|
|
799
|
+
import yaml from 'js-yaml';
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* A narrow view of a Greengrass recipe: only the keys this vended toolchain
|
|
803
|
+
* reads. Every other key - including unknown lifecycle keys - is preserved
|
|
804
|
+
* untouched wherever a recipe is re-serialized, because the recipe is
|
|
805
|
+
* user-owned and this toolchain does not model the full recipe schema.
|
|
806
|
+
*/
|
|
807
|
+
export type GreengrassRecipe = Record<string, unknown>;
|
|
808
|
+
|
|
809
|
+
export interface ResolvedComponent {
|
|
810
|
+
readonly componentName: string;
|
|
811
|
+
readonly componentVersion: string;
|
|
812
|
+
readonly recipe: GreengrassRecipe;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const COMPONENT_NAME_PATTERN = /^[a-zA-Z0-9-_.]+$/;
|
|
816
|
+
const RESERVED_COMPONENT_NAME_PREFIX = 'aws.greengrass.';
|
|
817
|
+
const MAX_COMPONENT_NAME_LENGTH = 128;
|
|
818
|
+
// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
|
|
819
|
+
const SEMVER_PATTERN =
|
|
820
|
+
/^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$/;
|
|
821
|
+
const STRICT_COMPONENT_VERSION_PATTERN =
|
|
822
|
+
/^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$/;
|
|
823
|
+
const DECOMPRESSED_PATH_TOKEN = '{artifacts:decompressedPath}';
|
|
824
|
+
|
|
825
|
+
export const NEXT_VERSION_SENTINELS = [
|
|
826
|
+
'NEXT_PATCH',
|
|
827
|
+
'NEXT_MINOR',
|
|
828
|
+
'NEXT_MAJOR',
|
|
829
|
+
] as const;
|
|
830
|
+
|
|
831
|
+
export type NextVersionSentinel = (typeof NEXT_VERSION_SENTINELS)[number];
|
|
832
|
+
|
|
833
|
+
/** Whether a value requests local deploy-time component version resolution. */
|
|
834
|
+
export function isNextVersionSentinel(
|
|
835
|
+
value: unknown,
|
|
836
|
+
): value is NextVersionSentinel {
|
|
837
|
+
return (
|
|
838
|
+
typeof value === 'string' &&
|
|
839
|
+
NEXT_VERSION_SENTINELS.includes(value as NextVersionSentinel)
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Resolves a version sentinel on a device: bumps the highest strict M.m.p
|
|
845
|
+
* version among the installed version and the candidate versions the caller
|
|
846
|
+
* collected from the nucleus's package stores, or returns 1.0.0 when there is
|
|
847
|
+
* none. A candidate that is not strict M.m.p is ignored. An installed version
|
|
848
|
+
* that is not strict M.m.p is an error, because the sentinel cannot bump it.
|
|
849
|
+
*/
|
|
850
|
+
export function resolveLocalNextVersion(
|
|
851
|
+
installedVersion: string | undefined,
|
|
852
|
+
candidateVersions: readonly string[],
|
|
853
|
+
sentinel: NextVersionSentinel,
|
|
854
|
+
): string {
|
|
855
|
+
if (
|
|
856
|
+
installedVersion !== undefined &&
|
|
857
|
+
!STRICT_COMPONENT_VERSION_PATTERN.test(installedVersion)
|
|
858
|
+
) {
|
|
859
|
+
throw new Error(
|
|
860
|
+
\`Installed component version "\${installedVersion}" is not a strict M.m.p version, so \${sentinel} cannot resolve it.\`,
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const highest = [...candidateVersions, installedVersion]
|
|
865
|
+
.filter(
|
|
866
|
+
(version): version is string =>
|
|
867
|
+
version !== undefined && STRICT_COMPONENT_VERSION_PATTERN.test(version),
|
|
868
|
+
)
|
|
869
|
+
.reduce<string | undefined>((currentHighest, candidate) => {
|
|
870
|
+
if (currentHighest === undefined) {
|
|
871
|
+
return candidate;
|
|
872
|
+
}
|
|
873
|
+
const current = STRICT_COMPONENT_VERSION_PATTERN.exec(currentHighest)!;
|
|
874
|
+
const next = STRICT_COMPONENT_VERSION_PATTERN.exec(candidate)!;
|
|
875
|
+
for (let index = 1; index <= 3; index++) {
|
|
876
|
+
const difference = BigInt(next[index]) - BigInt(current[index]);
|
|
877
|
+
if (difference !== 0n) {
|
|
878
|
+
return difference > 0n ? candidate : currentHighest;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return currentHighest;
|
|
882
|
+
}, undefined);
|
|
883
|
+
if (highest === undefined) {
|
|
884
|
+
return '1.0.0';
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const [, major, minor, patch] =
|
|
888
|
+
STRICT_COMPONENT_VERSION_PATTERN.exec(highest)!;
|
|
889
|
+
switch (sentinel) {
|
|
890
|
+
case 'NEXT_PATCH':
|
|
891
|
+
return \`\${major}.\${minor}.\${(BigInt(patch) + 1n).toString()}\`;
|
|
892
|
+
case 'NEXT_MINOR':
|
|
893
|
+
return \`\${major}.\${(BigInt(minor) + 1n).toString()}.0\`;
|
|
894
|
+
case 'NEXT_MAJOR':
|
|
895
|
+
return \`\${(BigInt(major) + 1n).toString()}.0.0\`;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** Reads and parses a recipe.yaml. Throws with a clear message on failure. */
|
|
900
|
+
export function loadRecipe(recipePath: string): GreengrassRecipe {
|
|
901
|
+
let raw: string;
|
|
902
|
+
try {
|
|
903
|
+
raw = readFileSync(recipePath, 'utf-8');
|
|
904
|
+
} catch {
|
|
905
|
+
throw new Error(\`Cannot read recipe at "\${recipePath}"\`);
|
|
906
|
+
}
|
|
907
|
+
const parsed = yaml.load(raw);
|
|
908
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
909
|
+
throw new Error(\`"\${recipePath}" is not a Greengrass recipe document\`);
|
|
910
|
+
}
|
|
911
|
+
return parsed as GreengrassRecipe;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Validates only the recipe fields this vended toolchain consumes:
|
|
916
|
+
* \`ComponentName\`, \`ComponentVersion\`, and, for every entry in
|
|
917
|
+
* \`artifactBaseNames\`, the \`{artifacts:decompressedPath}\` reference the
|
|
918
|
+
* local-deploy lifecycle script must carry so that a deployed component can
|
|
919
|
+
* find its own artifact once Greengrass decompresses it.
|
|
920
|
+
*
|
|
921
|
+
* Throws a single \`Error\` listing every violation when the recipe is invalid.
|
|
922
|
+
*/
|
|
923
|
+
export function validateRecipe(
|
|
924
|
+
recipe: GreengrassRecipe,
|
|
925
|
+
artifactBaseNames: readonly string[],
|
|
926
|
+
): ResolvedComponent {
|
|
927
|
+
const errors: string[] = [];
|
|
928
|
+
|
|
929
|
+
const componentName = recipe.ComponentName;
|
|
930
|
+
if (typeof componentName !== 'string' || componentName.length === 0) {
|
|
931
|
+
errors.push('ComponentName is required and must be a non-empty string');
|
|
932
|
+
} else {
|
|
933
|
+
if (componentName.length > MAX_COMPONENT_NAME_LENGTH) {
|
|
934
|
+
errors.push(
|
|
935
|
+
\`ComponentName must be at most \${MAX_COMPONENT_NAME_LENGTH} characters, got \${componentName.length}\`,
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
if (!COMPONENT_NAME_PATTERN.test(componentName)) {
|
|
939
|
+
errors.push(
|
|
940
|
+
'ComponentName may only contain letters, numbers, "-", "_" and "."',
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
if (componentName.startsWith(RESERVED_COMPONENT_NAME_PREFIX)) {
|
|
944
|
+
errors.push(
|
|
945
|
+
\`ComponentName must not start with the reserved prefix "\${RESERVED_COMPONENT_NAME_PREFIX}"\`,
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const componentVersion = recipe.ComponentVersion;
|
|
951
|
+
if (
|
|
952
|
+
typeof componentVersion !== 'string' ||
|
|
953
|
+
(!SEMVER_PATTERN.test(componentVersion) &&
|
|
954
|
+
!isNextVersionSentinel(componentVersion))
|
|
955
|
+
) {
|
|
956
|
+
errors.push(
|
|
957
|
+
'ComponentVersion is required and must be a valid semver version (e.g. "1.0.0") or one of NEXT_PATCH, NEXT_MINOR, NEXT_MAJOR',
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
for (const baseName of artifactBaseNames) {
|
|
962
|
+
if (!referencesDecompressedComponentDir(recipe, baseName)) {
|
|
963
|
+
errors.push(
|
|
964
|
+
\`No lifecycle script references "\${DECOMPRESSED_PATH_TOKEN}/\${baseName}/" - \` +
|
|
965
|
+
\`the Run script path must match the artifact zip base name ("\${baseName}.zip")\`,
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
if (!declaresComponentZipArtifact(recipe, baseName)) {
|
|
969
|
+
errors.push(
|
|
970
|
+
\`No manifest declares an artifact whose Uri ends in "/\${baseName}.zip" - \` +
|
|
971
|
+
'Greengrass only unarchives a declared artifact, so ' +
|
|
972
|
+
\`"\${DECOMPRESSED_PATH_TOKEN}/\${baseName}/" would never resolve on the device\`,
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (errors.length > 0) {
|
|
978
|
+
throw new Error(
|
|
979
|
+
\`Invalid recipe:\\n\${errors.map((e) => \` - \${e}\`).join('\\n')}\`,
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
return {
|
|
984
|
+
componentName: componentName as string,
|
|
985
|
+
componentVersion: componentVersion as string,
|
|
986
|
+
recipe,
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Recursively searches every string value for a decompressedPath reference to
|
|
992
|
+
* \`componentDirName\`, wherever the recipe places its Lifecycle block (top
|
|
993
|
+
* level or per-manifest).
|
|
994
|
+
*/
|
|
995
|
+
function referencesDecompressedComponentDir(
|
|
996
|
+
value: unknown,
|
|
997
|
+
componentDirName: string,
|
|
998
|
+
): boolean {
|
|
999
|
+
const needle = \`\${DECOMPRESSED_PATH_TOKEN}/\${componentDirName}/\`;
|
|
1000
|
+
if (typeof value === 'string') {
|
|
1001
|
+
return value.includes(needle);
|
|
1002
|
+
}
|
|
1003
|
+
if (Array.isArray(value)) {
|
|
1004
|
+
return value.some((entry) =>
|
|
1005
|
+
referencesDecompressedComponentDir(entry, componentDirName),
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
if (value !== null && typeof value === 'object') {
|
|
1009
|
+
return Object.values(value).some((entry) =>
|
|
1010
|
+
referencesDecompressedComponentDir(entry, componentDirName),
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
return false;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Whether any manifest declares an artifact whose Uri basename is
|
|
1018
|
+
* \`<componentDirName>.zip\`, which is the name the artifact this toolchain
|
|
1019
|
+
* writes actually carries.
|
|
1020
|
+
*/
|
|
1021
|
+
function declaresComponentZipArtifact(
|
|
1022
|
+
recipe: GreengrassRecipe,
|
|
1023
|
+
componentDirName: string,
|
|
1024
|
+
): boolean {
|
|
1025
|
+
const manifests = Array.isArray(recipe.Manifests) ? recipe.Manifests : [];
|
|
1026
|
+
return manifests.some((manifest) =>
|
|
1027
|
+
manifestDeclaresZipArtifact(manifest, componentDirName),
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Whether one manifest declares an artifact whose Uri basename is
|
|
1033
|
+
* \`<componentDirName>.zip\`. Case-insensitive on the key so a recipe authored
|
|
1034
|
+
* with \`URI\` (as some AWS samples spell it) still matches.
|
|
1035
|
+
*/
|
|
1036
|
+
function manifestDeclaresZipArtifact(
|
|
1037
|
+
manifest: unknown,
|
|
1038
|
+
componentDirName: string,
|
|
1039
|
+
): boolean {
|
|
1040
|
+
const suffix = \`/\${componentDirName}.zip\`;
|
|
1041
|
+
const artifacts = (manifest as { Artifacts?: unknown })?.Artifacts;
|
|
1042
|
+
if (!Array.isArray(artifacts)) {
|
|
1043
|
+
return false;
|
|
1044
|
+
}
|
|
1045
|
+
return artifacts.some((artifact) => {
|
|
1046
|
+
if (artifact === null || typeof artifact !== 'object') {
|
|
1047
|
+
return false;
|
|
1048
|
+
}
|
|
1049
|
+
return Object.entries(artifact as Record<string, unknown>).some(
|
|
1050
|
+
([key, value]) =>
|
|
1051
|
+
key.toLowerCase() === 'uri' &&
|
|
1052
|
+
typeof value === 'string' &&
|
|
1053
|
+
value.endsWith(suffix),
|
|
1054
|
+
);
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Multi-architecture only: checks that every manifest whose
|
|
1060
|
+
* \`Platform.architecture\` names an architecture this build vendored pairs with
|
|
1061
|
+
* THAT architecture's own artifact, both in its \`Artifacts\` and in the
|
|
1062
|
+
* lifecycle that unarchives it.
|
|
1063
|
+
*
|
|
1064
|
+
* {@link validateRecipe}'s checks are global - they prove every built zip is
|
|
1065
|
+
* declared and referenced somewhere in the recipe. A recipe that swaps two
|
|
1066
|
+
* manifests' artifacts and Run paths satisfies them, and a swap sends one
|
|
1067
|
+
* architecture's native wheels to the other, which fails only on the device.
|
|
1068
|
+
* A swap is one paste mistake away, because adding a second manifest to an
|
|
1069
|
+
* existing component is a hand edit of a user-owned recipe.
|
|
1070
|
+
*
|
|
1071
|
+
* A manifest with no \`Platform.architecture\`, or one naming an architecture
|
|
1072
|
+
* this build did not vendor, is left alone: a recipe may carry manifests this
|
|
1073
|
+
* toolchain does not build.
|
|
1074
|
+
*/
|
|
1075
|
+
export function validateManifestArchitectures(
|
|
1076
|
+
recipe: GreengrassRecipe,
|
|
1077
|
+
baseNameByArchitecture: Readonly<Record<string, string>>,
|
|
1078
|
+
): void {
|
|
1079
|
+
const errors: string[] = [];
|
|
1080
|
+
const manifests = Array.isArray(recipe.Manifests) ? recipe.Manifests : [];
|
|
1081
|
+
manifests.forEach((manifest, index) => {
|
|
1082
|
+
const architecture = (
|
|
1083
|
+
manifest as { Platform?: { architecture?: unknown } } | null
|
|
1084
|
+
)?.Platform?.architecture;
|
|
1085
|
+
if (typeof architecture !== 'string') {
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
const baseName = baseNameByArchitecture[architecture];
|
|
1089
|
+
if (!baseName) {
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
if (!manifestDeclaresZipArtifact(manifest, baseName)) {
|
|
1093
|
+
errors.push(
|
|
1094
|
+
\`Manifest \${index + 1} declares "architecture: \${architecture}", so its own artifact Uri must end in "/\${baseName}.zip" - \` +
|
|
1095
|
+
\`that is the zip this build writes for "\${architecture}". Manifests must not carry another architecture's artifact\`,
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
const lifecycle =
|
|
1099
|
+
(manifest as { Lifecycle?: unknown }).Lifecycle ?? recipe.Lifecycle;
|
|
1100
|
+
if (!referencesDecompressedComponentDir(lifecycle, baseName)) {
|
|
1101
|
+
errors.push(
|
|
1102
|
+
\`Manifest \${index + 1} declares "architecture: \${architecture}", so its lifecycle must reference \` +
|
|
1103
|
+
\`"\${DECOMPRESSED_PATH_TOKEN}/\${baseName}/" - the directory that architecture's own artifact unarchives to\`,
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
if (errors.length > 0) {
|
|
1109
|
+
throw new Error(
|
|
1110
|
+
\`Invalid recipe:\\n\${errors.map((e) => \` - \${e}\`).join('\\n')}\`,
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Reads the single resolved recipe a prior \`-artifact\` run wrote under a
|
|
1117
|
+
* \`greengrass-build/recipes\` directory, for scripts that only need to know
|
|
1118
|
+
* which component and version were last built.
|
|
1119
|
+
*/
|
|
1120
|
+
export function readResolvedRecipe(recipesDir: string): ResolvedComponent {
|
|
1121
|
+
let files: string[];
|
|
1122
|
+
try {
|
|
1123
|
+
files = readdirSync(recipesDir).filter(
|
|
1124
|
+
(f) => f.endsWith('.yaml') || f.endsWith('.yml'),
|
|
1125
|
+
);
|
|
1126
|
+
} catch {
|
|
1127
|
+
throw new Error(
|
|
1128
|
+
\`No resolved recipes found at "\${recipesDir}" - run the component's "-artifact" target first\`,
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
if (files.length !== 1) {
|
|
1132
|
+
throw new Error(
|
|
1133
|
+
\`Expected exactly one resolved recipe in "\${recipesDir}", found \${files.length}\`,
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
const recipePath = join(recipesDir, files[0]);
|
|
1137
|
+
const recipe = loadRecipe(recipePath);
|
|
1138
|
+
const { ComponentName, ComponentVersion } = recipe;
|
|
1139
|
+
if (
|
|
1140
|
+
typeof ComponentName !== 'string' ||
|
|
1141
|
+
typeof ComponentVersion !== 'string'
|
|
1142
|
+
) {
|
|
1143
|
+
throw new Error(
|
|
1144
|
+
\`"\${recipePath}" is missing ComponentName/ComponentVersion\`,
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
return {
|
|
1148
|
+
componentName: ComponentName,
|
|
1149
|
+
componentVersion: ComponentVersion,
|
|
1150
|
+
recipe,
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
"
|
|
1154
|
+
`;
|
|
1155
|
+
|
|
1156
|
+
exports[`sharedGreengrassScriptsGenerator > should vend the greengrass scripts into packages/common/scripts > packages/common/scripts/src/greengrass/zip-writer.ts 1`] = `
|
|
1157
|
+
"import { writeFileSync } from 'node:fs';
|
|
1158
|
+
// crc32 from node:zlib requires Node >= 20.15 / 22.2.
|
|
1159
|
+
import { crc32, deflateRawSync } from 'node:zlib';
|
|
1160
|
+
|
|
1161
|
+
/** One file to place in the archive, at a forward-slash relative path. */
|
|
1162
|
+
export interface ZipEntry {
|
|
1163
|
+
readonly name: string;
|
|
1164
|
+
readonly data: Buffer;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
|
1168
|
+
const CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50;
|
|
1169
|
+
const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
|
|
1170
|
+
// 20 = version 2.0, the lowest version that documents DEFLATE support.
|
|
1171
|
+
const VERSION_NEEDED = 20;
|
|
1172
|
+
const DEFLATE_METHOD = 8;
|
|
1173
|
+
const STORED_METHOD = 0;
|
|
1174
|
+
const MAX_ENTRIES = 65_535;
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Fixed MS-DOS timestamp fields (1980-01-01 00:00:00, the DOS epoch).
|
|
1178
|
+
*
|
|
1179
|
+
* The archive must be byte-reproducible: its sha256 keys the artifact's S3
|
|
1180
|
+
* object and is substituted into the recipe, so a rebuild that produced
|
|
1181
|
+
* different bytes for identical content would make CloudFormation try to
|
|
1182
|
+
* replace an already-published, immutable component version and fail with
|
|
1183
|
+
* AlreadyExists. Stamping the build time would do exactly that on every
|
|
1184
|
+
* rebuild.
|
|
1185
|
+
*/
|
|
1186
|
+
const DOS_EPOCH_TIME = 0;
|
|
1187
|
+
const DOS_EPOCH_DATE = (1 << 5) | 1;
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Writes \`entries\` to a plain ZIP archive at \`outPath\` with no external
|
|
1191
|
+
* dependency - only \`node:zlib\` for DEFLATE and CRC-32. Supports exactly what
|
|
1192
|
+
* Greengrass artifact packaging needs: a flat set of files, no encryption, no
|
|
1193
|
+
* zip64 (component artifacts are small). Cross-platform: no shell-out to a
|
|
1194
|
+
* system \`zip\` binary.
|
|
1195
|
+
*/
|
|
1196
|
+
export function writeZip(entries: readonly ZipEntry[], outPath: string): void {
|
|
1197
|
+
if (entries.length > MAX_ENTRIES) {
|
|
1198
|
+
throw new RangeError(
|
|
1199
|
+
\`ZIP64 is not supported; this ZIP writer can store at most 65,535 entries, received \${entries.length}.\`,
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
const fileChunks: Buffer[] = [];
|
|
1204
|
+
const centralRecords: Buffer[] = [];
|
|
1205
|
+
let offset = 0;
|
|
1206
|
+
const time = DOS_EPOCH_TIME;
|
|
1207
|
+
const date = DOS_EPOCH_DATE;
|
|
1208
|
+
|
|
1209
|
+
// Sorted so directory iteration order cannot change the archive's bytes.
|
|
1210
|
+
const sortedEntries = [...entries].sort((a, b) =>
|
|
1211
|
+
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
|
|
1212
|
+
);
|
|
1213
|
+
for (const entry of sortedEntries) {
|
|
1214
|
+
const nameBuf = Buffer.from(entry.name, 'utf-8');
|
|
1215
|
+
const entryCrc32 = crc32(entry.data) >>> 0;
|
|
1216
|
+
const compressed = deflateRawSync(entry.data);
|
|
1217
|
+
const useDeflate = compressed.length < entry.data.length;
|
|
1218
|
+
const method = useDeflate ? DEFLATE_METHOD : STORED_METHOD;
|
|
1219
|
+
const payload = useDeflate ? compressed : entry.data;
|
|
1220
|
+
|
|
1221
|
+
const localHeader = Buffer.alloc(30);
|
|
1222
|
+
localHeader.writeUInt32LE(LOCAL_FILE_HEADER_SIGNATURE, 0);
|
|
1223
|
+
localHeader.writeUInt16LE(VERSION_NEEDED, 4);
|
|
1224
|
+
localHeader.writeUInt16LE(0, 6); // general purpose flags
|
|
1225
|
+
localHeader.writeUInt16LE(method, 8);
|
|
1226
|
+
localHeader.writeUInt16LE(time, 10);
|
|
1227
|
+
localHeader.writeUInt16LE(date, 12);
|
|
1228
|
+
localHeader.writeUInt32LE(entryCrc32, 14);
|
|
1229
|
+
localHeader.writeUInt32LE(payload.length, 18);
|
|
1230
|
+
localHeader.writeUInt32LE(entry.data.length, 22);
|
|
1231
|
+
localHeader.writeUInt16LE(nameBuf.length, 26);
|
|
1232
|
+
localHeader.writeUInt16LE(0, 28); // extra field length
|
|
1233
|
+
|
|
1234
|
+
fileChunks.push(localHeader, nameBuf, payload);
|
|
1235
|
+
|
|
1236
|
+
const centralHeader = Buffer.alloc(46);
|
|
1237
|
+
centralHeader.writeUInt32LE(CENTRAL_DIRECTORY_SIGNATURE, 0);
|
|
1238
|
+
centralHeader.writeUInt16LE(VERSION_NEEDED, 4); // version made by
|
|
1239
|
+
centralHeader.writeUInt16LE(VERSION_NEEDED, 6); // version needed to extract
|
|
1240
|
+
centralHeader.writeUInt16LE(0, 8); // general purpose flags
|
|
1241
|
+
centralHeader.writeUInt16LE(method, 10);
|
|
1242
|
+
centralHeader.writeUInt16LE(time, 12);
|
|
1243
|
+
centralHeader.writeUInt16LE(date, 14);
|
|
1244
|
+
centralHeader.writeUInt32LE(entryCrc32, 16);
|
|
1245
|
+
centralHeader.writeUInt32LE(payload.length, 20);
|
|
1246
|
+
centralHeader.writeUInt32LE(entry.data.length, 24);
|
|
1247
|
+
centralHeader.writeUInt16LE(nameBuf.length, 28);
|
|
1248
|
+
centralHeader.writeUInt16LE(0, 30); // extra field length
|
|
1249
|
+
centralHeader.writeUInt16LE(0, 32); // file comment length
|
|
1250
|
+
centralHeader.writeUInt16LE(0, 34); // disk number start
|
|
1251
|
+
centralHeader.writeUInt16LE(0, 36); // internal file attributes
|
|
1252
|
+
centralHeader.writeUInt32LE(0, 38); // external file attributes
|
|
1253
|
+
centralHeader.writeUInt32LE(offset, 42); // relative offset of local header
|
|
1254
|
+
|
|
1255
|
+
centralRecords.push(centralHeader, nameBuf);
|
|
1256
|
+
|
|
1257
|
+
offset += localHeader.length + nameBuf.length + payload.length;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const centralDirectory = Buffer.concat(centralRecords);
|
|
1261
|
+
const centralDirectoryOffset = offset;
|
|
1262
|
+
|
|
1263
|
+
const endRecord = Buffer.alloc(22);
|
|
1264
|
+
endRecord.writeUInt32LE(END_OF_CENTRAL_DIRECTORY_SIGNATURE, 0);
|
|
1265
|
+
endRecord.writeUInt16LE(0, 4); // disk number
|
|
1266
|
+
endRecord.writeUInt16LE(0, 6); // disk with central directory start
|
|
1267
|
+
endRecord.writeUInt16LE(entries.length, 8); // entries on this disk
|
|
1268
|
+
endRecord.writeUInt16LE(entries.length, 10); // total entries
|
|
1269
|
+
endRecord.writeUInt32LE(centralDirectory.length, 12);
|
|
1270
|
+
endRecord.writeUInt32LE(centralDirectoryOffset, 16);
|
|
1271
|
+
endRecord.writeUInt16LE(0, 20); // comment length
|
|
1272
|
+
|
|
1273
|
+
writeFileSync(
|
|
1274
|
+
outPath,
|
|
1275
|
+
Buffer.concat([...fileChunks, centralDirectory, endRecord]),
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
"
|
|
1279
|
+
`;
|