@aws/ml-container-creator 0.9.0 → 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.
Files changed (48) hide show
  1. package/bin/cli.js +31 -137
  2. package/config/parameter-schema-v2.json +2065 -0
  3. package/package.json +6 -3
  4. package/servers/lib/catalogs/jumpstart-public.json +101 -16
  5. package/servers/lib/catalogs/models.json +182 -26
  6. package/src/app.js +6 -389
  7. package/src/lib/bootstrap-command-handler.js +75 -1078
  8. package/src/lib/bootstrap-profile-manager.js +634 -0
  9. package/src/lib/bootstrap-provisioners.js +421 -0
  10. package/src/lib/config-loader.js +405 -0
  11. package/src/lib/config-manager.js +59 -1668
  12. package/src/lib/config-mcp-client.js +118 -0
  13. package/src/lib/config-validator.js +634 -0
  14. package/src/lib/cuda-resolver.js +140 -0
  15. package/src/lib/e2e-catalog-validator.js +251 -3
  16. package/src/lib/e2e-ci-recorder.js +103 -0
  17. package/src/lib/generated/cli-options.js +471 -0
  18. package/src/lib/generated/parameter-matrix.js +671 -0
  19. package/src/lib/generated/validation-rules.js +202 -0
  20. package/src/lib/marketplace-flow.js +276 -0
  21. package/src/lib/mcp-query-runner.js +768 -0
  22. package/src/lib/parameter-schema-validator.js +62 -18
  23. package/src/lib/prompt-runner.js +41 -1504
  24. package/src/lib/prompts/feature-prompts.js +172 -0
  25. package/src/lib/prompts/index.js +48 -0
  26. package/src/lib/prompts/infrastructure-prompts.js +690 -0
  27. package/src/lib/prompts/model-prompts.js +552 -0
  28. package/src/lib/prompts/project-prompts.js +70 -0
  29. package/src/lib/prompts.js +2 -1446
  30. package/src/lib/registry-command-handler.js +135 -3
  31. package/src/lib/secrets-prompt-runner.js +251 -0
  32. package/src/lib/template-variable-resolver.js +398 -0
  33. package/templates/code/serve +5 -134
  34. package/templates/code/serve.d/lmi.ejs +19 -0
  35. package/templates/code/serve.d/sglang.ejs +47 -0
  36. package/templates/code/serve.d/tensorrt-llm.ejs +53 -0
  37. package/templates/code/serve.d/vllm.ejs +48 -0
  38. package/templates/do/clean +1 -1387
  39. package/templates/do/clean.d/async-inference.ejs +508 -0
  40. package/templates/do/clean.d/batch-transform.ejs +512 -0
  41. package/templates/do/clean.d/hyperpod-eks.ejs +481 -0
  42. package/templates/do/clean.d/managed-inference.ejs +1043 -0
  43. package/templates/do/deploy +1 -1766
  44. package/templates/do/deploy.d/async-inference.ejs +501 -0
  45. package/templates/do/deploy.d/batch-transform.ejs +529 -0
  46. package/templates/do/deploy.d/hyperpod-eks.ejs +339 -0
  47. package/templates/do/deploy.d/managed-inference.ejs +726 -0
  48. 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, readFileSync, unlinkSync, writeFileSync, mkdirSync } from 'node:fs';
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