thinkwork-cli 0.12.1 → 0.12.3

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 (29) hide show
  1. package/dist/cli.js +1062 -45
  2. package/dist/commands/enterprise/templates/deploy-repo/.github/workflows/deploy.yml +232 -0
  3. package/dist/commands/enterprise/templates/deploy-repo/README.md +31 -0
  4. package/dist/commands/enterprise/templates/deploy-repo/customer/branding/README.md +7 -0
  5. package/dist/commands/enterprise/templates/deploy-repo/customer/deployment.json +6 -0
  6. package/dist/commands/enterprise/templates/deploy-repo/customer/evals/README.md +10 -0
  7. package/dist/commands/enterprise/templates/deploy-repo/customer/seeds/README.md +7 -0
  8. package/dist/commands/enterprise/templates/deploy-repo/customer/skills/README.md +7 -0
  9. package/dist/commands/enterprise/templates/deploy-repo/customer/workspace-defaults/README.md +7 -0
  10. package/dist/commands/enterprise/templates/deploy-repo/scripts/apply-release.mjs +606 -0
  11. package/dist/commands/enterprise/templates/deploy-repo/scripts/smoke.mjs +99 -0
  12. package/dist/commands/enterprise/templates/deploy-repo/terraform/backend-dev.hcl +6 -0
  13. package/dist/commands/enterprise/templates/deploy-repo/terraform/main.tf +101 -0
  14. package/dist/commands/enterprise/templates/deploy-repo/terraform/stages/dev.tfvars +9 -0
  15. package/dist/commands/enterprise/templates/deploy-repo/terraform/stages/prod.tfvars +9 -0
  16. package/dist/commands/enterprise/templates/deploy-repo/thinkwork.lock +17 -0
  17. package/dist/terraform/examples/greenfield/main.tf +26 -0
  18. package/dist/terraform/examples/greenfield/terraform.tfvars.example +12 -0
  19. package/dist/terraform/modules/app/lambda-api/eval-fanout.tf +7 -7
  20. package/dist/terraform/modules/app/lambda-api/handlers.tf +78 -68
  21. package/dist/terraform/modules/app/lambda-api/outputs.tf +9 -4
  22. package/dist/terraform/modules/app/lambda-api/remote-artifacts.tf +36 -0
  23. package/dist/terraform/modules/app/lambda-api/variables.tf +7 -0
  24. package/dist/terraform/modules/app/lambda-api/workspace-events.tf +1 -1
  25. package/dist/terraform/modules/thinkwork/main.tf +3 -2
  26. package/dist/terraform/modules/thinkwork/outputs.tf +5 -0
  27. package/dist/terraform/modules/thinkwork/variables.tf +6 -0
  28. package/dist/terraform/schema.graphql +10 -40
  29. package/package.json +1 -1
@@ -0,0 +1,606 @@
1
+ #!/usr/bin/env node
2
+ // thinkwork-managed: enterprise-deploy-template
3
+ import { createHash } from "node:crypto";
4
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { dirname, join } from "node:path";
6
+ import { execFileSync } from "node:child_process";
7
+
8
+ const args = parseArgs(process.argv.slice(2));
9
+ const command = args._[0];
10
+
11
+ try {
12
+ switch (command) {
13
+ case "validate-manifest":
14
+ await validateManifest();
15
+ break;
16
+ case "prepare":
17
+ await prepareArtifacts();
18
+ break;
19
+ case "copy-runtime-images":
20
+ await copyRuntimeImages();
21
+ break;
22
+ case "update-agentcore-runtimes":
23
+ await updateAgentCoreRuntimes();
24
+ break;
25
+ case "sync-static":
26
+ await syncStaticSites();
27
+ break;
28
+ case "record-overlay":
29
+ await recordOverlay();
30
+ break;
31
+ case "write-summary":
32
+ await writeSummary();
33
+ break;
34
+ default:
35
+ throw new Error(`Unknown command: ${command ?? "(missing)"}`);
36
+ }
37
+ } catch (error) {
38
+ console.error(error instanceof Error ? error.message : String(error));
39
+ process.exit(1);
40
+ }
41
+
42
+ async function validateManifest() {
43
+ const manifest = await readManifest(required("--manifest"));
44
+ const expectedRelease = optional("--expected-release")?.replace(/^v/, "");
45
+ if (manifest.schemaVersion !== 1) {
46
+ throw new Error(
47
+ `Unsupported release manifest schema ${manifest.schemaVersion}`,
48
+ );
49
+ }
50
+ if (expectedRelease && manifest.release?.version !== expectedRelease) {
51
+ throw new Error(
52
+ `Release manifest version ${manifest.release?.version} does not match lock ${expectedRelease}`,
53
+ );
54
+ }
55
+ for (const artifact of manifest.artifacts ?? []) {
56
+ if (!artifact.url || !artifact.sha256 || !artifact.fileName) {
57
+ throw new Error(
58
+ `Release artifact ${artifact.name} is missing url, sha256, or fileName`,
59
+ );
60
+ }
61
+ }
62
+ console.log(`Validated ThinkWork release ${manifest.release.version}`);
63
+ }
64
+
65
+ async function prepareArtifacts() {
66
+ const manifest = await readManifest(required("--manifest"));
67
+ const workDir = required("--work-dir");
68
+ const artifactBucket = required("--artifact-bucket");
69
+ const lambdaPrefix = required("--lambda-prefix").replace(/^\/+|\/+$/g, "");
70
+ const downloadDir = join(workDir, "downloads");
71
+ await mkdir(downloadDir, { recursive: true });
72
+
73
+ const prepared = [];
74
+ for (const artifact of manifest.artifacts ?? []) {
75
+ const localPath = join(downloadDir, artifact.fileName);
76
+ await downloadAndVerify(artifact, localPath);
77
+ prepared.push({ ...artifact, localPath });
78
+ if (artifact.type === "lambda") {
79
+ run("aws", [
80
+ "s3",
81
+ "cp",
82
+ localPath,
83
+ `s3://${artifactBucket}/${lambdaPrefix}/${artifact.fileName}`,
84
+ ]);
85
+ }
86
+ }
87
+
88
+ await writeJson(join(workDir, "prepared-artifacts.json"), {
89
+ release: manifest.release,
90
+ artifacts: prepared,
91
+ });
92
+ }
93
+
94
+ async function copyRuntimeImages() {
95
+ const manifest = await readManifest(required("--manifest"));
96
+ const workDir = required("--work-dir");
97
+ const stage = required("--stage");
98
+ const repositoryUrl = required("--ecr-repository-url");
99
+ const copied = [];
100
+
101
+ for (const image of manifest.runtimeImages ?? []) {
102
+ const runtime = runtimeName(image.name);
103
+ const arch = image.architecture ?? "arm64";
104
+ const releaseTag = sanitizeTag(
105
+ `release-${manifest.release.version}-${image.name}`,
106
+ );
107
+ const stageTag = sanitizeTag(`${stage}-${runtime}-${arch}`);
108
+ const releaseUri = `${repositoryUrl}:${releaseTag}`;
109
+ const stageUri = `${repositoryUrl}:${stageTag}`;
110
+ run("docker", [
111
+ "buildx",
112
+ "imagetools",
113
+ "create",
114
+ "--tag",
115
+ releaseUri,
116
+ "--tag",
117
+ stageUri,
118
+ image.uri,
119
+ ]);
120
+ copied.push({ ...image, runtime, releaseUri, stageUri });
121
+ }
122
+
123
+ await writeJson(join(workDir, "runtime-images.json"), copied);
124
+ }
125
+
126
+ async function updateAgentCoreRuntimes() {
127
+ const workDir = required("--work-dir");
128
+ const stage = required("--stage");
129
+ const region = required("--region");
130
+ const copied = await readJson(join(workDir, "runtime-images.json"), []);
131
+ const updates = [];
132
+ const accountId = awsText([
133
+ "sts",
134
+ "get-caller-identity",
135
+ "--query",
136
+ "Account",
137
+ "--output",
138
+ "text",
139
+ ]);
140
+
141
+ for (const runtime of ["strands", "flue"]) {
142
+ const image = copied.find(
143
+ (item) => item.runtime === runtime && item.architecture === "arm64",
144
+ );
145
+ if (!image) continue;
146
+ const runtimeId = findAgentCoreRuntimeId({ stage, runtime, region });
147
+ if (!runtimeId) {
148
+ if (runtime === "flue") {
149
+ updates.push(
150
+ createFlueAgentCoreRuntime({
151
+ stage,
152
+ region,
153
+ accountId,
154
+ imageUri: image.stageUri,
155
+ }),
156
+ );
157
+ continue;
158
+ }
159
+ throw new Error(
160
+ `No ${runtime} AgentCore runtime found in SSM or runtime list`,
161
+ );
162
+ }
163
+ updates.push(
164
+ updateAgentCoreRuntime({
165
+ stage,
166
+ runtime,
167
+ runtimeId,
168
+ region,
169
+ accountId,
170
+ imageUri: image.stageUri,
171
+ }),
172
+ );
173
+ }
174
+
175
+ await writeJson(join(workDir, "runtime-updates.json"), updates);
176
+ }
177
+
178
+ function findAgentCoreRuntimeId({ stage, runtime, region }) {
179
+ const parameterValue = awsText([
180
+ "ssm",
181
+ "get-parameter",
182
+ "--name",
183
+ `/thinkwork/${stage}/agentcore/runtime-id-${runtime}`,
184
+ "--region",
185
+ region,
186
+ "--query",
187
+ "Parameter.Value",
188
+ "--output",
189
+ "text",
190
+ ]);
191
+ if (parameterValue && parameterValue !== "None") return parameterValue;
192
+
193
+ const runtimeNameValue = `thinkwork_${stage}_${runtime}`;
194
+ const listed = awsText([
195
+ "bedrock-agentcore-control",
196
+ "list-agent-runtimes",
197
+ "--region",
198
+ region,
199
+ "--query",
200
+ `agentRuntimes[?agentRuntimeName=='${runtimeNameValue}'].agentRuntimeId | [0]`,
201
+ "--output",
202
+ "text",
203
+ ]);
204
+ return listed && listed !== "None" ? listed : "";
205
+ }
206
+
207
+ function createFlueAgentCoreRuntime({ stage, region, accountId, imageUri }) {
208
+ if (!accountId) {
209
+ throw new Error("AWS account ID is required to create the Flue runtime");
210
+ }
211
+ const runtimeNameValue = `thinkwork_${stage}_flue`;
212
+ const runtimeId = awsText([
213
+ "bedrock-agentcore-control",
214
+ "create-agent-runtime",
215
+ "--region",
216
+ region,
217
+ "--agent-runtime-name",
218
+ runtimeNameValue,
219
+ "--agent-runtime-artifact",
220
+ `containerConfiguration={containerUri=${imageUri}}`,
221
+ "--role-arn",
222
+ canonicalAgentCoreRoleArn({ stage, runtime: "flue", accountId }),
223
+ "--network-configuration",
224
+ "networkMode=PUBLIC",
225
+ "--protocol-configuration",
226
+ "serverProtocol=HTTP",
227
+ "--query",
228
+ "agentRuntimeId",
229
+ "--output",
230
+ "text",
231
+ ]);
232
+ if (!runtimeId) {
233
+ throw new Error(`Failed to create ${runtimeNameValue}`);
234
+ }
235
+ run("aws", [
236
+ "ssm",
237
+ "put-parameter",
238
+ "--name",
239
+ `/thinkwork/${stage}/agentcore/runtime-id-flue`,
240
+ "--value",
241
+ runtimeId,
242
+ "--type",
243
+ "String",
244
+ "--overwrite",
245
+ "--region",
246
+ region,
247
+ ]);
248
+ waitForAgentCoreRuntime({ runtime: "flue", runtimeId, region, imageUri });
249
+ return {
250
+ runtime: "flue",
251
+ status: "created",
252
+ image: imageUri,
253
+ runtimeId,
254
+ };
255
+ }
256
+
257
+ function updateAgentCoreRuntime({
258
+ stage,
259
+ runtime,
260
+ runtimeId,
261
+ region,
262
+ accountId,
263
+ imageUri,
264
+ }) {
265
+ const current = awsJson([
266
+ "bedrock-agentcore-control",
267
+ "get-agent-runtime",
268
+ "--region",
269
+ region,
270
+ "--agent-runtime-id",
271
+ runtimeId,
272
+ "--output",
273
+ "json",
274
+ ]);
275
+ const roleArn = accountId
276
+ ? canonicalAgentCoreRoleArn({ stage, runtime, accountId })
277
+ : current.roleArn;
278
+ if (!roleArn) {
279
+ throw new Error(
280
+ `Existing ${runtime} runtime ${runtimeId} did not report roleArn`,
281
+ );
282
+ }
283
+ run("aws", [
284
+ "bedrock-agentcore-control",
285
+ "update-agent-runtime",
286
+ "--region",
287
+ region,
288
+ "--agent-runtime-id",
289
+ runtimeId,
290
+ "--role-arn",
291
+ roleArn,
292
+ "--network-configuration",
293
+ `networkMode=${current.networkConfiguration?.networkMode ?? "PUBLIC"}`,
294
+ "--protocol-configuration",
295
+ `serverProtocol=${current.protocolConfiguration?.serverProtocol ?? "HTTP"}`,
296
+ "--agent-runtime-artifact",
297
+ `containerConfiguration={containerUri=${imageUri}}`,
298
+ ]);
299
+ waitForAgentCoreRuntime({ runtime, runtimeId, region, imageUri });
300
+ return {
301
+ runtime,
302
+ status: "updated",
303
+ image: imageUri,
304
+ runtimeId,
305
+ roleArn,
306
+ };
307
+ }
308
+
309
+ function waitForAgentCoreRuntime({
310
+ runtime,
311
+ runtimeId,
312
+ region,
313
+ imageUri,
314
+ waitSeconds = 900,
315
+ }) {
316
+ const deadline = Date.now() + waitSeconds * 1000;
317
+ while (Date.now() < deadline) {
318
+ const detail = awsJson([
319
+ "bedrock-agentcore-control",
320
+ "get-agent-runtime",
321
+ "--region",
322
+ region,
323
+ "--agent-runtime-id",
324
+ runtimeId,
325
+ "--output",
326
+ "json",
327
+ ]);
328
+ const endpoints = awsJson([
329
+ "bedrock-agentcore-control",
330
+ "list-agent-runtime-endpoints",
331
+ "--region",
332
+ region,
333
+ "--agent-runtime-id",
334
+ runtimeId,
335
+ "--output",
336
+ "json",
337
+ ]);
338
+ const endpoint = endpoints.runtimeEndpoints?.find(
339
+ (item) => item.name === "DEFAULT",
340
+ );
341
+ const status = detail.status ?? "UNKNOWN";
342
+ const version = detail.agentRuntimeVersion ?? null;
343
+ const currentImage =
344
+ detail.agentRuntimeArtifact?.containerConfiguration?.containerUri ?? "";
345
+ const endpointStatus = endpoint?.status ?? "MISSING";
346
+ const liveVersion = endpoint?.liveVersion ?? null;
347
+ const targetVersion = endpoint?.targetVersion ?? null;
348
+
349
+ if (
350
+ status === "READY" &&
351
+ endpointStatus === "READY" &&
352
+ (targetVersion === null ||
353
+ targetVersion === "None" ||
354
+ targetVersion === undefined) &&
355
+ liveVersion === version &&
356
+ currentImage === imageUri
357
+ ) {
358
+ return;
359
+ }
360
+ console.log(
361
+ `Waiting for ${runtime} runtime ${runtimeId}: runtime=${status} endpoint=${endpointStatus} live=${liveVersion} target=${targetVersion} image=${currentImage}`,
362
+ );
363
+ run("sleep", ["15"]);
364
+ }
365
+ throw new Error(
366
+ `Timed out waiting for ${runtime} AgentCore runtime ${runtimeId} to serve ${imageUri}`,
367
+ );
368
+ }
369
+
370
+ function canonicalAgentCoreRoleArn({ stage, runtime, accountId }) {
371
+ const roleName =
372
+ runtime === "flue"
373
+ ? `thinkwork-${stage}-agentcore-flue-role`
374
+ : `thinkwork-${stage}-agentcore-role`;
375
+ return `arn:aws:iam::${accountId}:role/${roleName}`;
376
+ }
377
+
378
+ async function syncStaticSites() {
379
+ const manifest = await readManifest(required("--manifest"));
380
+ const workDir = required("--work-dir");
381
+ const terraformDir = required("--terraform-dir");
382
+ const prepared = await readJson(join(workDir, "prepared-artifacts.json"), {
383
+ artifacts: [],
384
+ });
385
+ const downloadByName = new Map(
386
+ prepared.artifacts.map((artifact) => [artifact.name, artifact.localPath]),
387
+ );
388
+ const synced = [];
389
+
390
+ for (const artifact of manifest.artifacts?.filter(
391
+ (item) => item.type === "static-site",
392
+ ) ?? []) {
393
+ const localPath = downloadByName.get(artifact.name);
394
+ if (!localPath) {
395
+ throw new Error(`Static artifact ${artifact.name} was not prepared`);
396
+ }
397
+ const extractDir = join(workDir, "static", artifact.name);
398
+ await rm(extractDir, { recursive: true, force: true });
399
+ await mkdir(extractDir, { recursive: true });
400
+ run("tar", ["-xzf", localPath, "-C", extractDir]);
401
+ const bucket = terraformOutput(
402
+ terraformDir,
403
+ `${artifact.name}_bucket_name`,
404
+ );
405
+ if (!bucket) {
406
+ throw new Error(
407
+ `Terraform output ${artifact.name}_bucket_name is missing`,
408
+ );
409
+ }
410
+ run("aws", ["s3", "sync", "--delete", extractDir, `s3://${bucket}/`]);
411
+ const distributionId = terraformOutput(
412
+ terraformDir,
413
+ `${artifact.name}_distribution_id`,
414
+ );
415
+ if (distributionId) {
416
+ run("aws", [
417
+ "cloudfront",
418
+ "create-invalidation",
419
+ "--distribution-id",
420
+ distributionId,
421
+ "--paths",
422
+ "/*",
423
+ ]);
424
+ }
425
+ synced.push({
426
+ name: artifact.name,
427
+ bucket,
428
+ distributionId: distributionId || null,
429
+ });
430
+ }
431
+
432
+ await writeJson(join(workDir, "static-sync.json"), synced);
433
+ }
434
+
435
+ async function recordOverlay() {
436
+ const stage = required("--stage");
437
+ const deployment = JSON.parse(
438
+ await readFile(required("--deployment"), "utf8"),
439
+ );
440
+ const workDir = required("--work-dir");
441
+ const stageConfig = deployment.stages?.[stage];
442
+ if (!stageConfig) {
443
+ throw new Error(`customer/deployment.json does not define stage ${stage}`);
444
+ }
445
+ await writeJson(join(workDir, "overlay-report.json"), {
446
+ status: "recorded",
447
+ stage,
448
+ tenantSlug: stageConfig.tenantSlug,
449
+ evalPacks: stageConfig.evalPacks ?? [],
450
+ seedPacks: stageConfig.seedPacks ?? [],
451
+ skillPacks: stageConfig.skillPacks ?? [],
452
+ workspaceDefaultPacks: stageConfig.workspaceDefaultPacks ?? [],
453
+ branding: stageConfig.branding ?? null,
454
+ });
455
+ }
456
+
457
+ async function writeSummary() {
458
+ const manifest = await readManifest(required("--manifest"));
459
+ const workDir = required("--work-dir");
460
+ const terraformDir = required("--terraform-dir");
461
+ const summary = {
462
+ stage: required("--stage"),
463
+ component: required("--component"),
464
+ release: manifest.release,
465
+ artifacts: await readJson(join(workDir, "prepared-artifacts.json"), null),
466
+ runtimeImages: await readJson(join(workDir, "runtime-images.json"), []),
467
+ runtimeUpdates: await readJson(join(workDir, "runtime-updates.json"), []),
468
+ staticSync: await readJson(join(workDir, "static-sync.json"), []),
469
+ overlay: await readJson(join(workDir, "overlay-report.json"), {
470
+ status: "not-run",
471
+ }),
472
+ smokes: await readJson(join(workDir, "smoke-summary.json"), {
473
+ status: "not-run",
474
+ }),
475
+ outputs: terraformOutputs(terraformDir),
476
+ };
477
+ await writeJson(required("--output"), summary);
478
+ }
479
+
480
+ async function downloadAndVerify(artifact, localPath) {
481
+ await mkdir(dirname(localPath), { recursive: true });
482
+ const response = await fetch(artifact.url);
483
+ if (!response.ok || !response.body) {
484
+ throw new Error(`Failed to download ${artifact.name}: ${response.status}`);
485
+ }
486
+ await writeFile(localPath, Buffer.from(await response.arrayBuffer()));
487
+ const actual = await sha256File(localPath);
488
+ if (actual !== artifact.sha256) {
489
+ throw new Error(`Checksum mismatch for ${artifact.name}: got ${actual}`);
490
+ }
491
+ }
492
+
493
+ function terraformOutputs(terraformDir) {
494
+ const names = [
495
+ "api_endpoint",
496
+ "admin_url",
497
+ "computer_url",
498
+ "docs_url",
499
+ "ecr_repository_url",
500
+ ];
501
+ return Object.fromEntries(
502
+ names.map((name) => [name, terraformOutput(terraformDir, name) || null]),
503
+ );
504
+ }
505
+
506
+ function terraformOutput(terraformDir, name) {
507
+ return safeText("terraform", [
508
+ "-chdir=" + terraformDir,
509
+ "output",
510
+ "-raw",
511
+ name,
512
+ ]);
513
+ }
514
+
515
+ function awsText(args) {
516
+ return safeText("aws", args);
517
+ }
518
+
519
+ function awsJson(args) {
520
+ const output = execFileSync("aws", args, {
521
+ encoding: "utf8",
522
+ stdio: ["ignore", "pipe", "inherit"],
523
+ }).trim();
524
+ return output ? JSON.parse(output) : {};
525
+ }
526
+
527
+ function safeText(commandName, commandArgs) {
528
+ try {
529
+ return execFileSync(commandName, commandArgs, {
530
+ encoding: "utf8",
531
+ stdio: ["ignore", "pipe", "ignore"],
532
+ }).trim();
533
+ } catch {
534
+ return "";
535
+ }
536
+ }
537
+
538
+ function run(commandName, commandArgs) {
539
+ execFileSync(commandName, commandArgs, { stdio: "inherit" });
540
+ }
541
+
542
+ async function readManifest(path) {
543
+ return JSON.parse(await readFile(path, "utf8"));
544
+ }
545
+
546
+ async function readJson(path, fallback) {
547
+ try {
548
+ return JSON.parse(await readFile(path, "utf8"));
549
+ } catch {
550
+ return fallback;
551
+ }
552
+ }
553
+
554
+ async function writeJson(path, value) {
555
+ await mkdir(dirname(path), { recursive: true });
556
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
557
+ }
558
+
559
+ async function sha256File(path) {
560
+ const hash = createHash("sha256");
561
+ const file = await readFile(path);
562
+ hash.update(file);
563
+ return hash.digest("hex");
564
+ }
565
+
566
+ function runtimeName(name) {
567
+ if (name.includes("flue")) return "flue";
568
+ if (name.includes("strands")) return "strands";
569
+ return name;
570
+ }
571
+
572
+ function sanitizeTag(value) {
573
+ return value.replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 128);
574
+ }
575
+
576
+ function parseArgs(argv) {
577
+ const parsed = { _: [] };
578
+ for (let index = 0; index < argv.length; index += 1) {
579
+ const arg = argv[index];
580
+ if (!arg.startsWith("--")) {
581
+ parsed._.push(arg);
582
+ continue;
583
+ }
584
+ const value = argv[index + 1];
585
+ if (!value || value.startsWith("--")) {
586
+ parsed[arg] = true;
587
+ } else {
588
+ parsed[arg] = value;
589
+ index += 1;
590
+ }
591
+ }
592
+ return parsed;
593
+ }
594
+
595
+ function required(flag) {
596
+ const value = args[flag];
597
+ if (typeof value !== "string" || value.length === 0) {
598
+ throw new Error(`${flag} is required`);
599
+ }
600
+ return value;
601
+ }
602
+
603
+ function optional(flag) {
604
+ const value = args[flag];
605
+ return typeof value === "string" ? value : undefined;
606
+ }
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ // thinkwork-managed: enterprise-deploy-template
3
+ import { execFileSync } from "node:child_process";
4
+ import { dirname } from "node:path";
5
+ import { mkdir, writeFile } from "node:fs/promises";
6
+
7
+ const args = parseArgs(process.argv.slice(2));
8
+
9
+ try {
10
+ const stage = required("--stage");
11
+ const terraformDir = required("--terraform-dir");
12
+ const outputPath = required("--summary");
13
+ const targets = [
14
+ ["api_endpoint", "api"],
15
+ ["admin_url", "admin"],
16
+ ["computer_url", "computer"],
17
+ ["docs_url", "docs"],
18
+ ]
19
+ .map(([output, name]) => ({
20
+ name,
21
+ url: terraformOutput(terraformDir, output),
22
+ }))
23
+ .filter((target) => target.url);
24
+
25
+ const results = [];
26
+ for (const target of targets) {
27
+ results.push(await probe(target));
28
+ }
29
+ const failed = results.filter((result) => result.status !== "ok");
30
+ const summary = {
31
+ status: failed.length === 0 ? "ok" : "degraded",
32
+ stage,
33
+ checkedAt: new Date().toISOString(),
34
+ results,
35
+ };
36
+ await mkdir(dirname(outputPath), { recursive: true });
37
+ await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
38
+ if (failed.length > 0) {
39
+ console.warn(
40
+ `Smoke checks degraded: ${failed.map((item) => item.name).join(", ")}`,
41
+ );
42
+ }
43
+ } catch (error) {
44
+ console.error(error instanceof Error ? error.message : String(error));
45
+ process.exit(1);
46
+ }
47
+
48
+ async function probe(target) {
49
+ try {
50
+ const response = await fetch(target.url, { method: "GET" });
51
+ return {
52
+ ...target,
53
+ status: response.ok || response.status < 500 ? "ok" : "error",
54
+ httpStatus: response.status,
55
+ };
56
+ } catch (error) {
57
+ return {
58
+ ...target,
59
+ status: "error",
60
+ error: error instanceof Error ? error.message : String(error),
61
+ };
62
+ }
63
+ }
64
+
65
+ function terraformOutput(terraformDir, name) {
66
+ try {
67
+ return execFileSync(
68
+ "terraform",
69
+ ["-chdir=" + terraformDir, "output", "-raw", name],
70
+ {
71
+ encoding: "utf8",
72
+ stdio: ["ignore", "pipe", "ignore"],
73
+ },
74
+ ).trim();
75
+ } catch {
76
+ return "";
77
+ }
78
+ }
79
+
80
+ function parseArgs(argv) {
81
+ const parsed = {};
82
+ for (let index = 0; index < argv.length; index += 1) {
83
+ const arg = argv[index];
84
+ const value = argv[index + 1];
85
+ if (arg.startsWith("--") && value && !value.startsWith("--")) {
86
+ parsed[arg] = value;
87
+ index += 1;
88
+ }
89
+ }
90
+ return parsed;
91
+ }
92
+
93
+ function required(flag) {
94
+ const value = args[flag];
95
+ if (typeof value !== "string" || value.length === 0) {
96
+ throw new Error(`${flag} is required`);
97
+ }
98
+ return value;
99
+ }
@@ -0,0 +1,6 @@
1
+ # thinkwork-managed: enterprise-deploy-template
2
+ bucket = "{{CUSTOMER_SLUG}}-thinkwork-terraform-state"
3
+ key = "thinkwork/{{STAGE}}/terraform.tfstate"
4
+ region = "{{REGION}}"
5
+ dynamodb_table = "{{CUSTOMER_SLUG}}-thinkwork-terraform-locks"
6
+ encrypt = true