@aws/ml-container-creator 0.9.1 → 0.10.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/config/parameter-schema-v2.json +2065 -0
- package/package.json +4 -4
- package/servers/lib/catalogs/jumpstart-public.json +101 -16
- package/servers/lib/catalogs/models.json +182 -26
- package/src/app.js +1 -389
- package/src/lib/bootstrap-command-handler.js +75 -1078
- package/src/lib/bootstrap-profile-manager.js +634 -0
- package/src/lib/bootstrap-provisioners.js +421 -0
- package/src/lib/config-loader.js +405 -0
- package/src/lib/config-manager.js +59 -1685
- package/src/lib/config-mcp-client.js +118 -0
- package/src/lib/config-validator.js +634 -0
- package/src/lib/cuda-resolver.js +140 -0
- package/src/lib/e2e-catalog-validator.js +251 -3
- package/src/lib/e2e-ci-recorder.js +103 -0
- package/src/lib/generated/cli-options.js +8 -4
- package/src/lib/generated/parameter-matrix.js +671 -0
- package/src/lib/generated/validation-rules.js +2 -2
- package/src/lib/marketplace-flow.js +276 -0
- package/src/lib/mcp-query-runner.js +768 -0
- package/src/lib/parameter-schema-validator.js +62 -18
- package/src/lib/prompt-runner.js +41 -1504
- package/src/lib/prompts/feature-prompts.js +172 -0
- package/src/lib/prompts/index.js +48 -0
- package/src/lib/prompts/infrastructure-prompts.js +690 -0
- package/src/lib/prompts/model-prompts.js +552 -0
- package/src/lib/prompts/project-prompts.js +70 -0
- package/src/lib/prompts.js +2 -1446
- package/src/lib/registry-command-handler.js +135 -3
- package/src/lib/secrets-prompt-runner.js +251 -0
- package/src/lib/template-variable-resolver.js +398 -0
- package/config/parameter-schema.json +0 -88
|
@@ -17,16 +17,17 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { execSync } from 'node:child_process';
|
|
20
|
-
import { existsSync,
|
|
20
|
+
import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
21
21
|
import path from 'node:path';
|
|
22
22
|
import { tmpdir } from 'node:os';
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
24
24
|
import BootstrapConfig from './bootstrap-config.js';
|
|
25
25
|
import AwsProfileParser from './aws-profile-parser.js';
|
|
26
|
-
import AssetManager from './asset-manager.js';
|
|
27
26
|
import McpCommandHandler from './mcp-command-handler.js';
|
|
28
27
|
import RegistryCommandHandler from './registry-command-handler.js';
|
|
29
28
|
import { runPrompts } from '../prompt-adapter.js';
|
|
29
|
+
import BootstrapProfileManager from './bootstrap-profile-manager.js';
|
|
30
|
+
import BootstrapProvisioners from './bootstrap-provisioners.js';
|
|
30
31
|
|
|
31
32
|
const __filename = fileURLToPath(import.meta.url);
|
|
32
33
|
const __dirname = path.dirname(__filename);
|
|
@@ -39,8 +40,29 @@ export default class BootstrapCommandHandler {
|
|
|
39
40
|
this.config = new BootstrapConfig();
|
|
40
41
|
this.profileParser = new AwsProfileParser();
|
|
41
42
|
this._promptFn = promptFn || runPrompts;
|
|
43
|
+
this.profileManager = new BootstrapProfileManager(this);
|
|
44
|
+
this.provisioners = new BootstrapProvisioners(this);
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
// ── Provisioner delegations (backward compat for tests) ─────────
|
|
48
|
+
|
|
49
|
+
_buildResourceTags() { return this.provisioners._buildResourceTags(); }
|
|
50
|
+
_setupEcrRepository() { return this.provisioners._setupEcrRepository(); }
|
|
51
|
+
_setupIamRole(options) { return this.provisioners._setupIamRole(options); }
|
|
52
|
+
_setupS3Buckets() { return this.provisioners._setupS3Buckets(); }
|
|
53
|
+
_createS3Bucket(name, tags) { return this.provisioners._createS3Bucket(name, tags); }
|
|
54
|
+
_verifyCliV2() { return this.provisioners._verifyCliV2(); }
|
|
55
|
+
|
|
56
|
+
// ── ProfileManager delegations (backward compat for tests) ──────
|
|
57
|
+
|
|
58
|
+
_handleStatus(options) { return this.profileManager._handleStatus(options); }
|
|
59
|
+
_handleUse(profileName) { return this.profileManager._handleUse(profileName); }
|
|
60
|
+
_handleList() { return this.profileManager._handleList(); }
|
|
61
|
+
_handleRemove(profileName, options) { return this.profileManager._handleRemove(profileName, options); }
|
|
62
|
+
_handleScan() { return this.profileManager._handleScan(); }
|
|
63
|
+
_handlePrune() { return this.profileManager._handlePrune(); }
|
|
64
|
+
_handleSyncSchemas() { return this.profileManager._handleSyncSchemas(); }
|
|
65
|
+
|
|
44
66
|
/**
|
|
45
67
|
* Dispatch bootstrap subcommands.
|
|
46
68
|
* @param {string[]} args - Remaining positional args after 'bootstrap'
|
|
@@ -117,7 +139,7 @@ export default class BootstrapCommandHandler {
|
|
|
117
139
|
console.log('\n🚀 Bootstrap — Shared AWS Infrastructure Setup\n');
|
|
118
140
|
|
|
119
141
|
// Verify AWS CLI v2 is installed
|
|
120
|
-
if (!this._verifyCliV2()) {
|
|
142
|
+
if (!this.provisioners._verifyCliV2()) {
|
|
121
143
|
return;
|
|
122
144
|
}
|
|
123
145
|
|
|
@@ -371,678 +393,6 @@ export default class BootstrapCommandHandler {
|
|
|
371
393
|
await this._runPostSetupChain(options);
|
|
372
394
|
}
|
|
373
395
|
|
|
374
|
-
/**
|
|
375
|
-
* Display active bootstrap profile and resource state.
|
|
376
|
-
* @param {object} [options] - Parsed CLI options (e.g., --verify)
|
|
377
|
-
*/
|
|
378
|
-
async _handleStatus(options = {}) {
|
|
379
|
-
const config = this.config.read();
|
|
380
|
-
if (!config) {
|
|
381
|
-
console.log('No bootstrap configuration found.');
|
|
382
|
-
console.log('Run `ml-container-creator bootstrap` to set up shared infrastructure.');
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
const profile = this.config.getActiveProfile();
|
|
387
|
-
if (!profile) {
|
|
388
|
-
console.log('No active bootstrap profile found.');
|
|
389
|
-
console.log('Run `ml-container-creator bootstrap` to set up shared infrastructure.');
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
const allProfiles = this.config.listProfiles();
|
|
394
|
-
console.log(`\n📋 Active Profile: ${profile.name} (${allProfiles.length} profile${allProfiles.length === 1 ? '' : 's'} total)`);
|
|
395
|
-
console.log('─'.repeat(40));
|
|
396
|
-
|
|
397
|
-
for (const [key, value] of Object.entries(profile.config)) {
|
|
398
|
-
console.log(` ${key}: ${value}`);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
console.log('─'.repeat(40));
|
|
402
|
-
|
|
403
|
-
// Validate bootstrap stack
|
|
404
|
-
console.log('\n🔍 Resource Validation:');
|
|
405
|
-
|
|
406
|
-
const stackName = profile.config.stackName || `${STACK_NAME_PREFIX}-${profile.name}`;
|
|
407
|
-
|
|
408
|
-
try {
|
|
409
|
-
const stackInfo = this._execAws(
|
|
410
|
-
`cloudformation describe-stacks --stack-name ${stackName} --region ${profile.config.awsRegion}`,
|
|
411
|
-
profile.config.awsProfile
|
|
412
|
-
);
|
|
413
|
-
|
|
414
|
-
const stack = stackInfo.Stacks && stackInfo.Stacks[0];
|
|
415
|
-
if (stack) {
|
|
416
|
-
const status = stack.StackStatus;
|
|
417
|
-
const statusIcon = status === 'CREATE_COMPLETE' || status === 'UPDATE_COMPLETE' ? '✅' : '⚠️';
|
|
418
|
-
console.log(` ${statusIcon} Bootstrap stack: ${stackName} (${status})`);
|
|
419
|
-
|
|
420
|
-
// Show stack outputs
|
|
421
|
-
const outputs = {};
|
|
422
|
-
for (const output of (stack.Outputs || [])) {
|
|
423
|
-
outputs[output.OutputKey] = output.OutputValue;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
if (outputs.RoleArn) {
|
|
427
|
-
console.log(` ✅ IAM role: ${outputs.RoleArn.split('/').pop()}`);
|
|
428
|
-
}
|
|
429
|
-
if (outputs.EcrRepositoryName) {
|
|
430
|
-
console.log(` ✅ ECR repository: ${outputs.EcrRepositoryName}`);
|
|
431
|
-
}
|
|
432
|
-
if (outputs.AsyncS3BucketName) {
|
|
433
|
-
console.log(` ✅ S3 bucket (async): ${outputs.AsyncS3BucketName}`);
|
|
434
|
-
}
|
|
435
|
-
if (outputs.BatchS3BucketName) {
|
|
436
|
-
console.log(` ✅ S3 bucket (batch): ${outputs.BatchS3BucketName}`);
|
|
437
|
-
}
|
|
438
|
-
if (outputs.AdapterS3BucketName) {
|
|
439
|
-
console.log(` ✅ S3 bucket (adapters): ${outputs.AdapterS3BucketName}`);
|
|
440
|
-
}
|
|
441
|
-
if (outputs.BenchmarkS3BucketName) {
|
|
442
|
-
console.log(` ✅ S3 bucket (benchmark): ${outputs.BenchmarkS3BucketName}`);
|
|
443
|
-
}
|
|
444
|
-
if (outputs.StackVersion) {
|
|
445
|
-
console.log(` 📋 Stack version: ${outputs.StackVersion}`);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
} catch {
|
|
449
|
-
// Fall back to individual resource checks for profiles created before CloudFormation migration
|
|
450
|
-
console.log(` ⚠️ Bootstrap stack "${stackName}" not found — checking resources individually`);
|
|
451
|
-
|
|
452
|
-
try {
|
|
453
|
-
const defaultRoleName = 'mlcc-sagemaker-execution-role';
|
|
454
|
-
let roleName = defaultRoleName;
|
|
455
|
-
if (profile.config.roleArn) {
|
|
456
|
-
const arnParts = profile.config.roleArn.split('/');
|
|
457
|
-
roleName = arnParts[arnParts.length - 1];
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
const roleExists = this._resourceExists(
|
|
461
|
-
`iam get-role --role-name ${roleName}`,
|
|
462
|
-
profile.config.awsProfile
|
|
463
|
-
);
|
|
464
|
-
if (roleExists) {
|
|
465
|
-
console.log(` ✅ IAM role: ${roleName}`);
|
|
466
|
-
} else {
|
|
467
|
-
console.log(` ⚠️ IAM role: ${roleName} — missing`);
|
|
468
|
-
}
|
|
469
|
-
} catch {
|
|
470
|
-
console.log(' ⚠️ IAM role: could not validate');
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
try {
|
|
474
|
-
const ecrExists = this._resourceExists(
|
|
475
|
-
`ecr describe-repositories --repository-names ml-container-creator --region ${profile.config.awsRegion}`,
|
|
476
|
-
profile.config.awsProfile
|
|
477
|
-
);
|
|
478
|
-
if (ecrExists) {
|
|
479
|
-
console.log(' ✅ ECR repository: ml-container-creator');
|
|
480
|
-
} else {
|
|
481
|
-
console.log(' ⚠️ ECR repository: ml-container-creator — missing');
|
|
482
|
-
}
|
|
483
|
-
} catch {
|
|
484
|
-
console.log(' ⚠️ ECR repository: could not validate');
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
if (profile.config.asyncS3Bucket) {
|
|
488
|
-
try {
|
|
489
|
-
const asyncExists = this._resourceExists(
|
|
490
|
-
`s3api head-bucket --bucket ${profile.config.asyncS3Bucket}`,
|
|
491
|
-
profile.config.awsProfile
|
|
492
|
-
);
|
|
493
|
-
console.log(asyncExists
|
|
494
|
-
? ` ✅ S3 bucket: ${profile.config.asyncS3Bucket}`
|
|
495
|
-
: ` ⚠️ S3 bucket: ${profile.config.asyncS3Bucket} — missing`);
|
|
496
|
-
} catch {
|
|
497
|
-
console.log(` ⚠️ S3 bucket: ${profile.config.asyncS3Bucket} — could not validate`);
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
if (profile.config.batchS3Bucket) {
|
|
502
|
-
try {
|
|
503
|
-
const batchExists = this._resourceExists(
|
|
504
|
-
`s3api head-bucket --bucket ${profile.config.batchS3Bucket}`,
|
|
505
|
-
profile.config.awsProfile
|
|
506
|
-
);
|
|
507
|
-
console.log(batchExists
|
|
508
|
-
? ` ✅ S3 bucket: ${profile.config.batchS3Bucket}`
|
|
509
|
-
: ` ⚠️ S3 bucket: ${profile.config.batchS3Bucket} — missing`);
|
|
510
|
-
} catch {
|
|
511
|
-
console.log(` ⚠️ S3 bucket: ${profile.config.batchS3Bucket} — could not validate`);
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
if (profile.config.benchmarkS3Bucket) {
|
|
516
|
-
try {
|
|
517
|
-
const benchmarkExists = this._resourceExists(
|
|
518
|
-
`s3api head-bucket --bucket ${profile.config.benchmarkS3Bucket}`,
|
|
519
|
-
profile.config.awsProfile
|
|
520
|
-
);
|
|
521
|
-
console.log(benchmarkExists
|
|
522
|
-
? ` ✅ S3 bucket (benchmark): ${profile.config.benchmarkS3Bucket}`
|
|
523
|
-
: ` ⚠️ S3 bucket (benchmark): ${profile.config.benchmarkS3Bucket} — missing`);
|
|
524
|
-
} catch {
|
|
525
|
-
console.log(` ⚠️ S3 bucket (benchmark): ${profile.config.benchmarkS3Bucket} — could not validate`);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// Display deployed resources from manifest
|
|
531
|
-
console.log('\n📦 Deployed Resources:');
|
|
532
|
-
|
|
533
|
-
const assetManager = new AssetManager(profile.name);
|
|
534
|
-
|
|
535
|
-
if (!existsSync(assetManager.manifestPath)) {
|
|
536
|
-
console.log(' No deployment tracking data available.');
|
|
537
|
-
console.log(' Resources will be tracked after running deploy, push, or submit scripts.');
|
|
538
|
-
return;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
const resourcesByProject = assetManager.getResourcesByProject();
|
|
542
|
-
|
|
543
|
-
if (resourcesByProject.size === 0) {
|
|
544
|
-
console.log(' No deployed resources tracked.');
|
|
545
|
-
return;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
for (const [project, resources] of resourcesByProject) {
|
|
549
|
-
console.log(`\n Project: ${project}`);
|
|
550
|
-
for (const resource of resources) {
|
|
551
|
-
const timestamp = resource.createdAt || resource.lastUpdatedAt;
|
|
552
|
-
console.log(` ${resource.resourceType} ${resource.resourceId} [${resource.status}] ${timestamp}`);
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
const counts = assetManager.getStatusCounts();
|
|
557
|
-
console.log(`\n Summary: ${counts.active} active, ${counts.deleted} deleted, ${counts.unknown} unknown`);
|
|
558
|
-
|
|
559
|
-
// Drift detection if --verify flag is set
|
|
560
|
-
if (options.verify) {
|
|
561
|
-
await this._handleStatusVerify(profile, assetManager);
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
/**
|
|
566
|
-
* Perform drift detection for active resources.
|
|
567
|
-
* @param {object} profile - Active profile object with name and config
|
|
568
|
-
* @param {AssetManager} assetManager - AssetManager instance for the profile
|
|
569
|
-
*/
|
|
570
|
-
async _handleStatusVerify(profile, assetManager) {
|
|
571
|
-
console.log('\n🔎 Drift Detection:');
|
|
572
|
-
|
|
573
|
-
const activeResources = assetManager.listResources({ status: 'active' });
|
|
574
|
-
|
|
575
|
-
if (activeResources.length === 0) {
|
|
576
|
-
console.log(' No active resources to verify.');
|
|
577
|
-
return;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
let verified = 0;
|
|
581
|
-
let drifted = 0;
|
|
582
|
-
let unchecked = 0;
|
|
583
|
-
|
|
584
|
-
for (const resource of activeResources) {
|
|
585
|
-
const checkCommand = this._buildDriftCheckCommand(resource);
|
|
586
|
-
|
|
587
|
-
if (!checkCommand) {
|
|
588
|
-
unchecked++;
|
|
589
|
-
continue;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
try {
|
|
593
|
-
const exists = this._resourceExists(checkCommand, profile.config.awsProfile);
|
|
594
|
-
|
|
595
|
-
if (exists) {
|
|
596
|
-
verified++;
|
|
597
|
-
console.log(` ✅ ${resource.resourceType}: ${resource.resourceId}`);
|
|
598
|
-
} else {
|
|
599
|
-
drifted++;
|
|
600
|
-
assetManager.updateStatus(resource.resourceId, 'unknown');
|
|
601
|
-
console.log(` ⚠️ ${resource.resourceType}: ${resource.resourceId} — not found (status updated to unknown)`);
|
|
602
|
-
}
|
|
603
|
-
} catch {
|
|
604
|
-
unchecked++;
|
|
605
|
-
console.log(` ⚠️ ${resource.resourceType}: ${resource.resourceId} — could not verify (credentials or API unavailable)`);
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
console.log(`\n Drift Summary: ${verified} verified, ${drifted} drifted, ${unchecked} unchecked`);
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
/**
|
|
613
|
-
* Build the AWS CLI command to check if a resource still exists.
|
|
614
|
-
* @param {object} resource - Asset record
|
|
615
|
-
* @returns {string|null} AWS CLI command string, or null if resource type is not supported
|
|
616
|
-
*/
|
|
617
|
-
_buildDriftCheckCommand(resource) {
|
|
618
|
-
const resourceId = resource.resourceId;
|
|
619
|
-
|
|
620
|
-
switch (resource.resourceType) {
|
|
621
|
-
case 'sagemaker-endpoint': {
|
|
622
|
-
const name = this._extractNameFromArn(resourceId);
|
|
623
|
-
return `sagemaker describe-endpoint --endpoint-name ${name}`;
|
|
624
|
-
}
|
|
625
|
-
case 'sagemaker-model': {
|
|
626
|
-
const name = this._extractNameFromArn(resourceId);
|
|
627
|
-
return `sagemaker describe-model --model-name ${name}`;
|
|
628
|
-
}
|
|
629
|
-
case 'sagemaker-inference-component': {
|
|
630
|
-
const name = this._extractNameFromArn(resourceId);
|
|
631
|
-
return `sagemaker describe-inference-component --inference-component-name ${name}`;
|
|
632
|
-
}
|
|
633
|
-
case 'ecr-image': {
|
|
634
|
-
// resourceId is a full image URI like 111111111111.dkr.ecr.us-east-1.amazonaws.com/repo:tag
|
|
635
|
-
const parts = resourceId.split('/');
|
|
636
|
-
const repoAndTag = parts[parts.length - 1];
|
|
637
|
-
const [repo, tag] = repoAndTag.split(':');
|
|
638
|
-
return `ecr describe-images --repository-name ${repo} --image-ids imageTag=${tag || 'latest'}`;
|
|
639
|
-
}
|
|
640
|
-
case 'codebuild-project': {
|
|
641
|
-
const name = this._extractNameFromArn(resourceId);
|
|
642
|
-
return `codebuild batch-get-projects --names ${name}`;
|
|
643
|
-
}
|
|
644
|
-
case 'iam-role': {
|
|
645
|
-
const name = this._extractNameFromArn(resourceId);
|
|
646
|
-
return `iam get-role --role-name ${name}`;
|
|
647
|
-
}
|
|
648
|
-
default:
|
|
649
|
-
return null;
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
/**
|
|
654
|
-
* Extract the resource name from an ARN.
|
|
655
|
-
* ARN format: arn:aws:service:region:account:resource-type/resource-name
|
|
656
|
-
* @param {string} arn - AWS ARN string
|
|
657
|
-
* @returns {string} The resource name portion
|
|
658
|
-
*/
|
|
659
|
-
_extractNameFromArn(arn) {
|
|
660
|
-
// Handle ARN formats like:
|
|
661
|
-
// arn:aws:sagemaker:us-east-1:111111111111:endpoint/my-endpoint
|
|
662
|
-
// arn:aws:iam::111111111111:role/my-role
|
|
663
|
-
// arn:aws:codebuild:us-east-1:111111111111:project/my-project
|
|
664
|
-
const parts = arn.split('/');
|
|
665
|
-
return parts[parts.length - 1];
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
/**
|
|
669
|
-
* Switch the active bootstrap profile.
|
|
670
|
-
* @param {string} profileName - Profile name to activate
|
|
671
|
-
*/
|
|
672
|
-
async _handleUse(profileName) {
|
|
673
|
-
if (!profileName) {
|
|
674
|
-
console.log('Usage: ml-container-creator bootstrap use <profile>');
|
|
675
|
-
console.log(' ml-container-creator bootstrap use none (deactivate)');
|
|
676
|
-
return;
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
if (profileName === 'none') {
|
|
680
|
-
this.config.setActiveProfile(null);
|
|
681
|
-
console.log('Active profile cleared. No bootstrap profile is active.');
|
|
682
|
-
return;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
const profile = this.config.getProfile(profileName);
|
|
686
|
-
if (!profile) {
|
|
687
|
-
const available = this.config.listProfiles();
|
|
688
|
-
console.log(`Profile "${profileName}" not found.`);
|
|
689
|
-
if (available.length > 0) {
|
|
690
|
-
console.log(`Available profiles: ${available.join(', ')}`);
|
|
691
|
-
} else {
|
|
692
|
-
console.log('No profiles configured. Run `ml-container-creator bootstrap` to create one.');
|
|
693
|
-
}
|
|
694
|
-
return;
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
this.config.setActiveProfile(profileName);
|
|
698
|
-
console.log(`Switched active profile to "${profileName}".`);
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
/**
|
|
702
|
-
* List all bootstrap profiles.
|
|
703
|
-
*/
|
|
704
|
-
async _handleList() {
|
|
705
|
-
const profiles = this.config.listProfiles();
|
|
706
|
-
|
|
707
|
-
if (profiles.length === 0) {
|
|
708
|
-
console.log('No bootstrap profiles configured.');
|
|
709
|
-
console.log('Run `ml-container-creator bootstrap` to set up shared infrastructure.');
|
|
710
|
-
return;
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
const config = this.config.read();
|
|
714
|
-
const activeProfileName = config ? config.activeProfile : null;
|
|
715
|
-
|
|
716
|
-
console.log('\nBootstrap Profiles:');
|
|
717
|
-
for (const name of profiles) {
|
|
718
|
-
if (name === activeProfileName) {
|
|
719
|
-
console.log(` * ${name} (active)`);
|
|
720
|
-
} else {
|
|
721
|
-
console.log(` ${name}`);
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
/**
|
|
727
|
-
* Remove a bootstrap profile.
|
|
728
|
-
* @param {string} profileName - Profile name to remove
|
|
729
|
-
* @param {object} options - Parsed CLI options (e.g., --force)
|
|
730
|
-
*/
|
|
731
|
-
async _handleRemove(profileName, options) {
|
|
732
|
-
if (!profileName) {
|
|
733
|
-
console.log('Usage: ml-container-creator bootstrap remove <profile> [--force]');
|
|
734
|
-
return;
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
const profile = this.config.getProfile(profileName);
|
|
738
|
-
if (!profile) {
|
|
739
|
-
console.log(`Profile "${profileName}" not found.`);
|
|
740
|
-
return;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
// Check for manifest file with active resources
|
|
744
|
-
const assetManager = new AssetManager(profileName);
|
|
745
|
-
const hasManifest = existsSync(assetManager.manifestPath);
|
|
746
|
-
|
|
747
|
-
if (hasManifest) {
|
|
748
|
-
const counts = assetManager.getStatusCounts();
|
|
749
|
-
if (counts.active > 0 && !options.force) {
|
|
750
|
-
console.log(`⚠️ Profile "${profileName}" has ${counts.active} active resource${counts.active === 1 ? '' : 's'} in the deployment manifest.`);
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
// Check for CloudFormation stack
|
|
755
|
-
const stackName = profile.stackName || `${STACK_NAME_PREFIX}-${profileName}`;
|
|
756
|
-
let hasStack = false;
|
|
757
|
-
try {
|
|
758
|
-
hasStack = this._resourceExists(
|
|
759
|
-
`cloudformation describe-stacks --stack-name ${stackName} --region ${profile.awsRegion}`,
|
|
760
|
-
profile.awsProfile
|
|
761
|
-
);
|
|
762
|
-
} catch {
|
|
763
|
-
// ignore
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
if (hasStack && !options.force) {
|
|
767
|
-
console.log(`⚠️ Profile "${profileName}" has a CloudFormation stack: ${stackName}`);
|
|
768
|
-
console.log(' Use --delete-stack to also delete the AWS resources, or --force to remove the profile only.');
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
if (!options.force) {
|
|
772
|
-
const { confirm } = await this._promptFn([{
|
|
773
|
-
type: 'confirm',
|
|
774
|
-
name: 'confirm',
|
|
775
|
-
message: `Remove bootstrap profile "${profileName}"?`,
|
|
776
|
-
default: false
|
|
777
|
-
}]);
|
|
778
|
-
|
|
779
|
-
if (!confirm) {
|
|
780
|
-
console.log('Removal cancelled.');
|
|
781
|
-
return;
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
// Delete CloudFormation stack if requested
|
|
786
|
-
if (hasStack && options['delete-stack']) {
|
|
787
|
-
try {
|
|
788
|
-
console.log(`🗑️ Deleting CloudFormation stack: ${stackName}`);
|
|
789
|
-
execSync(
|
|
790
|
-
`aws cloudformation delete-stack --stack-name ${stackName} --region ${profile.awsRegion} --profile ${profile.awsProfile}`,
|
|
791
|
-
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
792
|
-
);
|
|
793
|
-
console.log('⏳ Waiting for stack deletion...');
|
|
794
|
-
execSync(
|
|
795
|
-
`aws cloudformation wait stack-delete-complete --stack-name ${stackName} --region ${profile.awsRegion} --profile ${profile.awsProfile}`,
|
|
796
|
-
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
797
|
-
);
|
|
798
|
-
console.log(`✅ Stack "${stackName}" deleted.`);
|
|
799
|
-
} catch (err) {
|
|
800
|
-
console.log(`⚠️ Could not delete stack "${stackName}": ${err.message}`);
|
|
801
|
-
console.log(' You may need to delete it manually from the CloudFormation console.');
|
|
802
|
-
}
|
|
803
|
-
} else if (hasStack) {
|
|
804
|
-
console.log(`Note: CloudFormation stack "${stackName}" was left in place.`);
|
|
805
|
-
console.log(' To delete AWS resources, re-run with --delete-stack');
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
// Delete manifest file if it exists
|
|
809
|
-
if (hasManifest) {
|
|
810
|
-
try {
|
|
811
|
-
unlinkSync(assetManager.manifestPath);
|
|
812
|
-
console.log(`Manifest file for "${profileName}" deleted.`);
|
|
813
|
-
} catch {
|
|
814
|
-
console.log(`⚠️ Could not delete manifest file for "${profileName}".`);
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
this.config.removeProfile(profileName);
|
|
819
|
-
console.log(`Profile "${profileName}" removed.`);
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
/**
|
|
823
|
-
* Scan AWS for pre-existing MLCC-managed resources and add them to the manifest.
|
|
824
|
-
*/
|
|
825
|
-
async _handleScan() {
|
|
826
|
-
const profile = this.config.getActiveProfile();
|
|
827
|
-
if (!profile) {
|
|
828
|
-
console.log('No active bootstrap profile found.');
|
|
829
|
-
console.log('Run `ml-container-creator bootstrap` to set up shared infrastructure.');
|
|
830
|
-
return;
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
console.log(`\n🔍 Scanning for pre-existing resources in ${profile.config.awsRegion}...`);
|
|
834
|
-
|
|
835
|
-
const assetManager = new AssetManager(profile.name);
|
|
836
|
-
const now = new Date().toISOString();
|
|
837
|
-
let discovered = 0;
|
|
838
|
-
let added = 0;
|
|
839
|
-
let skipped = 0;
|
|
840
|
-
|
|
841
|
-
// 1. Query Resource Groups Tagging API for mlcc:managed-by tagged resources
|
|
842
|
-
try {
|
|
843
|
-
console.log('\n Checking tagged resources...');
|
|
844
|
-
const tagResult = this._execAws(
|
|
845
|
-
`resourcegroupstaggingapi get-resources --tag-filters Key=mlcc:managed-by,Values=ml-container-creator --region ${profile.config.awsRegion}`,
|
|
846
|
-
profile.config.awsProfile
|
|
847
|
-
);
|
|
848
|
-
|
|
849
|
-
const taggedResources = tagResult.ResourceTagMappingList || [];
|
|
850
|
-
for (const tagged of taggedResources) {
|
|
851
|
-
discovered++;
|
|
852
|
-
const arn = tagged.ResourceARN;
|
|
853
|
-
const existing = assetManager.getResource(arn);
|
|
854
|
-
if (existing) {
|
|
855
|
-
skipped++;
|
|
856
|
-
continue;
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
const resourceType = this._inferResourceTypeFromArn(arn);
|
|
860
|
-
if (!resourceType) {
|
|
861
|
-
skipped++;
|
|
862
|
-
continue;
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
const project = this._inferProjectFromTags(tagged.Tags) || 'unknown';
|
|
866
|
-
|
|
867
|
-
try {
|
|
868
|
-
assetManager.addResource({
|
|
869
|
-
resourceId: arn,
|
|
870
|
-
resourceType,
|
|
871
|
-
createdAt: now,
|
|
872
|
-
lastUpdatedAt: now,
|
|
873
|
-
project,
|
|
874
|
-
status: 'active',
|
|
875
|
-
metadata: { discoveredBy: 'scan' }
|
|
876
|
-
});
|
|
877
|
-
added++;
|
|
878
|
-
} catch {
|
|
879
|
-
skipped++;
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
} catch {
|
|
883
|
-
console.log(' ⚠️ Could not query tagged resources (credentials or API unavailable)');
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
// 2. Query ECR for images in ml-container-creator repository
|
|
887
|
-
try {
|
|
888
|
-
console.log(' Checking ECR images...');
|
|
889
|
-
const ecrResult = this._execAws(
|
|
890
|
-
`ecr describe-images --repository-name ml-container-creator --region ${profile.config.awsRegion}`,
|
|
891
|
-
profile.config.awsProfile
|
|
892
|
-
);
|
|
893
|
-
|
|
894
|
-
const images = ecrResult.imageDetails || [];
|
|
895
|
-
for (const image of images) {
|
|
896
|
-
const tags = image.imageTags || [];
|
|
897
|
-
for (const tag of tags) {
|
|
898
|
-
discovered++;
|
|
899
|
-
const imageUri = `${profile.config.accountId}.dkr.ecr.${profile.config.awsRegion}.amazonaws.com/ml-container-creator:${tag}`;
|
|
900
|
-
const existing = assetManager.getResource(imageUri);
|
|
901
|
-
if (existing) {
|
|
902
|
-
skipped++;
|
|
903
|
-
continue;
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
try {
|
|
907
|
-
assetManager.addResource({
|
|
908
|
-
resourceId: imageUri,
|
|
909
|
-
resourceType: 'ecr-image',
|
|
910
|
-
createdAt: now,
|
|
911
|
-
lastUpdatedAt: now,
|
|
912
|
-
project: this._inferProjectFromImageTag(tag),
|
|
913
|
-
status: 'active',
|
|
914
|
-
metadata: {
|
|
915
|
-
repositoryName: 'ml-container-creator',
|
|
916
|
-
imageTag: tag,
|
|
917
|
-
region: profile.config.awsRegion,
|
|
918
|
-
discoveredBy: 'scan'
|
|
919
|
-
}
|
|
920
|
-
});
|
|
921
|
-
added++;
|
|
922
|
-
} catch {
|
|
923
|
-
skipped++;
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
} catch {
|
|
928
|
-
console.log(' ⚠️ Could not query ECR images (credentials or API unavailable)');
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
// 3. Query CodeBuild for *-build-* projects
|
|
932
|
-
try {
|
|
933
|
-
console.log(' Checking CodeBuild projects...');
|
|
934
|
-
const cbResult = this._execAws(
|
|
935
|
-
`codebuild list-projects --region ${profile.config.awsRegion}`,
|
|
936
|
-
profile.config.awsProfile
|
|
937
|
-
);
|
|
938
|
-
|
|
939
|
-
const projects = (cbResult.projects || []).filter(name => name.includes('-build-'));
|
|
940
|
-
for (const projectName of projects) {
|
|
941
|
-
discovered++;
|
|
942
|
-
const arn = `arn:aws:codebuild:${profile.config.awsRegion}:${profile.config.accountId}:project/${projectName}`;
|
|
943
|
-
const existing = assetManager.getResource(arn);
|
|
944
|
-
if (existing) {
|
|
945
|
-
skipped++;
|
|
946
|
-
continue;
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
try {
|
|
950
|
-
assetManager.addResource({
|
|
951
|
-
resourceId: arn,
|
|
952
|
-
resourceType: 'codebuild-project',
|
|
953
|
-
createdAt: now,
|
|
954
|
-
lastUpdatedAt: now,
|
|
955
|
-
project: this._inferProjectFromCodeBuildName(projectName),
|
|
956
|
-
status: 'active',
|
|
957
|
-
metadata: {
|
|
958
|
-
projectName,
|
|
959
|
-
region: profile.config.awsRegion,
|
|
960
|
-
discoveredBy: 'scan'
|
|
961
|
-
}
|
|
962
|
-
});
|
|
963
|
-
added++;
|
|
964
|
-
} catch {
|
|
965
|
-
skipped++;
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
} catch {
|
|
969
|
-
console.log(' ⚠️ Could not query CodeBuild projects (credentials or API unavailable)');
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
// Display summary
|
|
973
|
-
console.log(`\n Scan complete: ${discovered} discovered, ${added} added, ${skipped} skipped (duplicates or unsupported)`);
|
|
974
|
-
|
|
975
|
-
if (discovered === 0) {
|
|
976
|
-
console.log(' No MLCC-managed resources were discovered.');
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
/**
|
|
981
|
-
* Prune stale records from the manifest — removes entries with status
|
|
982
|
-
* 'deleted' or 'unknown' that are no longer useful.
|
|
983
|
-
*/
|
|
984
|
-
async _handlePrune() {
|
|
985
|
-
const profile = this.config.getActiveProfile();
|
|
986
|
-
if (!profile) {
|
|
987
|
-
console.log('No active bootstrap profile found.');
|
|
988
|
-
return;
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
const assetManager = new AssetManager(profile.name);
|
|
992
|
-
|
|
993
|
-
if (!existsSync(assetManager.manifestPath)) {
|
|
994
|
-
console.log('No deployment tracking data to prune.');
|
|
995
|
-
return;
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
const before = assetManager.listResources();
|
|
999
|
-
const toRemove = before.filter(r => r.status === 'deleted' || r.status === 'unknown');
|
|
1000
|
-
|
|
1001
|
-
if (toRemove.length === 0) {
|
|
1002
|
-
console.log('Nothing to prune — no deleted or unknown records found.');
|
|
1003
|
-
return;
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
console.log(`\n🧹 Pruning ${toRemove.length} stale record${toRemove.length === 1 ? '' : 's'}:\n`);
|
|
1007
|
-
|
|
1008
|
-
for (const resource of toRemove) {
|
|
1009
|
-
assetManager.removeResource(resource.resourceId);
|
|
1010
|
-
console.log(` 🗑️ [${resource.status}] ${resource.resourceType}: ${resource.resourceId}`);
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
const after = assetManager.listResources();
|
|
1014
|
-
console.log(`\n Done. ${toRemove.length} removed, ${after.length} remaining.`);
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
/**
|
|
1018
|
-
* Handle sync-schemas subcommand: download service models and verify AWS CLI.
|
|
1019
|
-
*/
|
|
1020
|
-
async _handleSyncSchemas() {
|
|
1021
|
-
console.log('\n📦 Schema Sync — Downloading AWS service models...\n');
|
|
1022
|
-
|
|
1023
|
-
// Verify AWS CLI is installed
|
|
1024
|
-
try {
|
|
1025
|
-
const version = execSync('aws --version', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
1026
|
-
console.log(` AWS CLI: ${version}`);
|
|
1027
|
-
} catch {
|
|
1028
|
-
console.log(' ⚠️ AWS CLI not found.');
|
|
1029
|
-
console.log(' Install: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html');
|
|
1030
|
-
console.log(' Continuing without AWS CLI verification...\n');
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
// Dynamic import to avoid circular dependencies
|
|
1034
|
-
const { syncSchemas } = await import('./schema-sync.js');
|
|
1035
|
-
const result = await syncSchemas();
|
|
1036
|
-
|
|
1037
|
-
if (result.success) {
|
|
1038
|
-
console.log('\n ✅ Schema sync complete.');
|
|
1039
|
-
} else {
|
|
1040
|
-
console.log('\n ⚠️ Schema sync completed with errors (some services may be unavailable).');
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
console.log(` Manifest written: lastSynced = ${result.manifest.lastSynced}\n`);
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
396
|
/**
|
|
1047
397
|
* Re-deploy bootstrap infrastructure using the active profile.
|
|
1048
398
|
* No prompts — reads all config from the existing profile and re-applies
|
|
@@ -1203,6 +553,56 @@ export default class BootstrapCommandHandler {
|
|
|
1203
553
|
}
|
|
1204
554
|
}
|
|
1205
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Build the AWS CLI command to check if a resource still exists.
|
|
558
|
+
* @param {object} resource - Asset record
|
|
559
|
+
* @returns {string|null} AWS CLI command string, or null if resource type is not supported
|
|
560
|
+
*/
|
|
561
|
+
_buildDriftCheckCommand(resource) {
|
|
562
|
+
const resourceId = resource.resourceId;
|
|
563
|
+
|
|
564
|
+
switch (resource.resourceType) {
|
|
565
|
+
case 'sagemaker-endpoint': {
|
|
566
|
+
const name = this._extractNameFromArn(resourceId);
|
|
567
|
+
return `sagemaker describe-endpoint --endpoint-name ${name}`;
|
|
568
|
+
}
|
|
569
|
+
case 'sagemaker-model': {
|
|
570
|
+
const name = this._extractNameFromArn(resourceId);
|
|
571
|
+
return `sagemaker describe-model --model-name ${name}`;
|
|
572
|
+
}
|
|
573
|
+
case 'sagemaker-inference-component': {
|
|
574
|
+
const name = this._extractNameFromArn(resourceId);
|
|
575
|
+
return `sagemaker describe-inference-component --inference-component-name ${name}`;
|
|
576
|
+
}
|
|
577
|
+
case 'ecr-image': {
|
|
578
|
+
const parts = resourceId.split('/');
|
|
579
|
+
const repoAndTag = parts[parts.length - 1];
|
|
580
|
+
const [repo, tag] = repoAndTag.split(':');
|
|
581
|
+
return `ecr describe-images --repository-name ${repo} --image-ids imageTag=${tag || 'latest'}`;
|
|
582
|
+
}
|
|
583
|
+
case 'codebuild-project': {
|
|
584
|
+
const name = this._extractNameFromArn(resourceId);
|
|
585
|
+
return `codebuild batch-get-projects --names ${name}`;
|
|
586
|
+
}
|
|
587
|
+
case 'iam-role': {
|
|
588
|
+
const name = this._extractNameFromArn(resourceId);
|
|
589
|
+
return `iam get-role --role-name ${name}`;
|
|
590
|
+
}
|
|
591
|
+
default:
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Extract the resource name from an ARN.
|
|
598
|
+
* @param {string} arn - AWS ARN string
|
|
599
|
+
* @returns {string} The resource name portion
|
|
600
|
+
*/
|
|
601
|
+
_extractNameFromArn(arn) {
|
|
602
|
+
const parts = arn.split('/');
|
|
603
|
+
return parts[parts.length - 1];
|
|
604
|
+
}
|
|
605
|
+
|
|
1206
606
|
/**
|
|
1207
607
|
* Infer the resource type from an ARN.
|
|
1208
608
|
* @param {string} arn - AWS ARN
|
|
@@ -1307,396 +707,9 @@ export default class BootstrapCommandHandler {
|
|
|
1307
707
|
return { accountId, region };
|
|
1308
708
|
}
|
|
1309
709
|
|
|
1310
|
-
/**
|
|
1311
|
-
* Create or reuse the SageMaker execution IAM role.
|
|
1312
|
-
* @param {object} options - Parsed CLI options
|
|
1313
|
-
* @returns {Promise<string>} Role ARN
|
|
1314
|
-
*/
|
|
1315
|
-
async _setupIamRole(_options) {
|
|
1316
|
-
const roleName = 'mlcc-sagemaker-execution-role';
|
|
1317
|
-
|
|
1318
|
-
// Define trust policy for SageMaker
|
|
1319
|
-
const trustPolicy = {
|
|
1320
|
-
Version: '2012-10-17',
|
|
1321
|
-
Statement: [
|
|
1322
|
-
{
|
|
1323
|
-
Effect: 'Allow',
|
|
1324
|
-
Principal: {
|
|
1325
|
-
Service: 'sagemaker.amazonaws.com'
|
|
1326
|
-
},
|
|
1327
|
-
Action: 'sts:AssumeRole'
|
|
1328
|
-
}
|
|
1329
|
-
]
|
|
1330
|
-
};
|
|
1331
|
-
|
|
1332
|
-
// Define execution policy with least-privilege permissions
|
|
1333
|
-
const executionPolicy = {
|
|
1334
|
-
Version: '2012-10-17',
|
|
1335
|
-
Statement: [
|
|
1336
|
-
{
|
|
1337
|
-
Sid: 'SageMakerEndpoints',
|
|
1338
|
-
Effect: 'Allow',
|
|
1339
|
-
Action: [
|
|
1340
|
-
'sagemaker:CreateEndpoint',
|
|
1341
|
-
'sagemaker:CreateEndpointConfig',
|
|
1342
|
-
'sagemaker:CreateModel',
|
|
1343
|
-
'sagemaker:CreateInferenceComponent',
|
|
1344
|
-
'sagemaker:UpdateEndpoint',
|
|
1345
|
-
'sagemaker:UpdateEndpointWeightsAndCapacities',
|
|
1346
|
-
'sagemaker:UpdateInferenceComponent',
|
|
1347
|
-
'sagemaker:DeleteEndpoint',
|
|
1348
|
-
'sagemaker:DeleteEndpointConfig',
|
|
1349
|
-
'sagemaker:DeleteModel',
|
|
1350
|
-
'sagemaker:DeleteInferenceComponent',
|
|
1351
|
-
'sagemaker:DescribeEndpoint',
|
|
1352
|
-
'sagemaker:DescribeEndpointConfig',
|
|
1353
|
-
'sagemaker:DescribeModel',
|
|
1354
|
-
'sagemaker:DescribeInferenceComponent',
|
|
1355
|
-
'sagemaker:ListInferenceComponents',
|
|
1356
|
-
'sagemaker:InvokeEndpoint',
|
|
1357
|
-
'sagemaker:InvokeEndpointAsync'
|
|
1358
|
-
],
|
|
1359
|
-
Resource: '*'
|
|
1360
|
-
},
|
|
1361
|
-
{
|
|
1362
|
-
Sid: 'SageMakerBenchmarking',
|
|
1363
|
-
Effect: 'Allow',
|
|
1364
|
-
Action: [
|
|
1365
|
-
'sagemaker:CreateAIBenchmarkJob',
|
|
1366
|
-
'sagemaker:DescribeAIBenchmarkJob',
|
|
1367
|
-
'sagemaker:ListAIBenchmarkJobs',
|
|
1368
|
-
'sagemaker:StopAIBenchmarkJob',
|
|
1369
|
-
'sagemaker:DeleteAIBenchmarkJob',
|
|
1370
|
-
'sagemaker:CreateAIWorkloadConfig',
|
|
1371
|
-
'sagemaker:DescribeAIWorkloadConfig',
|
|
1372
|
-
'sagemaker:ListAIWorkloadConfigs',
|
|
1373
|
-
'sagemaker:DeleteAIWorkloadConfig'
|
|
1374
|
-
],
|
|
1375
|
-
Resource: '*'
|
|
1376
|
-
},
|
|
1377
|
-
{
|
|
1378
|
-
Sid: 'ECRPull',
|
|
1379
|
-
Effect: 'Allow',
|
|
1380
|
-
Action: [
|
|
1381
|
-
'ecr:GetAuthorizationToken',
|
|
1382
|
-
'ecr:BatchCheckLayerAvailability',
|
|
1383
|
-
'ecr:GetDownloadUrlForLayer',
|
|
1384
|
-
'ecr:BatchGetImage'
|
|
1385
|
-
],
|
|
1386
|
-
Resource: 'arn:aws:ecr:*:*:repository/ml-container-creator'
|
|
1387
|
-
},
|
|
1388
|
-
{
|
|
1389
|
-
Sid: 'ECRAuth',
|
|
1390
|
-
Effect: 'Allow',
|
|
1391
|
-
Action: 'ecr:GetAuthorizationToken',
|
|
1392
|
-
Resource: '*'
|
|
1393
|
-
},
|
|
1394
|
-
{
|
|
1395
|
-
Sid: 'CloudWatchLogs',
|
|
1396
|
-
Effect: 'Allow',
|
|
1397
|
-
Action: [
|
|
1398
|
-
'logs:CreateLogGroup',
|
|
1399
|
-
'logs:CreateLogStream',
|
|
1400
|
-
'logs:PutLogEvents'
|
|
1401
|
-
],
|
|
1402
|
-
Resource: 'arn:aws:logs:*:*:*'
|
|
1403
|
-
},
|
|
1404
|
-
{
|
|
1405
|
-
Sid: 'S3ModelRead',
|
|
1406
|
-
Effect: 'Allow',
|
|
1407
|
-
Action: [
|
|
1408
|
-
's3:GetObject',
|
|
1409
|
-
's3:PutObject',
|
|
1410
|
-
's3:AbortMultipartUpload',
|
|
1411
|
-
's3:ListBucket'
|
|
1412
|
-
],
|
|
1413
|
-
Resource: [
|
|
1414
|
-
'arn:aws:s3:::ml-container-creator-*',
|
|
1415
|
-
'arn:aws:s3:::ml-container-creator-*/*'
|
|
1416
|
-
]
|
|
1417
|
-
},
|
|
1418
|
-
{
|
|
1419
|
-
Sid: 'SNSPublish',
|
|
1420
|
-
Effect: 'Allow',
|
|
1421
|
-
Action: 'sns:Publish',
|
|
1422
|
-
Resource: 'arn:aws:sns:*:*:ml-container-creator-*'
|
|
1423
|
-
},
|
|
1424
|
-
{
|
|
1425
|
-
Sid: 'SecretsManagerBenchmark',
|
|
1426
|
-
Effect: 'Allow',
|
|
1427
|
-
Action: [
|
|
1428
|
-
'secretsmanager:CreateSecret',
|
|
1429
|
-
'secretsmanager:PutSecretValue',
|
|
1430
|
-
'secretsmanager:GetSecretValue',
|
|
1431
|
-
'secretsmanager:DescribeSecret'
|
|
1432
|
-
],
|
|
1433
|
-
Resource: 'arn:aws:secretsmanager:*:*:secret:ml-container-creator/*'
|
|
1434
|
-
},
|
|
1435
|
-
{
|
|
1436
|
-
Sid: 'QuotaAndAvailability',
|
|
1437
|
-
Effect: 'Allow',
|
|
1438
|
-
Action: [
|
|
1439
|
-
'service-quotas:GetServiceQuota',
|
|
1440
|
-
'service-quotas:ListServiceQuotas',
|
|
1441
|
-
'sagemaker:ListTrainingPlans',
|
|
1442
|
-
'sagemaker:DescribeTrainingPlan',
|
|
1443
|
-
'sagemaker:ListEndpoints'
|
|
1444
|
-
],
|
|
1445
|
-
Resource: '*'
|
|
1446
|
-
}
|
|
1447
|
-
]
|
|
1448
|
-
};
|
|
1449
|
-
|
|
1450
|
-
// Check if role already exists
|
|
1451
|
-
const roleExists = this._resourceExists(
|
|
1452
|
-
`iam get-role --role-name ${roleName}`,
|
|
1453
|
-
this._currentProfile
|
|
1454
|
-
);
|
|
1455
|
-
|
|
1456
|
-
if (roleExists) {
|
|
1457
|
-
const existingRole = this._execAws(
|
|
1458
|
-
`iam get-role --role-name ${roleName}`,
|
|
1459
|
-
this._currentProfile
|
|
1460
|
-
);
|
|
1461
|
-
const roleArn = existingRole.Role.Arn;
|
|
1462
|
-
console.log(` ✅ IAM role "${roleName}" already exists — reused`);
|
|
1463
|
-
|
|
1464
|
-
// Always update the inline policy and tags to ensure they're current
|
|
1465
|
-
try {
|
|
1466
|
-
const execPolicyFile = this._writeJsonTempFile(executionPolicy, 'exec-policy');
|
|
1467
|
-
this._execAws(
|
|
1468
|
-
`iam put-role-policy --role-name ${roleName} --policy-name mlcc-execution-policy --policy-document ${execPolicyFile}`,
|
|
1469
|
-
this._currentProfile
|
|
1470
|
-
);
|
|
1471
|
-
console.log(' ✅ IAM policy "mlcc-execution-policy" — updated');
|
|
1472
|
-
} catch (err) {
|
|
1473
|
-
console.log(` ⚠️ Could not update inline policy: ${err.message}`);
|
|
1474
|
-
}
|
|
1475
|
-
|
|
1476
|
-
try {
|
|
1477
|
-
const tags = this._buildResourceTags();
|
|
1478
|
-
this._execAws(
|
|
1479
|
-
`iam tag-role --role-name ${roleName} --tags ${this._formatTagsForCli(tags)}`,
|
|
1480
|
-
this._currentProfile
|
|
1481
|
-
);
|
|
1482
|
-
console.log(' ✅ IAM role tags — updated');
|
|
1483
|
-
} catch (err) {
|
|
1484
|
-
console.log(` ⚠️ Could not update role tags: ${err.message}`);
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
return roleArn;
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
// Display policies to user before creation
|
|
1491
|
-
console.log('\n Trust Policy:');
|
|
1492
|
-
console.log(JSON.stringify(trustPolicy, null, 2));
|
|
1493
|
-
console.log('\n Execution Policy:');
|
|
1494
|
-
console.log(JSON.stringify(executionPolicy, null, 2));
|
|
1495
|
-
console.log('');
|
|
1496
|
-
|
|
1497
|
-
try {
|
|
1498
|
-
// Create the IAM role — write policy to temp file to avoid shell escaping issues
|
|
1499
|
-
const trustPolicyFile = this._writeJsonTempFile(trustPolicy, 'trust-policy');
|
|
1500
|
-
const createRoleResult = this._execAws(
|
|
1501
|
-
`iam create-role --role-name ${roleName} --assume-role-policy-document ${trustPolicyFile}`,
|
|
1502
|
-
this._currentProfile
|
|
1503
|
-
);
|
|
1504
|
-
const roleArn = createRoleResult.Role.Arn;
|
|
1505
|
-
|
|
1506
|
-
// Attach inline execution policy
|
|
1507
|
-
const execPolicyFile = this._writeJsonTempFile(executionPolicy, 'exec-policy');
|
|
1508
|
-
this._execAws(
|
|
1509
|
-
`iam put-role-policy --role-name ${roleName} --policy-name mlcc-execution-policy --policy-document ${execPolicyFile}`,
|
|
1510
|
-
this._currentProfile
|
|
1511
|
-
);
|
|
1512
|
-
|
|
1513
|
-
// Apply resource tags
|
|
1514
|
-
const tags = this._buildResourceTags();
|
|
1515
|
-
this._execAws(
|
|
1516
|
-
`iam tag-role --role-name ${roleName} --tags ${this._formatTagsForCli(tags)}`,
|
|
1517
|
-
this._currentProfile
|
|
1518
|
-
);
|
|
1519
|
-
|
|
1520
|
-
console.log(` ✅ IAM role "${roleName}" — created`);
|
|
1521
|
-
return roleArn;
|
|
1522
|
-
} catch (error) {
|
|
1523
|
-
const errorMessage = error.message || '';
|
|
1524
|
-
if (errorMessage.includes('AccessDenied') || errorMessage.includes('UnauthorizedAccess')) {
|
|
1525
|
-
console.log(' ⚠️ Permission denied for iam:CreateRole. Please provide an existing role ARN.');
|
|
1526
|
-
const { roleArn } = await this._promptFn([{
|
|
1527
|
-
type: 'input',
|
|
1528
|
-
name: 'roleArn',
|
|
1529
|
-
message: 'Enter an existing IAM role ARN for SageMaker execution:'
|
|
1530
|
-
}]);
|
|
1531
|
-
return roleArn;
|
|
1532
|
-
}
|
|
1533
|
-
throw error;
|
|
1534
|
-
}
|
|
1535
|
-
}
|
|
1536
|
-
|
|
1537
|
-
/**
|
|
1538
|
-
* Create or reuse the ECR repository.
|
|
1539
|
-
* @returns {Promise<string>} ECR repository name
|
|
1540
|
-
*/
|
|
1541
|
-
async _setupEcrRepository() {
|
|
1542
|
-
const repoName = 'ml-container-creator';
|
|
1543
|
-
|
|
1544
|
-
// Check if repository already exists
|
|
1545
|
-
const repoExists = this._resourceExists(
|
|
1546
|
-
`ecr describe-repositories --repository-names ${repoName} --region ${this._currentRegion}`,
|
|
1547
|
-
this._currentProfile
|
|
1548
|
-
);
|
|
1549
|
-
|
|
1550
|
-
if (repoExists) {
|
|
1551
|
-
console.log(` ✅ ECR repository "${repoName}" already exists — reused`);
|
|
1552
|
-
return repoName;
|
|
1553
|
-
}
|
|
1554
|
-
|
|
1555
|
-
// Build resource tags
|
|
1556
|
-
const tags = this._buildResourceTags();
|
|
1557
|
-
|
|
1558
|
-
// Create the ECR repository with image scanning and AES256 encryption
|
|
1559
|
-
this._execAws(
|
|
1560
|
-
`ecr create-repository --repository-name ${repoName} --image-scanning-configuration scanOnPush=true --encryption-configuration encryptionType=AES256 --region ${this._currentRegion} --tags ${this._formatTagsForCli(tags)}`,
|
|
1561
|
-
this._currentProfile
|
|
1562
|
-
);
|
|
1563
|
-
|
|
1564
|
-
// Apply lifecycle policy to expire untagged images after 30 days
|
|
1565
|
-
const lifecyclePolicy = {
|
|
1566
|
-
rules: [
|
|
1567
|
-
{
|
|
1568
|
-
rulePriority: 1,
|
|
1569
|
-
description: 'Expire untagged images after 30 days',
|
|
1570
|
-
selection: {
|
|
1571
|
-
tagStatus: 'untagged',
|
|
1572
|
-
countType: 'sinceImagePushed',
|
|
1573
|
-
countUnit: 'days',
|
|
1574
|
-
countNumber: 30
|
|
1575
|
-
},
|
|
1576
|
-
action: {
|
|
1577
|
-
type: 'expire'
|
|
1578
|
-
}
|
|
1579
|
-
}
|
|
1580
|
-
]
|
|
1581
|
-
};
|
|
1582
|
-
|
|
1583
|
-
const lifecyclePolicyFile = this._writeJsonTempFile(lifecyclePolicy, 'ecr-lifecycle');
|
|
1584
|
-
this._execAws(
|
|
1585
|
-
`ecr put-lifecycle-policy --repository-name ${repoName} --lifecycle-policy-text ${lifecyclePolicyFile} --region ${this._currentRegion}`,
|
|
1586
|
-
this._currentProfile
|
|
1587
|
-
);
|
|
1588
|
-
|
|
1589
|
-
console.log(` ✅ ECR repository "${repoName}" — created`);
|
|
1590
|
-
return repoName;
|
|
1591
|
-
}
|
|
1592
|
-
|
|
1593
|
-
/**
|
|
1594
|
-
* Optionally create S3 buckets for async/batch deployments.
|
|
1595
|
-
* Always creates the benchmark S3 bucket (unconditional).
|
|
1596
|
-
* @returns {Promise<object|null>} Bucket names or null if skipped
|
|
1597
|
-
*/
|
|
1598
|
-
async _setupS3Buckets() {
|
|
1599
|
-
// Always create benchmark bucket (unconditional — avoids re-bootstrap when benchmarking is enabled later)
|
|
1600
|
-
const benchmarkBucketName = `ml-container-creator-benchmark-${this._currentRegion}-${this._currentAccountId}`;
|
|
1601
|
-
const tags = this._buildResourceTags();
|
|
1602
|
-
const benchmarkS3Bucket = await this._createS3Bucket(benchmarkBucketName, tags);
|
|
1603
|
-
|
|
1604
|
-
const { useS3 } = await this._promptFn([{
|
|
1605
|
-
type: 'confirm',
|
|
1606
|
-
name: 'useS3',
|
|
1607
|
-
message: 'Will you use async inference or batch transform?',
|
|
1608
|
-
default: false
|
|
1609
|
-
}]);
|
|
1610
|
-
|
|
1611
|
-
if (!useS3) {
|
|
1612
|
-
return { benchmarkS3Bucket };
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
const asyncBucketName = `ml-container-creator-async-${this._currentRegion}-${this._currentAccountId}`;
|
|
1616
|
-
const batchBucketName = `ml-container-creator-batch-${this._currentRegion}-${this._currentAccountId}`;
|
|
1617
|
-
|
|
1618
|
-
const asyncS3Bucket = await this._createS3Bucket(asyncBucketName, tags);
|
|
1619
|
-
const batchS3Bucket = await this._createS3Bucket(batchBucketName, tags);
|
|
1620
|
-
|
|
1621
|
-
return { asyncS3Bucket, batchS3Bucket, benchmarkS3Bucket };
|
|
1622
|
-
}
|
|
1623
|
-
|
|
1624
|
-
/**
|
|
1625
|
-
* Create or reuse a single S3 bucket with versioning, encryption, and tags.
|
|
1626
|
-
* @param {string} bucketName - S3 bucket name
|
|
1627
|
-
* @param {Array<{Key: string, Value: string}>} tags - Resource tags
|
|
1628
|
-
* @returns {Promise<string>} Bucket name
|
|
1629
|
-
*/
|
|
1630
|
-
async _createS3Bucket(bucketName, tags) {
|
|
1631
|
-
// Check if bucket already exists
|
|
1632
|
-
const bucketExists = this._resourceExists(
|
|
1633
|
-
`s3api head-bucket --bucket ${bucketName}`,
|
|
1634
|
-
this._currentProfile
|
|
1635
|
-
);
|
|
1636
|
-
|
|
1637
|
-
if (bucketExists) {
|
|
1638
|
-
console.log(` ✅ S3 bucket "${bucketName}" already exists — reused`);
|
|
1639
|
-
return bucketName;
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1642
|
-
// Build create-bucket command with region-appropriate configuration
|
|
1643
|
-
let createCommand = `s3api create-bucket --bucket ${bucketName} --region ${this._currentRegion}`;
|
|
1644
|
-
if (this._currentRegion !== 'us-east-1') {
|
|
1645
|
-
createCommand += ` --create-bucket-configuration LocationConstraint=${this._currentRegion}`;
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
this._execAws(createCommand, this._currentProfile);
|
|
1649
|
-
|
|
1650
|
-
// Enable versioning
|
|
1651
|
-
this._execAws(
|
|
1652
|
-
`s3api put-bucket-versioning --bucket ${bucketName} --versioning-configuration Status=Enabled`,
|
|
1653
|
-
this._currentProfile
|
|
1654
|
-
);
|
|
1655
|
-
|
|
1656
|
-
// Enable AES256 server-side encryption
|
|
1657
|
-
const encryptionConfig = { Rules: [{ ApplyServerSideEncryptionByDefault: { SSEAlgorithm: 'AES256' } }] };
|
|
1658
|
-
const encryptionFile = this._writeJsonTempFile(encryptionConfig, 's3-encryption');
|
|
1659
|
-
this._execAws(
|
|
1660
|
-
`s3api put-bucket-encryption --bucket ${bucketName} --server-side-encryption-configuration ${encryptionFile}`,
|
|
1661
|
-
this._currentProfile
|
|
1662
|
-
);
|
|
1663
|
-
|
|
1664
|
-
// Apply resource tags
|
|
1665
|
-
const tagging = { TagSet: tags };
|
|
1666
|
-
const taggingFile = this._writeJsonTempFile(tagging, 's3-tagging');
|
|
1667
|
-
this._execAws(
|
|
1668
|
-
`s3api put-bucket-tagging --bucket ${bucketName} --tagging ${taggingFile}`,
|
|
1669
|
-
this._currentProfile
|
|
1670
|
-
);
|
|
1671
|
-
|
|
1672
|
-
console.log(` ✅ S3 bucket "${bucketName}" — created`);
|
|
1673
|
-
return bucketName;
|
|
1674
|
-
}
|
|
1675
710
|
|
|
1676
711
|
// ── AWS CLI helpers ─────────────────────────────────────────────
|
|
1677
712
|
|
|
1678
|
-
/**
|
|
1679
|
-
* Verify AWS CLI v2 is installed. Returns true if v2 is detected, false otherwise.
|
|
1680
|
-
* Extracted as a method so tests can override it.
|
|
1681
|
-
* @returns {boolean}
|
|
1682
|
-
*/
|
|
1683
|
-
_verifyCliV2() {
|
|
1684
|
-
try {
|
|
1685
|
-
const versionOutput = execSync('aws --version', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
1686
|
-
if (!versionOutput.includes('aws-cli/2')) {
|
|
1687
|
-
console.log(` ❌ AWS CLI v2 is required. Detected: ${versionOutput.split(' ')[0]}`);
|
|
1688
|
-
console.log(' Install: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html');
|
|
1689
|
-
console.log(' Some features (benchmarking, newer SageMaker APIs) require CLI v2.\n');
|
|
1690
|
-
return false;
|
|
1691
|
-
}
|
|
1692
|
-
return true;
|
|
1693
|
-
} catch {
|
|
1694
|
-
console.log(' ❌ AWS CLI not found.');
|
|
1695
|
-
console.log(' Install: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html\n');
|
|
1696
|
-
return false;
|
|
1697
|
-
}
|
|
1698
|
-
}
|
|
1699
|
-
|
|
1700
713
|
/**
|
|
1701
714
|
* Execute an AWS CLI command and return parsed JSON output.
|
|
1702
715
|
* @param {string} command - AWS CLI command (without 'aws' prefix)
|
|
@@ -1808,22 +821,6 @@ export default class BootstrapCommandHandler {
|
|
|
1808
821
|
}
|
|
1809
822
|
}
|
|
1810
823
|
|
|
1811
|
-
// ── Tag helpers ─────────────────────────────────────────────────
|
|
1812
|
-
|
|
1813
|
-
/**
|
|
1814
|
-
* Build the standard resource tag set.
|
|
1815
|
-
* @returns {Array<{Key: string, Value: string}>} Tag array
|
|
1816
|
-
*/
|
|
1817
|
-
_buildResourceTags() {
|
|
1818
|
-
const packageJsonPath = path.resolve(__dirname, '../../package.json');
|
|
1819
|
-
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
1820
|
-
return [
|
|
1821
|
-
{ Key: 'mlcc:managed-by', Value: 'ml-container-creator' },
|
|
1822
|
-
{ Key: 'mlcc:created-by', Value: 'bootstrap' },
|
|
1823
|
-
{ Key: 'mlcc:version', Value: packageJson.version }
|
|
1824
|
-
];
|
|
1825
|
-
}
|
|
1826
|
-
|
|
1827
824
|
/**
|
|
1828
825
|
* Format tags for the AWS CLI --tags parameter.
|
|
1829
826
|
* Writes tags to a temp file and returns the file:// reference
|