@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.
- package/LICENSE +202 -0
- package/LICENSE-THIRD-PARTY +68620 -0
- package/NOTICE +2 -0
- package/README.md +106 -0
- package/bin/cli.js +365 -0
- package/config/defaults.json +32 -0
- package/config/presets/transformers-djl.json +26 -0
- package/config/presets/transformers-gpu.json +24 -0
- package/config/presets/transformers-lmi.json +27 -0
- package/package.json +129 -0
- package/servers/README.md +419 -0
- package/servers/base-image-picker/catalogs/model-servers.json +1191 -0
- package/servers/base-image-picker/catalogs/python-slim.json +38 -0
- package/servers/base-image-picker/catalogs/triton-backends.json +51 -0
- package/servers/base-image-picker/catalogs/triton.json +38 -0
- package/servers/base-image-picker/index.js +495 -0
- package/servers/base-image-picker/manifest.json +17 -0
- package/servers/base-image-picker/package.json +15 -0
- package/servers/hyperpod-cluster-picker/LICENSE +202 -0
- package/servers/hyperpod-cluster-picker/index.js +424 -0
- package/servers/hyperpod-cluster-picker/manifest.json +14 -0
- package/servers/hyperpod-cluster-picker/package.json +17 -0
- package/servers/instance-recommender/LICENSE +202 -0
- package/servers/instance-recommender/catalogs/instances.json +852 -0
- package/servers/instance-recommender/index.js +284 -0
- package/servers/instance-recommender/manifest.json +16 -0
- package/servers/instance-recommender/package.json +15 -0
- package/servers/lib/LICENSE +202 -0
- package/servers/lib/bedrock-client.js +160 -0
- package/servers/lib/custom-validators.js +46 -0
- package/servers/lib/dynamic-resolver.js +36 -0
- package/servers/lib/package.json +11 -0
- package/servers/lib/schemas/image-catalog.schema.json +185 -0
- package/servers/lib/schemas/instances.schema.json +124 -0
- package/servers/lib/schemas/manifest.schema.json +64 -0
- package/servers/lib/schemas/model-catalog.schema.json +91 -0
- package/servers/lib/schemas/regions.schema.json +26 -0
- package/servers/lib/schemas/triton-backends.schema.json +51 -0
- package/servers/model-picker/catalogs/jumpstart-public.json +66 -0
- package/servers/model-picker/catalogs/popular-diffusors.json +88 -0
- package/servers/model-picker/catalogs/popular-transformers.json +226 -0
- package/servers/model-picker/index.js +1693 -0
- package/servers/model-picker/manifest.json +18 -0
- package/servers/model-picker/package.json +20 -0
- package/servers/region-picker/LICENSE +202 -0
- package/servers/region-picker/catalogs/regions.json +263 -0
- package/servers/region-picker/index.js +230 -0
- package/servers/region-picker/manifest.json +16 -0
- package/servers/region-picker/package.json +15 -0
- package/src/app.js +1007 -0
- package/src/copy-tpl.js +77 -0
- package/src/lib/accelerator-validator.js +39 -0
- package/src/lib/asset-manager.js +385 -0
- package/src/lib/aws-profile-parser.js +181 -0
- package/src/lib/bootstrap-command-handler.js +1647 -0
- package/src/lib/bootstrap-config.js +238 -0
- package/src/lib/ci-register-helpers.js +124 -0
- package/src/lib/ci-report-helpers.js +158 -0
- package/src/lib/ci-stage-helpers.js +268 -0
- package/src/lib/cli-handler.js +529 -0
- package/src/lib/comment-generator.js +544 -0
- package/src/lib/community-reports-validator.js +91 -0
- package/src/lib/config-manager.js +2106 -0
- package/src/lib/configuration-exporter.js +204 -0
- package/src/lib/configuration-manager.js +695 -0
- package/src/lib/configuration-matcher.js +221 -0
- package/src/lib/cpu-validator.js +36 -0
- package/src/lib/cuda-validator.js +57 -0
- package/src/lib/deployment-config-resolver.js +103 -0
- package/src/lib/deployment-entry-schema.js +125 -0
- package/src/lib/deployment-registry.js +598 -0
- package/src/lib/docker-introspection-validator.js +51 -0
- package/src/lib/engine-prefix-resolver.js +60 -0
- package/src/lib/huggingface-client.js +172 -0
- package/src/lib/key-value-parser.js +37 -0
- package/src/lib/known-flags-validator.js +200 -0
- package/src/lib/manifest-cli.js +280 -0
- package/src/lib/mcp-client.js +303 -0
- package/src/lib/mcp-command-handler.js +532 -0
- package/src/lib/neuron-validator.js +80 -0
- package/src/lib/parameter-schema-validator.js +284 -0
- package/src/lib/prompt-runner.js +1349 -0
- package/src/lib/prompts.js +1138 -0
- package/src/lib/registry-command-handler.js +519 -0
- package/src/lib/registry-loader.js +198 -0
- package/src/lib/rocm-validator.js +80 -0
- package/src/lib/schema-validator.js +157 -0
- package/src/lib/sensitive-redactor.js +59 -0
- package/src/lib/template-engine.js +156 -0
- package/src/lib/template-manager.js +341 -0
- package/src/lib/validation-engine.js +314 -0
- package/src/prompt-adapter.js +63 -0
- package/templates/Dockerfile +300 -0
- package/templates/IAM_PERMISSIONS.md +84 -0
- package/templates/MIGRATION.md +488 -0
- package/templates/PROJECT_README.md +439 -0
- package/templates/TEMPLATE_SYSTEM.md +243 -0
- package/templates/buildspec.yml +64 -0
- package/templates/code/chat_template.jinja +1 -0
- package/templates/code/flask/gunicorn_config.py +35 -0
- package/templates/code/flask/wsgi.py +10 -0
- package/templates/code/model_handler.py +387 -0
- package/templates/code/serve +300 -0
- package/templates/code/serve.py +175 -0
- package/templates/code/serving.properties +105 -0
- package/templates/code/start_server.py +39 -0
- package/templates/code/start_server.sh +39 -0
- package/templates/diffusors/Dockerfile +72 -0
- package/templates/diffusors/patch_image_api.py +35 -0
- package/templates/diffusors/serve +115 -0
- package/templates/diffusors/start_server.sh +114 -0
- package/templates/do/.gitkeep +1 -0
- package/templates/do/README.md +541 -0
- package/templates/do/build +83 -0
- package/templates/do/ci +681 -0
- package/templates/do/clean +811 -0
- package/templates/do/config +260 -0
- package/templates/do/deploy +1560 -0
- package/templates/do/export +306 -0
- package/templates/do/logs +319 -0
- package/templates/do/manifest +12 -0
- package/templates/do/push +119 -0
- package/templates/do/register +580 -0
- package/templates/do/run +113 -0
- package/templates/do/submit +417 -0
- package/templates/do/test +1147 -0
- package/templates/hyperpod/configmap.yaml +24 -0
- package/templates/hyperpod/deployment.yaml +71 -0
- package/templates/hyperpod/pvc.yaml +42 -0
- package/templates/hyperpod/service.yaml +17 -0
- package/templates/nginx-diffusors.conf +74 -0
- package/templates/nginx-predictors.conf +47 -0
- package/templates/nginx-tensorrt.conf +74 -0
- package/templates/requirements.txt +61 -0
- package/templates/sample_model/test_inference.py +123 -0
- package/templates/sample_model/train_abalone.py +252 -0
- package/templates/test/test_endpoint.sh +79 -0
- package/templates/test/test_local_image.sh +80 -0
- package/templates/test/test_model_handler.py +180 -0
- package/templates/triton/Dockerfile +128 -0
- package/templates/triton/config.pbtxt +163 -0
- package/templates/triton/model.py +130 -0
- 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
|
+
}
|