@aws/ml-container-creator 0.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.
Files changed (143) hide show
  1. package/LICENSE +202 -0
  2. package/LICENSE-THIRD-PARTY +68620 -0
  3. package/NOTICE +2 -0
  4. package/README.md +106 -0
  5. package/bin/cli.js +365 -0
  6. package/config/defaults.json +32 -0
  7. package/config/presets/transformers-djl.json +26 -0
  8. package/config/presets/transformers-gpu.json +24 -0
  9. package/config/presets/transformers-lmi.json +27 -0
  10. package/package.json +129 -0
  11. package/servers/README.md +419 -0
  12. package/servers/base-image-picker/catalogs/model-servers.json +1191 -0
  13. package/servers/base-image-picker/catalogs/python-slim.json +38 -0
  14. package/servers/base-image-picker/catalogs/triton-backends.json +51 -0
  15. package/servers/base-image-picker/catalogs/triton.json +38 -0
  16. package/servers/base-image-picker/index.js +495 -0
  17. package/servers/base-image-picker/manifest.json +17 -0
  18. package/servers/base-image-picker/package.json +15 -0
  19. package/servers/hyperpod-cluster-picker/LICENSE +202 -0
  20. package/servers/hyperpod-cluster-picker/index.js +424 -0
  21. package/servers/hyperpod-cluster-picker/manifest.json +14 -0
  22. package/servers/hyperpod-cluster-picker/package.json +17 -0
  23. package/servers/instance-recommender/LICENSE +202 -0
  24. package/servers/instance-recommender/catalogs/instances.json +852 -0
  25. package/servers/instance-recommender/index.js +284 -0
  26. package/servers/instance-recommender/manifest.json +16 -0
  27. package/servers/instance-recommender/package.json +15 -0
  28. package/servers/lib/LICENSE +202 -0
  29. package/servers/lib/bedrock-client.js +160 -0
  30. package/servers/lib/custom-validators.js +46 -0
  31. package/servers/lib/dynamic-resolver.js +36 -0
  32. package/servers/lib/package.json +11 -0
  33. package/servers/lib/schemas/image-catalog.schema.json +185 -0
  34. package/servers/lib/schemas/instances.schema.json +124 -0
  35. package/servers/lib/schemas/manifest.schema.json +64 -0
  36. package/servers/lib/schemas/model-catalog.schema.json +91 -0
  37. package/servers/lib/schemas/regions.schema.json +26 -0
  38. package/servers/lib/schemas/triton-backends.schema.json +51 -0
  39. package/servers/model-picker/catalogs/jumpstart-public.json +66 -0
  40. package/servers/model-picker/catalogs/popular-diffusors.json +88 -0
  41. package/servers/model-picker/catalogs/popular-transformers.json +226 -0
  42. package/servers/model-picker/index.js +1693 -0
  43. package/servers/model-picker/manifest.json +18 -0
  44. package/servers/model-picker/package.json +20 -0
  45. package/servers/region-picker/LICENSE +202 -0
  46. package/servers/region-picker/catalogs/regions.json +263 -0
  47. package/servers/region-picker/index.js +230 -0
  48. package/servers/region-picker/manifest.json +16 -0
  49. package/servers/region-picker/package.json +15 -0
  50. package/src/app.js +1007 -0
  51. package/src/copy-tpl.js +77 -0
  52. package/src/lib/accelerator-validator.js +39 -0
  53. package/src/lib/asset-manager.js +385 -0
  54. package/src/lib/aws-profile-parser.js +181 -0
  55. package/src/lib/bootstrap-command-handler.js +1647 -0
  56. package/src/lib/bootstrap-config.js +238 -0
  57. package/src/lib/ci-register-helpers.js +124 -0
  58. package/src/lib/ci-report-helpers.js +158 -0
  59. package/src/lib/ci-stage-helpers.js +268 -0
  60. package/src/lib/cli-handler.js +529 -0
  61. package/src/lib/comment-generator.js +544 -0
  62. package/src/lib/community-reports-validator.js +91 -0
  63. package/src/lib/config-manager.js +2106 -0
  64. package/src/lib/configuration-exporter.js +204 -0
  65. package/src/lib/configuration-manager.js +695 -0
  66. package/src/lib/configuration-matcher.js +221 -0
  67. package/src/lib/cpu-validator.js +36 -0
  68. package/src/lib/cuda-validator.js +57 -0
  69. package/src/lib/deployment-config-resolver.js +103 -0
  70. package/src/lib/deployment-entry-schema.js +125 -0
  71. package/src/lib/deployment-registry.js +598 -0
  72. package/src/lib/docker-introspection-validator.js +51 -0
  73. package/src/lib/engine-prefix-resolver.js +60 -0
  74. package/src/lib/huggingface-client.js +172 -0
  75. package/src/lib/key-value-parser.js +37 -0
  76. package/src/lib/known-flags-validator.js +200 -0
  77. package/src/lib/manifest-cli.js +280 -0
  78. package/src/lib/mcp-client.js +303 -0
  79. package/src/lib/mcp-command-handler.js +532 -0
  80. package/src/lib/neuron-validator.js +80 -0
  81. package/src/lib/parameter-schema-validator.js +284 -0
  82. package/src/lib/prompt-runner.js +1349 -0
  83. package/src/lib/prompts.js +1138 -0
  84. package/src/lib/registry-command-handler.js +519 -0
  85. package/src/lib/registry-loader.js +198 -0
  86. package/src/lib/rocm-validator.js +80 -0
  87. package/src/lib/schema-validator.js +157 -0
  88. package/src/lib/sensitive-redactor.js +59 -0
  89. package/src/lib/template-engine.js +156 -0
  90. package/src/lib/template-manager.js +341 -0
  91. package/src/lib/validation-engine.js +314 -0
  92. package/src/prompt-adapter.js +63 -0
  93. package/templates/Dockerfile +300 -0
  94. package/templates/IAM_PERMISSIONS.md +84 -0
  95. package/templates/MIGRATION.md +488 -0
  96. package/templates/PROJECT_README.md +439 -0
  97. package/templates/TEMPLATE_SYSTEM.md +243 -0
  98. package/templates/buildspec.yml +64 -0
  99. package/templates/code/chat_template.jinja +1 -0
  100. package/templates/code/flask/gunicorn_config.py +35 -0
  101. package/templates/code/flask/wsgi.py +10 -0
  102. package/templates/code/model_handler.py +387 -0
  103. package/templates/code/serve +300 -0
  104. package/templates/code/serve.py +175 -0
  105. package/templates/code/serving.properties +105 -0
  106. package/templates/code/start_server.py +39 -0
  107. package/templates/code/start_server.sh +39 -0
  108. package/templates/diffusors/Dockerfile +72 -0
  109. package/templates/diffusors/patch_image_api.py +35 -0
  110. package/templates/diffusors/serve +115 -0
  111. package/templates/diffusors/start_server.sh +114 -0
  112. package/templates/do/.gitkeep +1 -0
  113. package/templates/do/README.md +541 -0
  114. package/templates/do/build +83 -0
  115. package/templates/do/ci +681 -0
  116. package/templates/do/clean +811 -0
  117. package/templates/do/config +260 -0
  118. package/templates/do/deploy +1560 -0
  119. package/templates/do/export +306 -0
  120. package/templates/do/logs +319 -0
  121. package/templates/do/manifest +12 -0
  122. package/templates/do/push +119 -0
  123. package/templates/do/register +580 -0
  124. package/templates/do/run +113 -0
  125. package/templates/do/submit +417 -0
  126. package/templates/do/test +1147 -0
  127. package/templates/hyperpod/configmap.yaml +24 -0
  128. package/templates/hyperpod/deployment.yaml +71 -0
  129. package/templates/hyperpod/pvc.yaml +42 -0
  130. package/templates/hyperpod/service.yaml +17 -0
  131. package/templates/nginx-diffusors.conf +74 -0
  132. package/templates/nginx-predictors.conf +47 -0
  133. package/templates/nginx-tensorrt.conf +74 -0
  134. package/templates/requirements.txt +61 -0
  135. package/templates/sample_model/test_inference.py +123 -0
  136. package/templates/sample_model/train_abalone.py +252 -0
  137. package/templates/test/test_endpoint.sh +79 -0
  138. package/templates/test/test_local_image.sh +80 -0
  139. package/templates/test/test_model_handler.py +180 -0
  140. package/templates/triton/Dockerfile +128 -0
  141. package/templates/triton/config.pbtxt +163 -0
  142. package/templates/triton/model.py +130 -0
  143. package/templates/triton/requirements.txt +11 -0
@@ -0,0 +1,1647 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Bootstrap Command Handler
6
+ *
7
+ * Handles the `bootstrap` CLI subcommand tree for provisioning shared
8
+ * AWS infrastructure (IAM role, ECR repository, S3 buckets) and
9
+ * persisting configuration to ~/.ml-container-creator/config.json.
10
+ *
11
+ * Subcommands:
12
+ * (no args) Interactive setup flow
13
+ * status Show active profile and resource state
14
+ * use <profile> Switch active bootstrap profile
15
+ * list List all bootstrap profiles
16
+ * remove <profile> [--force] Remove a bootstrap profile
17
+ */
18
+
19
+ import { execSync } from 'node:child_process'
20
+ import { existsSync, readFileSync, unlinkSync, writeFileSync, mkdirSync } from 'node:fs'
21
+ import path from 'node:path'
22
+ import { tmpdir } from 'node:os'
23
+ import { fileURLToPath } from 'node:url'
24
+ import BootstrapConfig from './bootstrap-config.js'
25
+ import AwsProfileParser from './aws-profile-parser.js'
26
+ import AssetManager from './asset-manager.js'
27
+ import { runPrompts } from '../prompt-adapter.js'
28
+
29
+ const __filename = fileURLToPath(import.meta.url)
30
+ const __dirname = path.dirname(__filename)
31
+
32
+ const STACK_NAME_PREFIX = 'mlcc-bootstrap'
33
+ const STACK_TEMPLATE_PATH = path.resolve(__dirname, '../../config/bootstrap-stack.json')
34
+
35
+ export default class BootstrapCommandHandler {
36
+ constructor({ promptFn } = {}) {
37
+ this.config = new BootstrapConfig()
38
+ this.profileParser = new AwsProfileParser()
39
+ this._promptFn = promptFn || runPrompts
40
+ }
41
+
42
+ /**
43
+ * Dispatch bootstrap subcommands.
44
+ * @param {string[]} args - Remaining positional args after 'bootstrap'
45
+ * @param {object} options - Parsed CLI options
46
+ */
47
+ async handle(args, options) {
48
+ if (args.length === 0) {
49
+ await this._handleInteractiveSetup(options)
50
+ return
51
+ }
52
+
53
+ const subcommand = args[0].toLowerCase()
54
+
55
+ switch (subcommand) {
56
+ case 'status':
57
+ await this._handleStatus(options)
58
+ break
59
+ case 'use':
60
+ await this._handleUse(args[1])
61
+ break
62
+ case 'list':
63
+ await this._handleList()
64
+ break
65
+ case 'remove':
66
+ await this._handleRemove(args[1], options)
67
+ break
68
+ case 'scan':
69
+ await this._handleScan()
70
+ break
71
+ case 'prune':
72
+ await this._handlePrune()
73
+ break
74
+ case 'update':
75
+ await this._handleUpdate(options)
76
+ break
77
+ default:
78
+ console.log(`Unknown bootstrap subcommand: ${subcommand}`)
79
+ this._showHelp()
80
+ break
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Interactive setup flow — provisions AWS resources and saves profile.
86
+ * @param {object} options - Parsed CLI options
87
+ */
88
+ async _handleInteractiveSetup(options) {
89
+ const nonInteractive = options['non-interactive']
90
+
91
+ // Non-interactive mode: validate required flags upfront
92
+ if (nonInteractive) {
93
+ const missingFlags = []
94
+ if (!options.profile) {
95
+ missingFlags.push('--profile')
96
+ }
97
+ if (!options.region) {
98
+ missingFlags.push('--region')
99
+ }
100
+ if (missingFlags.length > 0) {
101
+ console.log(`❌ Missing required flags for non-interactive mode: ${missingFlags.join(', ')}`)
102
+ return
103
+ }
104
+ }
105
+
106
+ console.log('\n🚀 Bootstrap — Shared AWS Infrastructure Setup\n')
107
+
108
+ // Determine bootstrap profile name
109
+ let profileName
110
+ if (nonInteractive) {
111
+ profileName = options.name || 'default'
112
+ } else {
113
+ const answer = await this._promptFn([{
114
+ type: 'input',
115
+ name: 'profileName',
116
+ message: 'Bootstrap profile name:',
117
+ default: 'default'
118
+ }])
119
+ profileName = answer.profileName
120
+ }
121
+
122
+ const profileData = {}
123
+
124
+ // Step 1: AWS profile selection
125
+ this._displayProgress('🔍', 'Selecting AWS profile...')
126
+ let awsProfile
127
+ if (nonInteractive) {
128
+ awsProfile = options.profile
129
+ } else {
130
+ awsProfile = await this._selectProfile(options)
131
+ }
132
+ profileData.awsProfile = awsProfile
133
+ this._currentProfile = awsProfile
134
+
135
+ // Step 2: Credential validation
136
+ this._displayProgress('🔑', 'Validating AWS credentials...')
137
+ const { accountId, region } = await this._validateCredentials(awsProfile, nonInteractive ? options.region : undefined)
138
+ profileData.accountId = accountId
139
+ profileData.awsRegion = region
140
+ this._currentRegion = region
141
+ this._currentAccountId = accountId
142
+
143
+ // Step 3: Determine stack parameters
144
+ let useExistingRoleArn = ''
145
+ if (nonInteractive && options['role-arn']) {
146
+ useExistingRoleArn = options['role-arn']
147
+ console.log(` Using provided IAM role ARN: ${options['role-arn']}`)
148
+ }
149
+
150
+ let createS3Buckets = false
151
+ if (nonInteractive && options['skip-s3']) {
152
+ console.log(' ⏭️ Skipping S3 bucket creation (--skip-s3)')
153
+ } else if (nonInteractive) {
154
+ createS3Buckets = true
155
+ } else {
156
+ const { useS3 } = await this._promptFn([{
157
+ type: 'confirm',
158
+ name: 'useS3',
159
+ message: 'Will you use async inference or batch transform?',
160
+ default: false
161
+ }])
162
+ createS3Buckets = useS3
163
+ }
164
+
165
+ // Step 4: Deploy CloudFormation stack
166
+ this._displayProgress('☁️', 'Deploying bootstrap infrastructure stack...')
167
+ const stackName = `${STACK_NAME_PREFIX}-${profileName}`
168
+
169
+ try {
170
+ const stackOutputs = this._deployStack(stackName, {
171
+ CreateS3Buckets: createS3Buckets ? 'true' : 'false',
172
+ UseExistingRoleArn: useExistingRoleArn
173
+ }, awsProfile, region)
174
+
175
+ // Read outputs into profile data
176
+ profileData.roleArn = stackOutputs.RoleArn
177
+ profileData.ecrRepositoryName = stackOutputs.EcrRepositoryName
178
+ profileData.stackName = stackName
179
+
180
+ if (stackOutputs.AsyncS3BucketName) {
181
+ profileData.asyncS3Bucket = stackOutputs.AsyncS3BucketName
182
+ }
183
+ if (stackOutputs.BatchS3BucketName) {
184
+ profileData.batchS3Bucket = stackOutputs.BatchS3BucketName
185
+ }
186
+
187
+ console.log(' ✅ Bootstrap stack deployed successfully')
188
+ } catch (error) {
189
+ console.log(` ❌ Stack deployment failed: ${error.message}`)
190
+ console.log(' Check the CloudFormation console for details:')
191
+ console.log(` https://console.aws.amazon.com/cloudformation/home?region=${region}#/stacks`)
192
+ return
193
+ }
194
+
195
+ // Step 5: CI Infrastructure setup (separate CDK stack — unchanged)
196
+ this._displayProgress('🧪', 'CI Testing Infrastructure...')
197
+ try {
198
+ let provisionCi = false
199
+
200
+ if (nonInteractive) {
201
+ if (options.ci) {
202
+ provisionCi = true
203
+ } else if (options['skip-ci']) {
204
+ console.log(' ⏭️ Skipping CI infrastructure (--skip-ci)')
205
+ provisionCi = false
206
+ } else {
207
+ provisionCi = false
208
+ }
209
+ } else {
210
+ const ciAnswer = await this._promptFn([{
211
+ type: 'confirm',
212
+ name: 'useCi',
213
+ message: 'Do you want CI testing infrastructure?',
214
+ default: false
215
+ }])
216
+ provisionCi = ciAnswer.useCi
217
+ }
218
+
219
+ if (provisionCi) {
220
+ // Ensure CDK is bootstrapped in this account/region
221
+ const cdkBootstrapped = this._resourceExists(
222
+ `ssm get-parameter --name /cdk-bootstrap/hnb659fds/version --region ${profileData.awsRegion}`,
223
+ profileData.awsProfile
224
+ )
225
+
226
+ if (!cdkBootstrapped) {
227
+ console.log(' 📦 CDK has not been bootstrapped in this account/region — bootstrapping now...')
228
+ try {
229
+ execSync(
230
+ `npx cdk bootstrap aws://${profileData.accountId}/${profileData.awsRegion}`,
231
+ {
232
+ encoding: 'utf8',
233
+ stdio: 'inherit',
234
+ env: {
235
+ ...process.env,
236
+ AWS_PROFILE: profileData.awsProfile
237
+ }
238
+ }
239
+ )
240
+ console.log(' ✅ CDK bootstrap complete')
241
+ } catch (cdkErr) {
242
+ console.log(` ❌ CDK bootstrap failed: ${cdkErr.message}`)
243
+ console.log(` Run manually: npx cdk bootstrap aws://${profileData.accountId}/${profileData.awsRegion} --profile ${profileData.awsProfile}`)
244
+ throw cdkErr
245
+ }
246
+ }
247
+
248
+ // Check if CI stack already exists — deploy or update
249
+ const ciStackExists = this._resourceExists(
250
+ `cloudformation describe-stacks --stack-name MlccCiHarnessStack --region ${profileData.awsRegion}`,
251
+ profileData.awsProfile
252
+ )
253
+
254
+ if (ciStackExists) {
255
+ console.log(' ✅ CI stack already deployed — updating if needed...')
256
+ } else {
257
+ console.log(' 🚀 Deploying CI harness stack...')
258
+ }
259
+
260
+ const ciHarnessDir = path.resolve(__dirname, '../../infra/ci-harness')
261
+
262
+ // Ensure dependencies are installed (handles cold starts / fresh clones)
263
+ execSync('npm install --silent', {
264
+ cwd: ciHarnessDir,
265
+ encoding: 'utf8',
266
+ stdio: ['pipe', 'pipe', 'pipe']
267
+ })
268
+
269
+ execSync(
270
+ `npx cdk deploy MlccCiHarnessStack --require-approval never`,
271
+ {
272
+ cwd: ciHarnessDir,
273
+ encoding: 'utf8',
274
+ stdio: 'inherit',
275
+ env: {
276
+ ...process.env,
277
+ CDK_DEFAULT_REGION: profileData.awsRegion,
278
+ CDK_DEFAULT_ACCOUNT: profileData.accountId,
279
+ AWS_PROFILE: profileData.awsProfile
280
+ }
281
+ }
282
+ )
283
+ console.log(' ✅ CI harness stack deployed')
284
+
285
+ profileData.ciInfraProvisioned = true
286
+ profileData.ciTableName = 'mlcc-ci-table'
287
+ }
288
+ } catch (error) {
289
+ console.log(`⚠️ CI infrastructure setup failed: ${error.message}`)
290
+ }
291
+
292
+ // Save profile to config
293
+ this.config.setProfile(profileName, profileData)
294
+ this._displayProgress('✅', `Profile "${profileName}" saved to config`)
295
+
296
+ // Display summary
297
+ this._displaySummary(profileName, profileData)
298
+ }
299
+
300
+ /**
301
+ * Display active bootstrap profile and resource state.
302
+ * @param {object} [options] - Parsed CLI options (e.g., --verify)
303
+ */
304
+ async _handleStatus(options = {}) {
305
+ const config = this.config.read()
306
+ if (!config) {
307
+ console.log('No bootstrap configuration found.')
308
+ console.log('Run `ml-container-creator bootstrap` to set up shared infrastructure.')
309
+ return
310
+ }
311
+
312
+ const profile = this.config.getActiveProfile()
313
+ if (!profile) {
314
+ console.log('No active bootstrap profile found.')
315
+ console.log('Run `ml-container-creator bootstrap` to set up shared infrastructure.')
316
+ return
317
+ }
318
+
319
+ const allProfiles = this.config.listProfiles()
320
+ console.log(`\n📋 Active Profile: ${profile.name} (${allProfiles.length} profile${allProfiles.length === 1 ? '' : 's'} total)`)
321
+ console.log('─'.repeat(40))
322
+
323
+ for (const [key, value] of Object.entries(profile.config)) {
324
+ console.log(` ${key}: ${value}`)
325
+ }
326
+
327
+ console.log('─'.repeat(40))
328
+
329
+ // Validate bootstrap stack
330
+ console.log('\n🔍 Resource Validation:')
331
+
332
+ const stackName = profile.config.stackName || `${STACK_NAME_PREFIX}-${profile.name}`
333
+
334
+ try {
335
+ const stackInfo = this._execAws(
336
+ `cloudformation describe-stacks --stack-name ${stackName} --region ${profile.config.awsRegion}`,
337
+ profile.config.awsProfile
338
+ )
339
+
340
+ const stack = stackInfo.Stacks && stackInfo.Stacks[0]
341
+ if (stack) {
342
+ const status = stack.StackStatus
343
+ const statusIcon = status === 'CREATE_COMPLETE' || status === 'UPDATE_COMPLETE' ? '✅' : '⚠️'
344
+ console.log(` ${statusIcon} Bootstrap stack: ${stackName} (${status})`)
345
+
346
+ // Show stack outputs
347
+ const outputs = {}
348
+ for (const output of (stack.Outputs || [])) {
349
+ outputs[output.OutputKey] = output.OutputValue
350
+ }
351
+
352
+ if (outputs.RoleArn) {
353
+ console.log(` ✅ IAM role: ${outputs.RoleArn.split('/').pop()}`)
354
+ }
355
+ if (outputs.EcrRepositoryName) {
356
+ console.log(` ✅ ECR repository: ${outputs.EcrRepositoryName}`)
357
+ }
358
+ if (outputs.AsyncS3BucketName) {
359
+ console.log(` ✅ S3 bucket (async): ${outputs.AsyncS3BucketName}`)
360
+ }
361
+ if (outputs.BatchS3BucketName) {
362
+ console.log(` ✅ S3 bucket (batch): ${outputs.BatchS3BucketName}`)
363
+ }
364
+ if (outputs.StackVersion) {
365
+ console.log(` 📋 Stack version: ${outputs.StackVersion}`)
366
+ }
367
+ }
368
+ } catch {
369
+ // Fall back to individual resource checks for profiles created before CloudFormation migration
370
+ console.log(` ⚠️ Bootstrap stack "${stackName}" not found — checking resources individually`)
371
+
372
+ try {
373
+ const defaultRoleName = 'mlcc-sagemaker-execution-role'
374
+ let roleName = defaultRoleName
375
+ if (profile.config.roleArn) {
376
+ const arnParts = profile.config.roleArn.split('/')
377
+ roleName = arnParts[arnParts.length - 1]
378
+ }
379
+
380
+ const roleExists = this._resourceExists(
381
+ `iam get-role --role-name ${roleName}`,
382
+ profile.config.awsProfile
383
+ )
384
+ if (roleExists) {
385
+ console.log(` ✅ IAM role: ${roleName}`)
386
+ } else {
387
+ console.log(` ⚠️ IAM role: ${roleName} — missing`)
388
+ }
389
+ } catch {
390
+ console.log(' ⚠️ IAM role: could not validate')
391
+ }
392
+
393
+ try {
394
+ const ecrExists = this._resourceExists(
395
+ `ecr describe-repositories --repository-names ml-container-creator --region ${profile.config.awsRegion}`,
396
+ profile.config.awsProfile
397
+ )
398
+ if (ecrExists) {
399
+ console.log(' ✅ ECR repository: ml-container-creator')
400
+ } else {
401
+ console.log(' ⚠️ ECR repository: ml-container-creator — missing')
402
+ }
403
+ } catch {
404
+ console.log(' ⚠️ ECR repository: could not validate')
405
+ }
406
+
407
+ if (profile.config.asyncS3Bucket) {
408
+ try {
409
+ const asyncExists = this._resourceExists(
410
+ `s3api head-bucket --bucket ${profile.config.asyncS3Bucket}`,
411
+ profile.config.awsProfile
412
+ )
413
+ console.log(asyncExists
414
+ ? ` ✅ S3 bucket: ${profile.config.asyncS3Bucket}`
415
+ : ` ⚠️ S3 bucket: ${profile.config.asyncS3Bucket} — missing`)
416
+ } catch {
417
+ console.log(` ⚠️ S3 bucket: ${profile.config.asyncS3Bucket} — could not validate`)
418
+ }
419
+ }
420
+
421
+ if (profile.config.batchS3Bucket) {
422
+ try {
423
+ const batchExists = this._resourceExists(
424
+ `s3api head-bucket --bucket ${profile.config.batchS3Bucket}`,
425
+ profile.config.awsProfile
426
+ )
427
+ console.log(batchExists
428
+ ? ` ✅ S3 bucket: ${profile.config.batchS3Bucket}`
429
+ : ` ⚠️ S3 bucket: ${profile.config.batchS3Bucket} — missing`)
430
+ } catch {
431
+ console.log(` ⚠️ S3 bucket: ${profile.config.batchS3Bucket} — could not validate`)
432
+ }
433
+ }
434
+ }
435
+
436
+ // Display deployed resources from manifest
437
+ console.log('\n📦 Deployed Resources:')
438
+
439
+ const assetManager = new AssetManager(profile.name)
440
+
441
+ if (!existsSync(assetManager.manifestPath)) {
442
+ console.log(' No deployment tracking data available.')
443
+ console.log(' Resources will be tracked after running deploy, push, or submit scripts.')
444
+ return
445
+ }
446
+
447
+ const resourcesByProject = assetManager.getResourcesByProject()
448
+
449
+ if (resourcesByProject.size === 0) {
450
+ console.log(' No deployed resources tracked.')
451
+ return
452
+ }
453
+
454
+ for (const [project, resources] of resourcesByProject) {
455
+ console.log(`\n Project: ${project}`)
456
+ for (const resource of resources) {
457
+ const timestamp = resource.createdAt || resource.lastUpdatedAt
458
+ console.log(` ${resource.resourceType} ${resource.resourceId} [${resource.status}] ${timestamp}`)
459
+ }
460
+ }
461
+
462
+ const counts = assetManager.getStatusCounts()
463
+ console.log(`\n Summary: ${counts.active} active, ${counts.deleted} deleted, ${counts.unknown} unknown`)
464
+
465
+ // Drift detection if --verify flag is set
466
+ if (options.verify) {
467
+ await this._handleStatusVerify(profile, assetManager)
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Perform drift detection for active resources.
473
+ * @param {object} profile - Active profile object with name and config
474
+ * @param {AssetManager} assetManager - AssetManager instance for the profile
475
+ */
476
+ async _handleStatusVerify(profile, assetManager) {
477
+ console.log('\n🔎 Drift Detection:')
478
+
479
+ const activeResources = assetManager.listResources({ status: 'active' })
480
+
481
+ if (activeResources.length === 0) {
482
+ console.log(' No active resources to verify.')
483
+ return
484
+ }
485
+
486
+ let verified = 0
487
+ let drifted = 0
488
+ let unchecked = 0
489
+
490
+ for (const resource of activeResources) {
491
+ const checkCommand = this._buildDriftCheckCommand(resource)
492
+
493
+ if (!checkCommand) {
494
+ unchecked++
495
+ continue
496
+ }
497
+
498
+ try {
499
+ const exists = this._resourceExists(checkCommand, profile.config.awsProfile)
500
+
501
+ if (exists) {
502
+ verified++
503
+ console.log(` ✅ ${resource.resourceType}: ${resource.resourceId}`)
504
+ } else {
505
+ drifted++
506
+ assetManager.updateStatus(resource.resourceId, 'unknown')
507
+ console.log(` ⚠️ ${resource.resourceType}: ${resource.resourceId} — not found (status updated to unknown)`)
508
+ }
509
+ } catch {
510
+ unchecked++
511
+ console.log(` ⚠️ ${resource.resourceType}: ${resource.resourceId} — could not verify (credentials or API unavailable)`)
512
+ }
513
+ }
514
+
515
+ console.log(`\n Drift Summary: ${verified} verified, ${drifted} drifted, ${unchecked} unchecked`)
516
+ }
517
+
518
+ /**
519
+ * Build the AWS CLI command to check if a resource still exists.
520
+ * @param {object} resource - Asset record
521
+ * @returns {string|null} AWS CLI command string, or null if resource type is not supported
522
+ */
523
+ _buildDriftCheckCommand(resource) {
524
+ const resourceId = resource.resourceId
525
+
526
+ switch (resource.resourceType) {
527
+ case 'sagemaker-endpoint': {
528
+ const name = this._extractNameFromArn(resourceId)
529
+ return `sagemaker describe-endpoint --endpoint-name ${name}`
530
+ }
531
+ case 'sagemaker-model': {
532
+ const name = this._extractNameFromArn(resourceId)
533
+ return `sagemaker describe-model --model-name ${name}`
534
+ }
535
+ case 'sagemaker-inference-component': {
536
+ const name = this._extractNameFromArn(resourceId)
537
+ return `sagemaker describe-inference-component --inference-component-name ${name}`
538
+ }
539
+ case 'ecr-image': {
540
+ // resourceId is a full image URI like 111111111111.dkr.ecr.us-east-1.amazonaws.com/repo:tag
541
+ const parts = resourceId.split('/')
542
+ const repoAndTag = parts[parts.length - 1]
543
+ const [repo, tag] = repoAndTag.split(':')
544
+ return `ecr describe-images --repository-name ${repo} --image-ids imageTag=${tag || 'latest'}`
545
+ }
546
+ case 'codebuild-project': {
547
+ const name = this._extractNameFromArn(resourceId)
548
+ return `codebuild batch-get-projects --names ${name}`
549
+ }
550
+ case 'iam-role': {
551
+ const name = this._extractNameFromArn(resourceId)
552
+ return `iam get-role --role-name ${name}`
553
+ }
554
+ default:
555
+ return null
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Extract the resource name from an ARN.
561
+ * ARN format: arn:aws:service:region:account:resource-type/resource-name
562
+ * @param {string} arn - AWS ARN string
563
+ * @returns {string} The resource name portion
564
+ */
565
+ _extractNameFromArn(arn) {
566
+ // Handle ARN formats like:
567
+ // arn:aws:sagemaker:us-east-1:111111111111:endpoint/my-endpoint
568
+ // arn:aws:iam::111111111111:role/my-role
569
+ // arn:aws:codebuild:us-east-1:111111111111:project/my-project
570
+ const parts = arn.split('/')
571
+ return parts[parts.length - 1]
572
+ }
573
+
574
+ /**
575
+ * Switch the active bootstrap profile.
576
+ * @param {string} profileName - Profile name to activate
577
+ */
578
+ async _handleUse(profileName) {
579
+ if (!profileName) {
580
+ console.log('Usage: ml-container-creator bootstrap use <profile>')
581
+ console.log(' ml-container-creator bootstrap use none (deactivate)')
582
+ return
583
+ }
584
+
585
+ if (profileName === 'none') {
586
+ this.config.setActiveProfile(null)
587
+ console.log('Active profile cleared. No bootstrap profile is active.')
588
+ return
589
+ }
590
+
591
+ const profile = this.config.getProfile(profileName)
592
+ if (!profile) {
593
+ const available = this.config.listProfiles()
594
+ console.log(`Profile "${profileName}" not found.`)
595
+ if (available.length > 0) {
596
+ console.log(`Available profiles: ${available.join(', ')}`)
597
+ } else {
598
+ console.log('No profiles configured. Run `ml-container-creator bootstrap` to create one.')
599
+ }
600
+ return
601
+ }
602
+
603
+ this.config.setActiveProfile(profileName)
604
+ console.log(`Switched active profile to "${profileName}".`)
605
+ }
606
+
607
+ /**
608
+ * List all bootstrap profiles.
609
+ */
610
+ async _handleList() {
611
+ const profiles = this.config.listProfiles()
612
+
613
+ if (profiles.length === 0) {
614
+ console.log('No bootstrap profiles configured.')
615
+ console.log('Run `ml-container-creator bootstrap` to set up shared infrastructure.')
616
+ return
617
+ }
618
+
619
+ const config = this.config.read()
620
+ const activeProfileName = config ? config.activeProfile : null
621
+
622
+ console.log('\nBootstrap Profiles:')
623
+ for (const name of profiles) {
624
+ if (name === activeProfileName) {
625
+ console.log(` * ${name} (active)`)
626
+ } else {
627
+ console.log(` ${name}`)
628
+ }
629
+ }
630
+ }
631
+
632
+ /**
633
+ * Remove a bootstrap profile.
634
+ * @param {string} profileName - Profile name to remove
635
+ * @param {object} options - Parsed CLI options (e.g., --force)
636
+ */
637
+ async _handleRemove(profileName, options) {
638
+ if (!profileName) {
639
+ console.log('Usage: ml-container-creator bootstrap remove <profile> [--force]')
640
+ return
641
+ }
642
+
643
+ const profile = this.config.getProfile(profileName)
644
+ if (!profile) {
645
+ console.log(`Profile "${profileName}" not found.`)
646
+ return
647
+ }
648
+
649
+ // Check for manifest file with active resources
650
+ const assetManager = new AssetManager(profileName)
651
+ const hasManifest = existsSync(assetManager.manifestPath)
652
+
653
+ if (hasManifest) {
654
+ const counts = assetManager.getStatusCounts()
655
+ if (counts.active > 0 && !options.force) {
656
+ console.log(`⚠️ Profile "${profileName}" has ${counts.active} active resource${counts.active === 1 ? '' : 's'} in the deployment manifest.`)
657
+ }
658
+ }
659
+
660
+ // Check for CloudFormation stack
661
+ const stackName = profile.stackName || `${STACK_NAME_PREFIX}-${profileName}`
662
+ let hasStack = false
663
+ try {
664
+ hasStack = this._resourceExists(
665
+ `cloudformation describe-stacks --stack-name ${stackName} --region ${profile.awsRegion}`,
666
+ profile.awsProfile
667
+ )
668
+ } catch {
669
+ // ignore
670
+ }
671
+
672
+ if (hasStack && !options.force) {
673
+ console.log(`⚠️ Profile "${profileName}" has a CloudFormation stack: ${stackName}`)
674
+ console.log(' Use --delete-stack to also delete the AWS resources, or --force to remove the profile only.')
675
+ }
676
+
677
+ if (!options.force) {
678
+ const { confirm } = await this._promptFn([{
679
+ type: 'confirm',
680
+ name: 'confirm',
681
+ message: `Remove bootstrap profile "${profileName}"?`,
682
+ default: false
683
+ }])
684
+
685
+ if (!confirm) {
686
+ console.log('Removal cancelled.')
687
+ return
688
+ }
689
+ }
690
+
691
+ // Delete CloudFormation stack if requested
692
+ if (hasStack && options['delete-stack']) {
693
+ try {
694
+ console.log(`🗑️ Deleting CloudFormation stack: ${stackName}`)
695
+ execSync(
696
+ `aws cloudformation delete-stack --stack-name ${stackName} --region ${profile.awsRegion} --profile ${profile.awsProfile}`,
697
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
698
+ )
699
+ console.log(`⏳ Waiting for stack deletion...`)
700
+ execSync(
701
+ `aws cloudformation wait stack-delete-complete --stack-name ${stackName} --region ${profile.awsRegion} --profile ${profile.awsProfile}`,
702
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
703
+ )
704
+ console.log(`✅ Stack "${stackName}" deleted.`)
705
+ } catch (err) {
706
+ console.log(`⚠️ Could not delete stack "${stackName}": ${err.message}`)
707
+ console.log(' You may need to delete it manually from the CloudFormation console.')
708
+ }
709
+ } else if (hasStack) {
710
+ console.log(`Note: CloudFormation stack "${stackName}" was left in place.`)
711
+ console.log(' To delete AWS resources, re-run with --delete-stack')
712
+ }
713
+
714
+ // Delete manifest file if it exists
715
+ if (hasManifest) {
716
+ try {
717
+ unlinkSync(assetManager.manifestPath)
718
+ console.log(`Manifest file for "${profileName}" deleted.`)
719
+ } catch {
720
+ console.log(`⚠️ Could not delete manifest file for "${profileName}".`)
721
+ }
722
+ }
723
+
724
+ this.config.removeProfile(profileName)
725
+ console.log(`Profile "${profileName}" removed.`)
726
+ }
727
+
728
+ /**
729
+ * Scan AWS for pre-existing MLCC-managed resources and add them to the manifest.
730
+ */
731
+ async _handleScan() {
732
+ const profile = this.config.getActiveProfile()
733
+ if (!profile) {
734
+ console.log('No active bootstrap profile found.')
735
+ console.log('Run `ml-container-creator bootstrap` to set up shared infrastructure.')
736
+ return
737
+ }
738
+
739
+ console.log(`\n🔍 Scanning for pre-existing resources in ${profile.config.awsRegion}...`)
740
+
741
+ const assetManager = new AssetManager(profile.name)
742
+ const now = new Date().toISOString()
743
+ let discovered = 0
744
+ let added = 0
745
+ let skipped = 0
746
+
747
+ // 1. Query Resource Groups Tagging API for mlcc:managed-by tagged resources
748
+ try {
749
+ console.log('\n Checking tagged resources...')
750
+ const tagResult = this._execAws(
751
+ `resourcegroupstaggingapi get-resources --tag-filters Key=mlcc:managed-by,Values=ml-container-creator --region ${profile.config.awsRegion}`,
752
+ profile.config.awsProfile
753
+ )
754
+
755
+ const taggedResources = tagResult.ResourceTagMappingList || []
756
+ for (const tagged of taggedResources) {
757
+ discovered++
758
+ const arn = tagged.ResourceARN
759
+ const existing = assetManager.getResource(arn)
760
+ if (existing) {
761
+ skipped++
762
+ continue
763
+ }
764
+
765
+ const resourceType = this._inferResourceTypeFromArn(arn)
766
+ if (!resourceType) {
767
+ skipped++
768
+ continue
769
+ }
770
+
771
+ const project = this._inferProjectFromTags(tagged.Tags) || 'unknown'
772
+
773
+ try {
774
+ assetManager.addResource({
775
+ resourceId: arn,
776
+ resourceType,
777
+ createdAt: now,
778
+ lastUpdatedAt: now,
779
+ project,
780
+ status: 'active',
781
+ metadata: { discoveredBy: 'scan' }
782
+ })
783
+ added++
784
+ } catch {
785
+ skipped++
786
+ }
787
+ }
788
+ } catch {
789
+ console.log(' ⚠️ Could not query tagged resources (credentials or API unavailable)')
790
+ }
791
+
792
+ // 2. Query ECR for images in ml-container-creator repository
793
+ try {
794
+ console.log(' Checking ECR images...')
795
+ const ecrResult = this._execAws(
796
+ `ecr describe-images --repository-name ml-container-creator --region ${profile.config.awsRegion}`,
797
+ profile.config.awsProfile
798
+ )
799
+
800
+ const images = ecrResult.imageDetails || []
801
+ for (const image of images) {
802
+ const tags = image.imageTags || []
803
+ for (const tag of tags) {
804
+ discovered++
805
+ const imageUri = `${profile.config.accountId}.dkr.ecr.${profile.config.awsRegion}.amazonaws.com/ml-container-creator:${tag}`
806
+ const existing = assetManager.getResource(imageUri)
807
+ if (existing) {
808
+ skipped++
809
+ continue
810
+ }
811
+
812
+ try {
813
+ assetManager.addResource({
814
+ resourceId: imageUri,
815
+ resourceType: 'ecr-image',
816
+ createdAt: now,
817
+ lastUpdatedAt: now,
818
+ project: this._inferProjectFromImageTag(tag),
819
+ status: 'active',
820
+ metadata: {
821
+ repositoryName: 'ml-container-creator',
822
+ imageTag: tag,
823
+ region: profile.config.awsRegion,
824
+ discoveredBy: 'scan'
825
+ }
826
+ })
827
+ added++
828
+ } catch {
829
+ skipped++
830
+ }
831
+ }
832
+ }
833
+ } catch {
834
+ console.log(' ⚠️ Could not query ECR images (credentials or API unavailable)')
835
+ }
836
+
837
+ // 3. Query CodeBuild for *-build-* projects
838
+ try {
839
+ console.log(' Checking CodeBuild projects...')
840
+ const cbResult = this._execAws(
841
+ `codebuild list-projects --region ${profile.config.awsRegion}`,
842
+ profile.config.awsProfile
843
+ )
844
+
845
+ const projects = (cbResult.projects || []).filter(name => name.includes('-build-'))
846
+ for (const projectName of projects) {
847
+ discovered++
848
+ const arn = `arn:aws:codebuild:${profile.config.awsRegion}:${profile.config.accountId}:project/${projectName}`
849
+ const existing = assetManager.getResource(arn)
850
+ if (existing) {
851
+ skipped++
852
+ continue
853
+ }
854
+
855
+ try {
856
+ assetManager.addResource({
857
+ resourceId: arn,
858
+ resourceType: 'codebuild-project',
859
+ createdAt: now,
860
+ lastUpdatedAt: now,
861
+ project: this._inferProjectFromCodeBuildName(projectName),
862
+ status: 'active',
863
+ metadata: {
864
+ projectName,
865
+ region: profile.config.awsRegion,
866
+ discoveredBy: 'scan'
867
+ }
868
+ })
869
+ added++
870
+ } catch {
871
+ skipped++
872
+ }
873
+ }
874
+ } catch {
875
+ console.log(' ⚠️ Could not query CodeBuild projects (credentials or API unavailable)')
876
+ }
877
+
878
+ // Display summary
879
+ console.log(`\n Scan complete: ${discovered} discovered, ${added} added, ${skipped} skipped (duplicates or unsupported)`)
880
+
881
+ if (discovered === 0) {
882
+ console.log(' No MLCC-managed resources were discovered.')
883
+ }
884
+ }
885
+
886
+ /**
887
+ * Prune stale records from the manifest — removes entries with status
888
+ * 'deleted' or 'unknown' that are no longer useful.
889
+ */
890
+ async _handlePrune() {
891
+ const profile = this.config.getActiveProfile()
892
+ if (!profile) {
893
+ console.log('No active bootstrap profile found.')
894
+ return
895
+ }
896
+
897
+ const assetManager = new AssetManager(profile.name)
898
+
899
+ if (!existsSync(assetManager.manifestPath)) {
900
+ console.log('No deployment tracking data to prune.')
901
+ return
902
+ }
903
+
904
+ const before = assetManager.listResources()
905
+ const toRemove = before.filter(r => r.status === 'deleted' || r.status === 'unknown')
906
+
907
+ if (toRemove.length === 0) {
908
+ console.log('Nothing to prune — no deleted or unknown records found.')
909
+ return
910
+ }
911
+
912
+ console.log(`\n🧹 Pruning ${toRemove.length} stale record${toRemove.length === 1 ? '' : 's'}:\n`)
913
+
914
+ for (const resource of toRemove) {
915
+ assetManager.removeResource(resource.resourceId)
916
+ console.log(` 🗑️ [${resource.status}] ${resource.resourceType}: ${resource.resourceId}`)
917
+ }
918
+
919
+ const after = assetManager.listResources()
920
+ console.log(`\n Done. ${toRemove.length} removed, ${after.length} remaining.`)
921
+ }
922
+
923
+ /**
924
+ * Re-deploy bootstrap infrastructure using the active profile.
925
+ * No prompts — reads all config from the existing profile and re-applies
926
+ * the CloudFormation stack and optionally the CI CDK stack.
927
+ *
928
+ * @param {object} [options] - Parsed CLI options (e.g., --ci to force CI update)
929
+ */
930
+ async _handleUpdate(options = {}) {
931
+ const profile = this.config.getActiveProfile()
932
+ if (!profile) {
933
+ console.log('No active bootstrap profile found.')
934
+ console.log('Run `ml-container-creator bootstrap` to set up shared infrastructure first.')
935
+ return
936
+ }
937
+
938
+ const { name, config: profileConfig } = profile
939
+ console.log(`\n🔄 Updating bootstrap infrastructure for profile "${name}"`)
940
+ console.log(` Region: ${profileConfig.awsRegion}`)
941
+ console.log(` Account: ${profileConfig.accountId}`)
942
+
943
+ // Re-deploy the CloudFormation bootstrap stack
944
+ const stackName = profileConfig.stackName || `${STACK_NAME_PREFIX}-${name}`
945
+ this._displayProgress('☁️', 'Updating bootstrap stack...')
946
+
947
+ try {
948
+ const stackOutputs = this._deployStack(stackName, {
949
+ CreateS3Buckets: (profileConfig.asyncS3Bucket || profileConfig.batchS3Bucket) ? 'true' : 'false',
950
+ UseExistingRoleArn: ''
951
+ }, profileConfig.awsProfile, profileConfig.awsRegion)
952
+
953
+ // Update profile with any new outputs
954
+ if (stackOutputs.RoleArn) profileConfig.roleArn = stackOutputs.RoleArn
955
+ if (stackOutputs.EcrRepositoryName) profileConfig.ecrRepositoryName = stackOutputs.EcrRepositoryName
956
+ if (stackOutputs.AsyncS3BucketName) profileConfig.asyncS3Bucket = stackOutputs.AsyncS3BucketName
957
+ if (stackOutputs.BatchS3BucketName) profileConfig.batchS3Bucket = stackOutputs.BatchS3BucketName
958
+ profileConfig.stackName = stackName
959
+
960
+ console.log(' ✅ Bootstrap stack updated')
961
+ } catch (error) {
962
+ console.log(` ❌ Stack update failed: ${error.message}`)
963
+ }
964
+
965
+ // Re-deploy CI stack if it was provisioned or --ci flag is set
966
+ const shouldUpdateCi = profileConfig.ciInfraProvisioned || options.ci
967
+ if (shouldUpdateCi) {
968
+ this._displayProgress('🧪', 'Updating CI harness stack...')
969
+
970
+ try {
971
+ const ciHarnessDir = path.resolve(__dirname, '../../infra/ci-harness')
972
+
973
+ // Ensure dependencies are installed (handles cold starts / fresh clones)
974
+ execSync('npm install --silent', {
975
+ cwd: ciHarnessDir,
976
+ encoding: 'utf8',
977
+ stdio: ['pipe', 'pipe', 'pipe']
978
+ })
979
+
980
+ execSync(
981
+ `npx cdk deploy MlccCiHarnessStack --require-approval never`,
982
+ {
983
+ cwd: ciHarnessDir,
984
+ encoding: 'utf8',
985
+ stdio: 'inherit',
986
+ env: {
987
+ ...process.env,
988
+ CDK_DEFAULT_REGION: profileConfig.awsRegion,
989
+ CDK_DEFAULT_ACCOUNT: profileConfig.accountId,
990
+ AWS_PROFILE: profileConfig.awsProfile
991
+ }
992
+ }
993
+ )
994
+ profileConfig.ciInfraProvisioned = true
995
+ console.log(' ✅ CI harness stack updated')
996
+ } catch (error) {
997
+ console.log(` ❌ CI stack update failed: ${error.message}`)
998
+ }
999
+ } else {
1000
+ console.log(' ⏭️ CI stack skipped (not provisioned — use --ci to force)')
1001
+ }
1002
+
1003
+ // Save updated profile
1004
+ this.config.setProfile(name, profileConfig)
1005
+ console.log(`\n✅ Update complete for profile "${name}"`)
1006
+ }
1007
+
1008
+ /**
1009
+ * Infer the resource type from an ARN.
1010
+ * @param {string} arn - AWS ARN
1011
+ * @returns {string|null} Resource type or null if not recognized
1012
+ */
1013
+ _inferResourceTypeFromArn(arn) {
1014
+ if (arn.includes(':endpoint/')) return 'sagemaker-endpoint'
1015
+ if (arn.includes(':endpoint-config/')) return 'sagemaker-endpoint-config'
1016
+ if (arn.includes(':model/')) return 'sagemaker-model'
1017
+ if (arn.includes(':inference-component/')) return 'sagemaker-inference-component'
1018
+ if (arn.includes(':transform-job/')) return 'sagemaker-transform-job'
1019
+ if (arn.includes(':project/')) return 'codebuild-project'
1020
+ if (arn.includes(':role/')) return 'iam-role'
1021
+ if (arn.includes(':topic')) return 'sns-topic'
1022
+ return null
1023
+ }
1024
+
1025
+ /**
1026
+ * Infer the project name from resource tags.
1027
+ * @param {Array<{Key: string, Value: string}>} tags - Resource tags
1028
+ * @returns {string|null} Project name or null
1029
+ */
1030
+ _inferProjectFromTags(tags) {
1031
+ if (!tags) return null
1032
+ const projectTag = tags.find(t => t.Key === 'mlcc:project' || t.Key === 'project')
1033
+ return projectTag ? projectTag.Value : null
1034
+ }
1035
+
1036
+ /**
1037
+ * Infer the project name from an ECR image tag.
1038
+ * @param {string} tag - Image tag (e.g., "my-project-latest")
1039
+ * @returns {string} Project name
1040
+ */
1041
+ _inferProjectFromImageTag(tag) {
1042
+ // Tags often follow pattern: project-name-suffix
1043
+ // Best effort: use the tag itself as project identifier
1044
+ return tag.replace(/-latest$/, '').replace(/-\d+$/, '') || 'unknown'
1045
+ }
1046
+
1047
+ /**
1048
+ * Infer the project name from a CodeBuild project name.
1049
+ * @param {string} name - CodeBuild project name (e.g., "my-project-build-xyz")
1050
+ * @returns {string} Project name
1051
+ */
1052
+ _inferProjectFromCodeBuildName(name) {
1053
+ // Pattern: {project}-build-{suffix}
1054
+ const match = name.match(/^(.+?)-build-/)
1055
+ return match ? match[1] : name
1056
+ }
1057
+
1058
+ // ── Provisioning steps ──────────────────────────────────────────
1059
+
1060
+ /**
1061
+ * Prompt user to select an AWS profile.
1062
+ * @param {object} options - Parsed CLI options
1063
+ * @returns {Promise<string>} Selected AWS profile name
1064
+ */
1065
+ async _selectProfile(options) {
1066
+ const profiles = this.profileParser.getProfiles()
1067
+
1068
+ if (profiles.length === 0) {
1069
+ console.log('❌ No AWS profiles found. Run `aws configure` first.')
1070
+ throw new Error('No AWS profiles found. Run `aws configure` first.')
1071
+ }
1072
+
1073
+ const defaultProfile = profiles.includes('default') ? 'default' : profiles[0]
1074
+
1075
+ const { awsProfile } = await this._promptFn([{
1076
+ type: 'list',
1077
+ name: 'awsProfile',
1078
+ message: 'Select an AWS profile:',
1079
+ choices: profiles,
1080
+ default: defaultProfile
1081
+ }])
1082
+
1083
+ return awsProfile
1084
+ }
1085
+
1086
+ /**
1087
+ * Validate AWS credentials via STS and extract account ID.
1088
+ * @param {string} profile - AWS profile name
1089
+ * @param {string} [providedRegion] - Optional region to use (skips prompt when provided)
1090
+ * @returns {Promise<object>} Object with accountId and region
1091
+ */
1092
+ async _validateCredentials(profile, providedRegion) {
1093
+ const identity = this._execAws('sts get-caller-identity', profile)
1094
+ const accountId = identity.Account
1095
+
1096
+ let region
1097
+ if (providedRegion) {
1098
+ region = providedRegion
1099
+ } else {
1100
+ const answer = await this._promptFn([{
1101
+ type: 'input',
1102
+ name: 'region',
1103
+ message: 'AWS region for resources:',
1104
+ default: 'us-east-1'
1105
+ }])
1106
+ region = answer.region
1107
+ }
1108
+
1109
+ return { accountId, region }
1110
+ }
1111
+
1112
+ /**
1113
+ * Create or reuse the SageMaker execution IAM role.
1114
+ * @param {object} options - Parsed CLI options
1115
+ * @returns {Promise<string>} Role ARN
1116
+ */
1117
+ async _setupIamRole(options) {
1118
+ const roleName = 'mlcc-sagemaker-execution-role'
1119
+
1120
+ // Define trust policy for SageMaker
1121
+ const trustPolicy = {
1122
+ Version: '2012-10-17',
1123
+ Statement: [
1124
+ {
1125
+ Effect: 'Allow',
1126
+ Principal: {
1127
+ Service: 'sagemaker.amazonaws.com'
1128
+ },
1129
+ Action: 'sts:AssumeRole'
1130
+ }
1131
+ ]
1132
+ }
1133
+
1134
+ // Define execution policy with least-privilege permissions
1135
+ const executionPolicy = {
1136
+ Version: '2012-10-17',
1137
+ Statement: [
1138
+ {
1139
+ Sid: 'SageMakerEndpoints',
1140
+ Effect: 'Allow',
1141
+ Action: [
1142
+ 'sagemaker:CreateEndpoint',
1143
+ 'sagemaker:CreateEndpointConfig',
1144
+ 'sagemaker:CreateModel',
1145
+ 'sagemaker:CreateInferenceComponent',
1146
+ 'sagemaker:UpdateEndpoint',
1147
+ 'sagemaker:UpdateEndpointWeightsAndCapacities',
1148
+ 'sagemaker:UpdateInferenceComponent',
1149
+ 'sagemaker:DeleteEndpoint',
1150
+ 'sagemaker:DeleteEndpointConfig',
1151
+ 'sagemaker:DeleteModel',
1152
+ 'sagemaker:DeleteInferenceComponent',
1153
+ 'sagemaker:DescribeEndpoint',
1154
+ 'sagemaker:DescribeEndpointConfig',
1155
+ 'sagemaker:DescribeModel',
1156
+ 'sagemaker:DescribeInferenceComponent',
1157
+ 'sagemaker:InvokeEndpoint',
1158
+ 'sagemaker:InvokeEndpointAsync'
1159
+ ],
1160
+ Resource: '*'
1161
+ },
1162
+ {
1163
+ Sid: 'ECRPull',
1164
+ Effect: 'Allow',
1165
+ Action: [
1166
+ 'ecr:GetAuthorizationToken',
1167
+ 'ecr:BatchCheckLayerAvailability',
1168
+ 'ecr:GetDownloadUrlForLayer',
1169
+ 'ecr:BatchGetImage'
1170
+ ],
1171
+ Resource: 'arn:aws:ecr:*:*:repository/ml-container-creator'
1172
+ },
1173
+ {
1174
+ Sid: 'ECRAuth',
1175
+ Effect: 'Allow',
1176
+ Action: 'ecr:GetAuthorizationToken',
1177
+ Resource: '*'
1178
+ },
1179
+ {
1180
+ Sid: 'CloudWatchLogs',
1181
+ Effect: 'Allow',
1182
+ Action: [
1183
+ 'logs:CreateLogGroup',
1184
+ 'logs:CreateLogStream',
1185
+ 'logs:PutLogEvents'
1186
+ ],
1187
+ Resource: 'arn:aws:logs:*:*:*'
1188
+ },
1189
+ {
1190
+ Sid: 'S3ModelRead',
1191
+ Effect: 'Allow',
1192
+ Action: [
1193
+ 's3:GetObject',
1194
+ 's3:ListBucket'
1195
+ ],
1196
+ Resource: [
1197
+ 'arn:aws:s3:::ml-container-creator-*',
1198
+ 'arn:aws:s3:::ml-container-creator-*/*'
1199
+ ]
1200
+ }
1201
+ ]
1202
+ }
1203
+
1204
+ // Check if role already exists
1205
+ const roleExists = this._resourceExists(
1206
+ `iam get-role --role-name ${roleName}`,
1207
+ this._currentProfile
1208
+ )
1209
+
1210
+ if (roleExists) {
1211
+ const existingRole = this._execAws(
1212
+ `iam get-role --role-name ${roleName}`,
1213
+ this._currentProfile
1214
+ )
1215
+ const roleArn = existingRole.Role.Arn
1216
+ console.log(` ✅ IAM role "${roleName}" already exists — reused`)
1217
+
1218
+ // Always update the inline policy and tags to ensure they're current
1219
+ try {
1220
+ const execPolicyFile = this._writeJsonTempFile(executionPolicy, 'exec-policy')
1221
+ this._execAws(
1222
+ `iam put-role-policy --role-name ${roleName} --policy-name mlcc-execution-policy --policy-document ${execPolicyFile}`,
1223
+ this._currentProfile
1224
+ )
1225
+ console.log(` ✅ IAM policy "mlcc-execution-policy" — updated`)
1226
+ } catch (err) {
1227
+ console.log(` ⚠️ Could not update inline policy: ${err.message}`)
1228
+ }
1229
+
1230
+ try {
1231
+ const tags = this._buildResourceTags()
1232
+ this._execAws(
1233
+ `iam tag-role --role-name ${roleName} --tags ${this._formatTagsForCli(tags)}`,
1234
+ this._currentProfile
1235
+ )
1236
+ console.log(` ✅ IAM role tags — updated`)
1237
+ } catch (err) {
1238
+ console.log(` ⚠️ Could not update role tags: ${err.message}`)
1239
+ }
1240
+
1241
+ return roleArn
1242
+ }
1243
+
1244
+ // Display policies to user before creation
1245
+ console.log('\n Trust Policy:')
1246
+ console.log(JSON.stringify(trustPolicy, null, 2))
1247
+ console.log('\n Execution Policy:')
1248
+ console.log(JSON.stringify(executionPolicy, null, 2))
1249
+ console.log('')
1250
+
1251
+ try {
1252
+ // Create the IAM role — write policy to temp file to avoid shell escaping issues
1253
+ const trustPolicyFile = this._writeJsonTempFile(trustPolicy, 'trust-policy')
1254
+ const createRoleResult = this._execAws(
1255
+ `iam create-role --role-name ${roleName} --assume-role-policy-document ${trustPolicyFile}`,
1256
+ this._currentProfile
1257
+ )
1258
+ const roleArn = createRoleResult.Role.Arn
1259
+
1260
+ // Attach inline execution policy
1261
+ const execPolicyFile = this._writeJsonTempFile(executionPolicy, 'exec-policy')
1262
+ this._execAws(
1263
+ `iam put-role-policy --role-name ${roleName} --policy-name mlcc-execution-policy --policy-document ${execPolicyFile}`,
1264
+ this._currentProfile
1265
+ )
1266
+
1267
+ // Apply resource tags
1268
+ const tags = this._buildResourceTags()
1269
+ this._execAws(
1270
+ `iam tag-role --role-name ${roleName} --tags ${this._formatTagsForCli(tags)}`,
1271
+ this._currentProfile
1272
+ )
1273
+
1274
+ console.log(` ✅ IAM role "${roleName}" — created`)
1275
+ return roleArn
1276
+ } catch (error) {
1277
+ const errorMessage = error.message || ''
1278
+ if (errorMessage.includes('AccessDenied') || errorMessage.includes('UnauthorizedAccess')) {
1279
+ console.log(' ⚠️ Permission denied for iam:CreateRole. Please provide an existing role ARN.')
1280
+ const { roleArn } = await this._promptFn([{
1281
+ type: 'input',
1282
+ name: 'roleArn',
1283
+ message: 'Enter an existing IAM role ARN for SageMaker execution:'
1284
+ }])
1285
+ return roleArn
1286
+ }
1287
+ throw error
1288
+ }
1289
+ }
1290
+
1291
+ /**
1292
+ * Create or reuse the ECR repository.
1293
+ * @returns {Promise<string>} ECR repository name
1294
+ */
1295
+ async _setupEcrRepository() {
1296
+ const repoName = 'ml-container-creator'
1297
+
1298
+ // Check if repository already exists
1299
+ const repoExists = this._resourceExists(
1300
+ `ecr describe-repositories --repository-names ${repoName} --region ${this._currentRegion}`,
1301
+ this._currentProfile
1302
+ )
1303
+
1304
+ if (repoExists) {
1305
+ console.log(` ✅ ECR repository "${repoName}" already exists — reused`)
1306
+ return repoName
1307
+ }
1308
+
1309
+ // Build resource tags
1310
+ const tags = this._buildResourceTags()
1311
+
1312
+ // Create the ECR repository with image scanning and AES256 encryption
1313
+ this._execAws(
1314
+ `ecr create-repository --repository-name ${repoName} --image-scanning-configuration scanOnPush=true --encryption-configuration encryptionType=AES256 --region ${this._currentRegion} --tags ${this._formatTagsForCli(tags)}`,
1315
+ this._currentProfile
1316
+ )
1317
+
1318
+ // Apply lifecycle policy to expire untagged images after 30 days
1319
+ const lifecyclePolicy = {
1320
+ rules: [
1321
+ {
1322
+ rulePriority: 1,
1323
+ description: 'Expire untagged images after 30 days',
1324
+ selection: {
1325
+ tagStatus: 'untagged',
1326
+ countType: 'sinceImagePushed',
1327
+ countUnit: 'days',
1328
+ countNumber: 30
1329
+ },
1330
+ action: {
1331
+ type: 'expire'
1332
+ }
1333
+ }
1334
+ ]
1335
+ }
1336
+
1337
+ const lifecyclePolicyFile = this._writeJsonTempFile(lifecyclePolicy, 'ecr-lifecycle')
1338
+ this._execAws(
1339
+ `ecr put-lifecycle-policy --repository-name ${repoName} --lifecycle-policy-text ${lifecyclePolicyFile} --region ${this._currentRegion}`,
1340
+ this._currentProfile
1341
+ )
1342
+
1343
+ console.log(` ✅ ECR repository "${repoName}" — created`)
1344
+ return repoName
1345
+ }
1346
+
1347
+ /**
1348
+ * Optionally create S3 buckets for async/batch deployments.
1349
+ * @returns {Promise<object|null>} Bucket names or null if skipped
1350
+ */
1351
+ async _setupS3Buckets() {
1352
+ const { useS3 } = await this._promptFn([{
1353
+ type: 'confirm',
1354
+ name: 'useS3',
1355
+ message: 'Will you use async inference or batch transform?',
1356
+ default: false
1357
+ }])
1358
+
1359
+ if (!useS3) {
1360
+ return null
1361
+ }
1362
+
1363
+ const asyncBucketName = `ml-container-creator-async-${this._currentRegion}-${this._currentAccountId}`
1364
+ const batchBucketName = `ml-container-creator-batch-${this._currentRegion}-${this._currentAccountId}`
1365
+
1366
+ const tags = this._buildResourceTags()
1367
+ const asyncS3Bucket = await this._createS3Bucket(asyncBucketName, tags)
1368
+ const batchS3Bucket = await this._createS3Bucket(batchBucketName, tags)
1369
+
1370
+ return { asyncS3Bucket, batchS3Bucket }
1371
+ }
1372
+
1373
+ /**
1374
+ * Create or reuse a single S3 bucket with versioning, encryption, and tags.
1375
+ * @param {string} bucketName - S3 bucket name
1376
+ * @param {Array<{Key: string, Value: string}>} tags - Resource tags
1377
+ * @returns {Promise<string>} Bucket name
1378
+ */
1379
+ async _createS3Bucket(bucketName, tags) {
1380
+ // Check if bucket already exists
1381
+ const bucketExists = this._resourceExists(
1382
+ `s3api head-bucket --bucket ${bucketName}`,
1383
+ this._currentProfile
1384
+ )
1385
+
1386
+ if (bucketExists) {
1387
+ console.log(` ✅ S3 bucket "${bucketName}" already exists — reused`)
1388
+ return bucketName
1389
+ }
1390
+
1391
+ // Build create-bucket command with region-appropriate configuration
1392
+ let createCommand = `s3api create-bucket --bucket ${bucketName} --region ${this._currentRegion}`
1393
+ if (this._currentRegion !== 'us-east-1') {
1394
+ createCommand += ` --create-bucket-configuration LocationConstraint=${this._currentRegion}`
1395
+ }
1396
+
1397
+ this._execAws(createCommand, this._currentProfile)
1398
+
1399
+ // Enable versioning
1400
+ this._execAws(
1401
+ `s3api put-bucket-versioning --bucket ${bucketName} --versioning-configuration Status=Enabled`,
1402
+ this._currentProfile
1403
+ )
1404
+
1405
+ // Enable AES256 server-side encryption
1406
+ const encryptionConfig = { Rules: [{ ApplyServerSideEncryptionByDefault: { SSEAlgorithm: 'AES256' } }] }
1407
+ const encryptionFile = this._writeJsonTempFile(encryptionConfig, 's3-encryption')
1408
+ this._execAws(
1409
+ `s3api put-bucket-encryption --bucket ${bucketName} --server-side-encryption-configuration ${encryptionFile}`,
1410
+ this._currentProfile
1411
+ )
1412
+
1413
+ // Apply resource tags
1414
+ const tagging = { TagSet: tags }
1415
+ const taggingFile = this._writeJsonTempFile(tagging, 's3-tagging')
1416
+ this._execAws(
1417
+ `s3api put-bucket-tagging --bucket ${bucketName} --tagging ${taggingFile}`,
1418
+ this._currentProfile
1419
+ )
1420
+
1421
+ console.log(` ✅ S3 bucket "${bucketName}" — created`)
1422
+ return bucketName
1423
+ }
1424
+
1425
+ // ── AWS CLI helpers ─────────────────────────────────────────────
1426
+
1427
+ /**
1428
+ * Execute an AWS CLI command and return parsed JSON output.
1429
+ * @param {string} command - AWS CLI command (without 'aws' prefix)
1430
+ * @param {string} profile - AWS profile name
1431
+ * @returns {object} Parsed JSON output
1432
+ */
1433
+ _execAws(command, profile) {
1434
+ const fullCommand = `aws ${command} --profile ${profile} --output json`
1435
+ const output = execSync(fullCommand, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
1436
+ const trimmed = output.trim()
1437
+ if (!trimmed) {
1438
+ return {}
1439
+ }
1440
+ return JSON.parse(trimmed)
1441
+ }
1442
+
1443
+ /**
1444
+ * Deploy the bootstrap CloudFormation stack and return its outputs.
1445
+ *
1446
+ * Uses `aws cloudformation deploy` which is idempotent — it creates the
1447
+ * stack on first run and updates it on subsequent runs. If the template
1448
+ * hasn't changed, it exits with "No changes to deploy" which we handle
1449
+ * gracefully.
1450
+ *
1451
+ * @param {string} stackName - CloudFormation stack name
1452
+ * @param {object} parameters - Stack parameter key-value pairs
1453
+ * @param {string} profile - AWS CLI profile name
1454
+ * @param {string} region - AWS region
1455
+ * @returns {object} Map of output key → output value
1456
+ */
1457
+ _deployStack(stackName, parameters, profile, region) {
1458
+ // Build parameter overrides string
1459
+ const paramOverrides = Object.entries(parameters)
1460
+ .map(([key, value]) => `${key}=${value}`)
1461
+ .join(' ')
1462
+
1463
+ const deployCommand = [
1464
+ 'aws cloudformation deploy',
1465
+ `--template-file ${STACK_TEMPLATE_PATH}`,
1466
+ `--stack-name ${stackName}`,
1467
+ '--capabilities CAPABILITY_NAMED_IAM',
1468
+ `--parameter-overrides ${paramOverrides}`,
1469
+ `--profile ${profile}`,
1470
+ `--region ${region}`
1471
+ ].join(' ')
1472
+
1473
+ try {
1474
+ execSync(deployCommand, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
1475
+ } catch (error) {
1476
+ // "No changes to deploy" is a success case — CloudFormation deploy
1477
+ // exits with code 255 when there's nothing to update
1478
+ const stderr = error.stderr || error.message || ''
1479
+ if (stderr.includes('No changes to deploy')) {
1480
+ console.log(' ℹ️ Stack is up to date — no changes needed')
1481
+ } else {
1482
+ throw error
1483
+ }
1484
+ }
1485
+
1486
+ // Read stack outputs
1487
+ const describeResult = this._execAws(
1488
+ `cloudformation describe-stacks --stack-name ${stackName} --region ${region}`,
1489
+ profile
1490
+ )
1491
+
1492
+ const stack = describeResult.Stacks && describeResult.Stacks[0]
1493
+ if (!stack) {
1494
+ throw new Error(`Stack "${stackName}" not found after deployment`)
1495
+ }
1496
+
1497
+ const outputs = {}
1498
+ for (const output of (stack.Outputs || [])) {
1499
+ outputs[output.OutputKey] = output.OutputValue
1500
+ }
1501
+
1502
+ return outputs
1503
+ }
1504
+
1505
+ /**
1506
+ * Write a JSON object to a temp file and return the `file://` path.
1507
+ * Used for passing complex JSON to AWS CLI commands without shell escaping issues.
1508
+ *
1509
+ * @param {object} jsonObj - The JSON object to write
1510
+ * @param {string} prefix - Filename prefix for the temp file
1511
+ * @returns {string} The `file://` path to the temp file
1512
+ */
1513
+ _writeJsonTempFile(jsonObj, prefix = 'mlcc-policy') {
1514
+ const dir = path.join(tmpdir(), 'mlcc-bootstrap')
1515
+ if (!existsSync(dir)) {
1516
+ mkdirSync(dir, { recursive: true })
1517
+ }
1518
+ const filePath = path.join(dir, `${prefix}-${Date.now()}.json`)
1519
+ writeFileSync(filePath, JSON.stringify(jsonObj))
1520
+ return `file://${filePath}`
1521
+ }
1522
+
1523
+ /**
1524
+ * Check whether an AWS resource exists by running a check command.
1525
+ * @param {string} checkCommand - AWS CLI command to check existence
1526
+ * @param {string} profile - AWS profile name
1527
+ * @returns {boolean} True if resource exists
1528
+ */
1529
+ _resourceExists(checkCommand, profile) {
1530
+ try {
1531
+ this._execAws(checkCommand, profile)
1532
+ return true
1533
+ } catch {
1534
+ return false
1535
+ }
1536
+ }
1537
+
1538
+ // ── Tag helpers ─────────────────────────────────────────────────
1539
+
1540
+ /**
1541
+ * Build the standard resource tag set.
1542
+ * @returns {Array<{Key: string, Value: string}>} Tag array
1543
+ */
1544
+ _buildResourceTags() {
1545
+ const packageJsonPath = path.resolve(__dirname, '../../package.json')
1546
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
1547
+ return [
1548
+ { Key: 'mlcc:managed-by', Value: 'ml-container-creator' },
1549
+ { Key: 'mlcc:created-by', Value: 'bootstrap' },
1550
+ { Key: 'mlcc:version', Value: packageJson.version }
1551
+ ]
1552
+ }
1553
+
1554
+ /**
1555
+ * Format tags for the AWS CLI --tags parameter.
1556
+ * Writes tags to a temp file and returns the file:// reference
1557
+ * to avoid shell escaping issues with special characters in tag keys/values.
1558
+ *
1559
+ * @param {Array<{Key: string, Value: string}>} tags - Tag array
1560
+ * @returns {string} file:// path to the tags JSON file
1561
+ */
1562
+ _formatTagsForCli(tags) {
1563
+ return this._writeJsonTempFile(tags, 'tags')
1564
+ }
1565
+
1566
+ // ── Display helpers ─────────────────────────────────────────────
1567
+
1568
+ /**
1569
+ * Show bootstrap usage help.
1570
+ */
1571
+ _showHelp() {
1572
+ console.log(`
1573
+ Bootstrap — Shared AWS Infrastructure Setup
1574
+
1575
+ Provisions shared infrastructure via a CloudFormation stack. Re-run bootstrap
1576
+ at any time to apply updates from new versions — CloudFormation handles the diff.
1577
+
1578
+ USAGE:
1579
+ ml-container-creator bootstrap [subcommand] [options]
1580
+
1581
+ SUBCOMMANDS:
1582
+ (no subcommand) Interactive setup (default) — creates or updates stack
1583
+ status Show active profile, stack state, and deployed resources
1584
+ status --verify Show status and verify active resources exist in AWS
1585
+ use <profile> Switch active bootstrap profile
1586
+ list List all bootstrap profiles
1587
+ remove <profile> Remove a bootstrap profile
1588
+ scan Discover pre-existing MLCC-managed resources in AWS
1589
+ prune Remove deleted and unknown records from the deployment manifest
1590
+ update Re-deploy bootstrap stacks using active profile (no prompts)
1591
+
1592
+ SETUP OPTIONS:
1593
+ --non-interactive Run without interactive prompts
1594
+ --name <name> Bootstrap profile name (default: "default")
1595
+ --profile <profile> AWS CLI profile to use
1596
+ --region <region> AWS region for resources
1597
+ --role-arn <arn> Use existing IAM role ARN (skip role creation)
1598
+ --skip-s3 Skip S3 bucket creation
1599
+ --ci Provision CI testing infrastructure
1600
+ --skip-ci Skip CI infrastructure provisioning
1601
+
1602
+ STATUS OPTIONS:
1603
+ --verify Check each active resource against AWS APIs for drift detection
1604
+
1605
+ REMOVE OPTIONS:
1606
+ --force Skip confirmation prompt
1607
+ --delete-stack Also delete the CloudFormation stack and AWS resources
1608
+
1609
+ EXAMPLES:
1610
+ ml-container-creator bootstrap
1611
+ ml-container-creator bootstrap status
1612
+ ml-container-creator bootstrap status --verify
1613
+ ml-container-creator bootstrap use prod
1614
+ ml-container-creator bootstrap list
1615
+ ml-container-creator bootstrap remove dev
1616
+ ml-container-creator bootstrap remove dev --force --delete-stack
1617
+ ml-container-creator bootstrap scan
1618
+ ml-container-creator bootstrap --non-interactive --profile my-aws-profile --region us-west-2
1619
+ ml-container-creator bootstrap --non-interactive --profile my-aws-profile --role-arn arn:aws:iam::123456789012:role/MyRole --skip-s3
1620
+ ml-container-creator bootstrap --non-interactive --profile my-aws-profile --region us-west-2 --ci
1621
+ ml-container-creator bootstrap --non-interactive --profile my-aws-profile --region us-west-2 --skip-ci
1622
+ `)
1623
+ }
1624
+
1625
+ /**
1626
+ * Display a summary of the bootstrap profile configuration.
1627
+ * @param {string} profileName - Bootstrap profile name
1628
+ * @param {object} profileConfig - Profile configuration object
1629
+ */
1630
+ _displaySummary(profileName, profileConfig) {
1631
+ console.log(`\n📋 Bootstrap Profile: ${profileName}`)
1632
+ console.log('─'.repeat(40))
1633
+ for (const [key, value] of Object.entries(profileConfig)) {
1634
+ console.log(` ${key}: ${value}`)
1635
+ }
1636
+ console.log('─'.repeat(40))
1637
+ }
1638
+
1639
+ /**
1640
+ * Display a progress indicator line.
1641
+ * @param {string} emoji - Emoji prefix
1642
+ * @param {string} message - Progress message
1643
+ */
1644
+ _displayProgress(emoji, message) {
1645
+ console.log(`${emoji} ${message}`)
1646
+ }
1647
+ }