@aws/ml-container-creator 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +202 -0
  2. package/LICENSE-THIRD-PARTY +68620 -0
  3. package/NOTICE +2 -0
  4. package/README.md +106 -0
  5. package/bin/cli.js +365 -0
  6. package/config/defaults.json +32 -0
  7. package/config/presets/transformers-djl.json +26 -0
  8. package/config/presets/transformers-gpu.json +24 -0
  9. package/config/presets/transformers-lmi.json +27 -0
  10. package/package.json +129 -0
  11. package/servers/README.md +419 -0
  12. package/servers/base-image-picker/catalogs/model-servers.json +1191 -0
  13. package/servers/base-image-picker/catalogs/python-slim.json +38 -0
  14. package/servers/base-image-picker/catalogs/triton-backends.json +51 -0
  15. package/servers/base-image-picker/catalogs/triton.json +38 -0
  16. package/servers/base-image-picker/index.js +495 -0
  17. package/servers/base-image-picker/manifest.json +17 -0
  18. package/servers/base-image-picker/package.json +15 -0
  19. package/servers/hyperpod-cluster-picker/LICENSE +202 -0
  20. package/servers/hyperpod-cluster-picker/index.js +424 -0
  21. package/servers/hyperpod-cluster-picker/manifest.json +14 -0
  22. package/servers/hyperpod-cluster-picker/package.json +17 -0
  23. package/servers/instance-recommender/LICENSE +202 -0
  24. package/servers/instance-recommender/catalogs/instances.json +852 -0
  25. package/servers/instance-recommender/index.js +284 -0
  26. package/servers/instance-recommender/manifest.json +16 -0
  27. package/servers/instance-recommender/package.json +15 -0
  28. package/servers/lib/LICENSE +202 -0
  29. package/servers/lib/bedrock-client.js +160 -0
  30. package/servers/lib/custom-validators.js +46 -0
  31. package/servers/lib/dynamic-resolver.js +36 -0
  32. package/servers/lib/package.json +11 -0
  33. package/servers/lib/schemas/image-catalog.schema.json +185 -0
  34. package/servers/lib/schemas/instances.schema.json +124 -0
  35. package/servers/lib/schemas/manifest.schema.json +64 -0
  36. package/servers/lib/schemas/model-catalog.schema.json +91 -0
  37. package/servers/lib/schemas/regions.schema.json +26 -0
  38. package/servers/lib/schemas/triton-backends.schema.json +51 -0
  39. package/servers/model-picker/catalogs/jumpstart-public.json +66 -0
  40. package/servers/model-picker/catalogs/popular-diffusors.json +88 -0
  41. package/servers/model-picker/catalogs/popular-transformers.json +226 -0
  42. package/servers/model-picker/index.js +1693 -0
  43. package/servers/model-picker/manifest.json +18 -0
  44. package/servers/model-picker/package.json +20 -0
  45. package/servers/region-picker/LICENSE +202 -0
  46. package/servers/region-picker/catalogs/regions.json +263 -0
  47. package/servers/region-picker/index.js +230 -0
  48. package/servers/region-picker/manifest.json +16 -0
  49. package/servers/region-picker/package.json +15 -0
  50. package/src/app.js +1007 -0
  51. package/src/copy-tpl.js +77 -0
  52. package/src/lib/accelerator-validator.js +39 -0
  53. package/src/lib/asset-manager.js +385 -0
  54. package/src/lib/aws-profile-parser.js +181 -0
  55. package/src/lib/bootstrap-command-handler.js +1647 -0
  56. package/src/lib/bootstrap-config.js +238 -0
  57. package/src/lib/ci-register-helpers.js +124 -0
  58. package/src/lib/ci-report-helpers.js +158 -0
  59. package/src/lib/ci-stage-helpers.js +268 -0
  60. package/src/lib/cli-handler.js +529 -0
  61. package/src/lib/comment-generator.js +544 -0
  62. package/src/lib/community-reports-validator.js +91 -0
  63. package/src/lib/config-manager.js +2106 -0
  64. package/src/lib/configuration-exporter.js +204 -0
  65. package/src/lib/configuration-manager.js +695 -0
  66. package/src/lib/configuration-matcher.js +221 -0
  67. package/src/lib/cpu-validator.js +36 -0
  68. package/src/lib/cuda-validator.js +57 -0
  69. package/src/lib/deployment-config-resolver.js +103 -0
  70. package/src/lib/deployment-entry-schema.js +125 -0
  71. package/src/lib/deployment-registry.js +598 -0
  72. package/src/lib/docker-introspection-validator.js +51 -0
  73. package/src/lib/engine-prefix-resolver.js +60 -0
  74. package/src/lib/huggingface-client.js +172 -0
  75. package/src/lib/key-value-parser.js +37 -0
  76. package/src/lib/known-flags-validator.js +200 -0
  77. package/src/lib/manifest-cli.js +280 -0
  78. package/src/lib/mcp-client.js +303 -0
  79. package/src/lib/mcp-command-handler.js +532 -0
  80. package/src/lib/neuron-validator.js +80 -0
  81. package/src/lib/parameter-schema-validator.js +284 -0
  82. package/src/lib/prompt-runner.js +1349 -0
  83. package/src/lib/prompts.js +1138 -0
  84. package/src/lib/registry-command-handler.js +519 -0
  85. package/src/lib/registry-loader.js +198 -0
  86. package/src/lib/rocm-validator.js +80 -0
  87. package/src/lib/schema-validator.js +157 -0
  88. package/src/lib/sensitive-redactor.js +59 -0
  89. package/src/lib/template-engine.js +156 -0
  90. package/src/lib/template-manager.js +341 -0
  91. package/src/lib/validation-engine.js +314 -0
  92. package/src/prompt-adapter.js +63 -0
  93. package/templates/Dockerfile +300 -0
  94. package/templates/IAM_PERMISSIONS.md +84 -0
  95. package/templates/MIGRATION.md +488 -0
  96. package/templates/PROJECT_README.md +439 -0
  97. package/templates/TEMPLATE_SYSTEM.md +243 -0
  98. package/templates/buildspec.yml +64 -0
  99. package/templates/code/chat_template.jinja +1 -0
  100. package/templates/code/flask/gunicorn_config.py +35 -0
  101. package/templates/code/flask/wsgi.py +10 -0
  102. package/templates/code/model_handler.py +387 -0
  103. package/templates/code/serve +300 -0
  104. package/templates/code/serve.py +175 -0
  105. package/templates/code/serving.properties +105 -0
  106. package/templates/code/start_server.py +39 -0
  107. package/templates/code/start_server.sh +39 -0
  108. package/templates/diffusors/Dockerfile +72 -0
  109. package/templates/diffusors/patch_image_api.py +35 -0
  110. package/templates/diffusors/serve +115 -0
  111. package/templates/diffusors/start_server.sh +114 -0
  112. package/templates/do/.gitkeep +1 -0
  113. package/templates/do/README.md +541 -0
  114. package/templates/do/build +83 -0
  115. package/templates/do/ci +681 -0
  116. package/templates/do/clean +811 -0
  117. package/templates/do/config +260 -0
  118. package/templates/do/deploy +1560 -0
  119. package/templates/do/export +306 -0
  120. package/templates/do/logs +319 -0
  121. package/templates/do/manifest +12 -0
  122. package/templates/do/push +119 -0
  123. package/templates/do/register +580 -0
  124. package/templates/do/run +113 -0
  125. package/templates/do/submit +417 -0
  126. package/templates/do/test +1147 -0
  127. package/templates/hyperpod/configmap.yaml +24 -0
  128. package/templates/hyperpod/deployment.yaml +71 -0
  129. package/templates/hyperpod/pvc.yaml +42 -0
  130. package/templates/hyperpod/service.yaml +17 -0
  131. package/templates/nginx-diffusors.conf +74 -0
  132. package/templates/nginx-predictors.conf +47 -0
  133. package/templates/nginx-tensorrt.conf +74 -0
  134. package/templates/requirements.txt +61 -0
  135. package/templates/sample_model/test_inference.py +123 -0
  136. package/templates/sample_model/train_abalone.py +252 -0
  137. package/templates/test/test_endpoint.sh +79 -0
  138. package/templates/test/test_local_image.sh +80 -0
  139. package/templates/test/test_model_handler.py +180 -0
  140. package/templates/triton/Dockerfile +128 -0
  141. package/templates/triton/config.pbtxt +163 -0
  142. package/templates/triton/model.py +130 -0
  143. package/templates/triton/requirements.txt +11 -0
@@ -0,0 +1,77 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import ejs from 'ejs'
5
+ import { globSync } from 'tinyglobby'
6
+ import fs from 'fs'
7
+ import path from 'path'
8
+
9
+ /**
10
+ * Binary file extensions that should be copied without EJS rendering.
11
+ */
12
+ const BINARY_EXTENSIONS = new Set([
13
+ '.png', '.jpg', '.jpeg', '.gif', '.ico', '.bmp', '.tiff', '.webp',
14
+ '.woff', '.woff2', '.ttf', '.eot', '.otf',
15
+ '.zip', '.tar', '.gz', '.bz2', '.7z',
16
+ '.pdf',
17
+ '.exe', '.dll', '.so', '.dylib',
18
+ '.pyc', '.pyo', '.class', '.jar', '.war', '.ear'
19
+ ])
20
+
21
+ /**
22
+ * Determines whether a file is binary based on its extension.
23
+ *
24
+ * @param {string} filePath - Path to the file
25
+ * @returns {boolean} True if the file has a known binary extension
26
+ */
27
+ function isBinaryFile(filePath) {
28
+ const ext = path.extname(filePath).toLowerCase()
29
+ return BINARY_EXTENSIONS.has(ext)
30
+ }
31
+
32
+ /**
33
+ * Copies template files from a source directory to a destination directory,
34
+ * rendering EJS templates with the provided variables.
35
+ *
36
+ * Binary files (identified by extension) are copied without EJS rendering.
37
+ * Directories are created as needed.
38
+ *
39
+ * @param {string} templateDir - Source directory containing template files
40
+ * @param {string} destDir - Destination directory for rendered output
41
+ * @param {object} vars - Variables to pass to EJS templates
42
+ * @param {string[]} [ignorePatterns=[]] - Glob patterns for files to exclude
43
+ */
44
+ export function copyTpl(templateDir, destDir, vars, ignorePatterns = []) {
45
+ const files = globSync('**/*', {
46
+ cwd: templateDir,
47
+ ignore: ignorePatterns,
48
+ dot: true,
49
+ onlyFiles: true
50
+ })
51
+
52
+ for (const file of files) {
53
+ const src = path.join(templateDir, file)
54
+ const dest = path.join(destDir, file)
55
+
56
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
57
+
58
+ if (isBinaryFile(file)) {
59
+ fs.copyFileSync(src, dest)
60
+ continue
61
+ }
62
+
63
+ const content = fs.readFileSync(src, 'utf8')
64
+
65
+ let rendered
66
+ try {
67
+ rendered = ejs.render(content, vars, { filename: src })
68
+ } catch (err) {
69
+ const line = err.line ? ` (line ${err.line})` : ''
70
+ throw new Error(
71
+ `EJS rendering failed for "${file}"${line}: ${err.message}`
72
+ )
73
+ }
74
+
75
+ fs.writeFileSync(dest, rendered)
76
+ }
77
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Base class for accelerator-specific validators.
3
+ * Implements the plugin architecture for extensible accelerator validation.
4
+ *
5
+ * Requirements: 4.10, 4.21
6
+ */
7
+ export default class AcceleratorValidator {
8
+ /**
9
+ * Validate framework config against instance config.
10
+ *
11
+ * @param {Object} frameworkConfig - Framework accelerator requirements
12
+ * @param {Object} frameworkConfig.accelerator - Accelerator configuration
13
+ * @param {string} frameworkConfig.accelerator.type - Accelerator type (cuda, neuron, cpu, rocm)
14
+ * @param {string} frameworkConfig.accelerator.version - Required accelerator version
15
+ * @param {Object} instanceConfig - Instance accelerator capabilities
16
+ * @param {Object} instanceConfig.accelerator - Accelerator configuration
17
+ * @param {string} instanceConfig.accelerator.type - Accelerator type
18
+ * @param {Array<string>} instanceConfig.accelerator.versions - Supported versions
19
+ * @returns {Object} ValidationResult
20
+ * @returns {boolean} ValidationResult.compatible - Whether configuration is compatible
21
+ * @returns {string} [ValidationResult.error] - Error message if incompatible
22
+ * @returns {string} [ValidationResult.warning] - Warning message if issues detected
23
+ * @returns {string} [ValidationResult.info] - Informational message
24
+ */
25
+ validate(_frameworkConfig, _instanceConfig) {
26
+ throw new Error('AcceleratorValidator.validate() must be implemented by subclass');
27
+ }
28
+
29
+ /**
30
+ * Get user-friendly error message for version mismatch.
31
+ *
32
+ * @param {string} required - Required version
33
+ * @param {Array<string>|string} provided - Provided version(s)
34
+ * @returns {string} User-friendly error message
35
+ */
36
+ getVersionMismatchMessage(_required, _provided) {
37
+ throw new Error('AcceleratorValidator.getVersionMismatchMessage() must be implemented by subclass');
38
+ }
39
+ }
@@ -0,0 +1,385 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Asset Manager
6
+ *
7
+ * Core data-access module for the deployment registry asset manifest.
8
+ * Handles reading, writing, querying, and validating the per-profile
9
+ * asset manifest stored at ~/.ml-container-creator/manifests/{profileName}.json.
10
+ *
11
+ * Manifest file format:
12
+ * {
13
+ * "schemaVersion": "2026-05-04",
14
+ * "resources": [
15
+ * {
16
+ * "resourceId": "arn:aws:sagemaker:us-east-1:111111111111:endpoint/my-endpoint",
17
+ * "resourceType": "sagemaker-endpoint",
18
+ * "createdAt": "2026-05-04T10:30:00Z",
19
+ * "lastUpdatedAt": "2026-05-04T10:30:00Z",
20
+ * "project": "my-llm-project",
21
+ * "status": "active",
22
+ * "metadata": {
23
+ * "endpointName": "my-endpoint",
24
+ * "instanceType": "ml.g5.xlarge",
25
+ * "region": "us-east-1"
26
+ * }
27
+ * }
28
+ * ]
29
+ * }
30
+ */
31
+
32
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
33
+ import { join, dirname } from 'node:path'
34
+ import { homedir } from 'node:os'
35
+
36
+ const SCHEMA_VERSION = '2026-05-04'
37
+
38
+ const VALID_RESOURCE_TYPES = [
39
+ 'sagemaker-endpoint',
40
+ 'sagemaker-endpoint-config',
41
+ 'sagemaker-model',
42
+ 'sagemaker-inference-component',
43
+ 'sagemaker-transform-job',
44
+ 'ecr-image',
45
+ 'codebuild-project',
46
+ 'iam-role',
47
+ 's3-object',
48
+ 'sns-topic',
49
+ 'k8s-deployment',
50
+ 'k8s-service'
51
+ ]
52
+
53
+ const VALID_STATUSES = ['active', 'deleted', 'unknown']
54
+
55
+ export { SCHEMA_VERSION, VALID_RESOURCE_TYPES, VALID_STATUSES }
56
+
57
+ export default class AssetManager {
58
+ /**
59
+ * @param {string} profileName - The bootstrap profile name
60
+ * @param {Object} [options] - Optional configuration
61
+ * @param {string} [options.configDir] - Override the default config directory
62
+ * Defaults to ~/.ml-container-creator
63
+ */
64
+ constructor(profileName, options = {}) {
65
+ this.profileName = profileName
66
+ this.configDir = options.configDir || join(homedir(), '.ml-container-creator')
67
+ }
68
+
69
+ /**
70
+ * Derive the manifest file path from the profile name.
71
+ *
72
+ * @returns {string} Absolute path to the manifest JSON file
73
+ */
74
+ get manifestPath() {
75
+ return join(this.configDir, 'manifests', `${this.profileName}.json`)
76
+ }
77
+
78
+ /**
79
+ * Read the manifest file and return the parsed manifest object.
80
+ *
81
+ * Handles:
82
+ * - Missing file → return { schemaVersion, resources: [] }
83
+ * - Invalid JSON → throw descriptive error
84
+ * - Missing/unrecognized schemaVersion → log warning, attempt best-effort read
85
+ *
86
+ * @returns {{ schemaVersion: string, resources: Array<Object> }}
87
+ */
88
+ _readManifest() {
89
+ if (!existsSync(this.manifestPath)) {
90
+ return { schemaVersion: SCHEMA_VERSION, resources: [] }
91
+ }
92
+
93
+ const raw = readFileSync(this.manifestPath, 'utf8')
94
+
95
+ let data
96
+ try {
97
+ data = JSON.parse(raw)
98
+ } catch (err) {
99
+ throw new Error(
100
+ `Invalid JSON in manifest file ${this.manifestPath}: ${err.message}`
101
+ )
102
+ }
103
+
104
+ if (!data.schemaVersion) {
105
+ console.warn(
106
+ `Warning: Manifest file ${this.manifestPath} has no schemaVersion. Attempting best-effort read.`
107
+ )
108
+ } else if (data.schemaVersion !== SCHEMA_VERSION) {
109
+ console.warn(
110
+ `Warning: Manifest file ${this.manifestPath} has unrecognized schemaVersion "${data.schemaVersion}". Attempting best-effort read.`
111
+ )
112
+ }
113
+
114
+ return {
115
+ schemaVersion: data.schemaVersion || SCHEMA_VERSION,
116
+ resources: Array.isArray(data.resources) ? data.resources : []
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Write a manifest object to the manifest file.
122
+ * Creates parent directories if they don't exist.
123
+ * Uses 2-space indentation and a trailing newline.
124
+ *
125
+ * @param {{ schemaVersion: string, resources: Array<Object> }} manifest
126
+ */
127
+ _writeManifest(manifest) {
128
+ const dir = dirname(this.manifestPath)
129
+ if (!existsSync(dir)) {
130
+ mkdirSync(dir, { recursive: true })
131
+ }
132
+
133
+ writeFileSync(
134
+ this.manifestPath,
135
+ `${JSON.stringify(manifest, null, 2)}\n`
136
+ )
137
+ }
138
+
139
+ /**
140
+ * Validate an Asset_Record against the required schema.
141
+ *
142
+ * Checks:
143
+ * - Required fields: resourceId, resourceType, createdAt, lastUpdatedAt, project, status, metadata
144
+ * - resourceType is one of VALID_RESOURCE_TYPES
145
+ * - status is one of VALID_STATUSES
146
+ * - createdAt and lastUpdatedAt are valid ISO 8601 strings
147
+ * - metadata is a non-null object
148
+ *
149
+ * @param {Object} record - The record to validate
150
+ * @returns {{ valid: boolean, errors: string[] }}
151
+ */
152
+ _validateRecord(record) {
153
+ const errors = []
154
+
155
+ const requiredFields = [
156
+ 'resourceId',
157
+ 'resourceType',
158
+ 'createdAt',
159
+ 'lastUpdatedAt',
160
+ 'project',
161
+ 'status',
162
+ 'metadata'
163
+ ]
164
+
165
+ for (const field of requiredFields) {
166
+ if (record[field] === undefined || record[field] === null) {
167
+ errors.push(`Missing required field: ${field}`)
168
+ }
169
+ }
170
+
171
+ if (record.resourceType !== undefined && record.resourceType !== null) {
172
+ if (!VALID_RESOURCE_TYPES.includes(record.resourceType)) {
173
+ errors.push(
174
+ `Invalid resourceType: "${record.resourceType}". Must be one of: ${VALID_RESOURCE_TYPES.join(', ')}`
175
+ )
176
+ }
177
+ }
178
+
179
+ if (record.status !== undefined && record.status !== null) {
180
+ if (!VALID_STATUSES.includes(record.status)) {
181
+ errors.push(
182
+ `Invalid status: "${record.status}". Must be one of: ${VALID_STATUSES.join(', ')}`
183
+ )
184
+ }
185
+ }
186
+
187
+ if (record.createdAt !== undefined && record.createdAt !== null) {
188
+ if (!_isValidISO8601(record.createdAt)) {
189
+ errors.push(
190
+ `Invalid createdAt: "${record.createdAt}". Must be a valid ISO 8601 timestamp.`
191
+ )
192
+ }
193
+ }
194
+
195
+ if (record.lastUpdatedAt !== undefined && record.lastUpdatedAt !== null) {
196
+ if (!_isValidISO8601(record.lastUpdatedAt)) {
197
+ errors.push(
198
+ `Invalid lastUpdatedAt: "${record.lastUpdatedAt}". Must be a valid ISO 8601 timestamp.`
199
+ )
200
+ }
201
+ }
202
+
203
+ if (record.metadata !== undefined && record.metadata !== null) {
204
+ if (typeof record.metadata !== 'object' || Array.isArray(record.metadata)) {
205
+ errors.push('Invalid metadata: must be a non-null object.')
206
+ }
207
+ }
208
+
209
+ return { valid: errors.length === 0, errors }
210
+ }
211
+
212
+ /**
213
+ * Add or update a resource record in the manifest (upsert semantics).
214
+ *
215
+ * Validates the record, reads the manifest, and either updates an
216
+ * existing record (matching resourceId) or appends a new one.
217
+ *
218
+ * @param {Object} record - The Asset_Record to add or update
219
+ * @throws {Error} If the record fails validation
220
+ */
221
+ addResource(record) {
222
+ const { valid, errors } = this._validateRecord(record)
223
+ if (!valid) {
224
+ throw new Error(`Invalid asset record: ${errors.join('; ')}`)
225
+ }
226
+
227
+ const manifest = this._readManifest()
228
+ const existingIndex = manifest.resources.findIndex(
229
+ r => r.resourceId === record.resourceId
230
+ )
231
+
232
+ if (existingIndex !== -1) {
233
+ manifest.resources[existingIndex].lastUpdatedAt = record.lastUpdatedAt
234
+ manifest.resources[existingIndex].status = record.status
235
+ } else {
236
+ manifest.resources.push(record)
237
+ }
238
+
239
+ this._writeManifest(manifest)
240
+ }
241
+
242
+ /**
243
+ * Update the status of a resource by its resourceId.
244
+ *
245
+ * @param {string} resourceId - The resource identifier to find
246
+ * @param {string} newStatus - The new status value
247
+ * @returns {boolean} true if the resource was found and updated, false otherwise
248
+ */
249
+ updateStatus(resourceId, newStatus) {
250
+ const manifest = this._readManifest()
251
+ const resource = manifest.resources.find(r => r.resourceId === resourceId)
252
+
253
+ if (!resource) {
254
+ return false
255
+ }
256
+
257
+ resource.status = newStatus
258
+ resource.lastUpdatedAt = new Date().toISOString()
259
+ this._writeManifest(manifest)
260
+ return true
261
+ }
262
+
263
+ /**
264
+ * Get a single resource record by its resourceId.
265
+ *
266
+ * @param {string} resourceId - The resource identifier to find
267
+ * @returns {Object|null} The matching Asset_Record, or null if not found
268
+ */
269
+ getResource(resourceId) {
270
+ const manifest = this._readManifest()
271
+ return manifest.resources.find(r => r.resourceId === resourceId) || null
272
+ }
273
+
274
+ /**
275
+ * Remove a resource record from the manifest by its resourceId.
276
+ *
277
+ * @param {string} resourceId - The resource identifier to remove
278
+ * @returns {boolean} true if the resource was found and removed, false otherwise
279
+ */
280
+ removeResource(resourceId) {
281
+ const manifest = this._readManifest()
282
+ const originalLength = manifest.resources.length
283
+ manifest.resources = manifest.resources.filter(
284
+ r => r.resourceId !== resourceId
285
+ )
286
+
287
+ if (manifest.resources.length === originalLength) {
288
+ return false
289
+ }
290
+
291
+ this._writeManifest(manifest)
292
+ return true
293
+ }
294
+
295
+ /**
296
+ * List resources matching optional filters (AND logic).
297
+ *
298
+ * Supported filter keys: resourceType, project, status.
299
+ * With no filters, returns all resources.
300
+ *
301
+ * @param {Object} [filters] - Optional filter criteria
302
+ * @returns {Array<Object>} Matching Asset_Records
303
+ */
304
+ listResources(filters = {}) {
305
+ const manifest = this._readManifest()
306
+
307
+ if (!filters || Object.keys(filters).length === 0) {
308
+ return manifest.resources
309
+ }
310
+
311
+ return manifest.resources.filter(resource => {
312
+ if (filters.resourceType && resource.resourceType !== filters.resourceType) {
313
+ return false
314
+ }
315
+ if (filters.project && resource.project !== filters.project) {
316
+ return false
317
+ }
318
+ if (filters.status && resource.status !== filters.status) {
319
+ return false
320
+ }
321
+ return true
322
+ })
323
+ }
324
+
325
+ /**
326
+ * Group all resources by their project name.
327
+ *
328
+ * @returns {Map<string, Array<Object>>} Map of project name → Asset_Record array
329
+ */
330
+ getResourcesByProject() {
331
+ const manifest = this._readManifest()
332
+ const grouped = new Map()
333
+
334
+ for (const resource of manifest.resources) {
335
+ const project = resource.project
336
+ if (!grouped.has(project)) {
337
+ grouped.set(project, [])
338
+ }
339
+ grouped.get(project).push(resource)
340
+ }
341
+
342
+ return grouped
343
+ }
344
+
345
+ /**
346
+ * Count resources by status.
347
+ *
348
+ * @returns {{ active: number, deleted: number, unknown: number }}
349
+ */
350
+ getStatusCounts() {
351
+ const manifest = this._readManifest()
352
+ const counts = { active: 0, deleted: 0, unknown: 0 }
353
+
354
+ for (const resource of manifest.resources) {
355
+ if (resource.status in counts) {
356
+ counts[resource.status]++
357
+ }
358
+ }
359
+
360
+ return counts
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Check whether a string is a valid ISO 8601 timestamp.
366
+ *
367
+ * Accepts any string that the Date constructor can parse into a valid date
368
+ * and that matches the ISO 8601 pattern (must contain date separators and
369
+ * a time designator). This allows both `2026-05-04T10:30:00Z` and
370
+ * `2026-05-04T10:30:00.000Z` forms.
371
+ *
372
+ * @param {string} str - The string to check
373
+ * @returns {boolean} true if the string is a valid ISO 8601 timestamp
374
+ */
375
+ function _isValidISO8601(str) {
376
+ if (typeof str !== 'string' || str.length === 0) {
377
+ return false
378
+ }
379
+ const date = new Date(str)
380
+ if (isNaN(date.getTime())) {
381
+ return false
382
+ }
383
+ // Ensure the string looks like an ISO 8601 timestamp (not just any parseable date string)
384
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(str)
385
+ }
@@ -0,0 +1,181 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * AWS Profile Parser
6
+ *
7
+ * Parses INI-format AWS configuration files (~/.aws/config and
8
+ * ~/.aws/credentials) to extract available profile names. Used by
9
+ * the bootstrap command to present a selectable list of AWS profiles.
10
+ *
11
+ * Parsing rules:
12
+ * - ~/.aws/config uses [profile name] sections (except [default])
13
+ * - ~/.aws/credentials uses [name] sections directly
14
+ * - Profiles from both files are merged and deduplicated
15
+ * - 'default' is always sorted first if present
16
+ */
17
+
18
+ import { readFileSync, existsSync } from 'node:fs'
19
+ import { join } from 'node:path'
20
+ import { homedir } from 'node:os'
21
+
22
+ export default class AwsProfileParser {
23
+ /**
24
+ * @param {Object} [options] - Optional overrides for testing
25
+ * @param {string} [options.configPath] - Override path to ~/.aws/config
26
+ * @param {string} [options.credentialsPath] - Override path to ~/.aws/credentials
27
+ */
28
+ constructor(options = {}) {
29
+ this._configPath = options.configPath || null
30
+ this._credentialsPath = options.credentialsPath || null
31
+ }
32
+
33
+ /**
34
+ * Get all AWS profile names from config and credentials files.
35
+ * Returns a deduplicated array with 'default' sorted first if present.
36
+ *
37
+ * @returns {string[]} Profile names, with 'default' first if it exists
38
+ */
39
+ getProfiles() {
40
+ const configProfiles = this._getProfilesFromConfig()
41
+ const credentialsProfiles = this._getProfilesFromCredentials()
42
+
43
+ const allNames = new Set([...configProfiles, ...credentialsProfiles])
44
+
45
+ const sorted = [...allNames].sort((a, b) => {
46
+ if (a === 'default') return -1
47
+ if (b === 'default') return 1
48
+ return a.localeCompare(b)
49
+ })
50
+
51
+ return sorted
52
+ }
53
+
54
+ /**
55
+ * Parse an INI-format file into a Map of section names to key-value objects.
56
+ *
57
+ * @param {string} filePath - Absolute path to the INI file
58
+ * @returns {Map<string, Object>} Map of section names to parsed key-value pairs
59
+ */
60
+ _parseIniFile(filePath) {
61
+ const sections = new Map()
62
+
63
+ if (!existsSync(filePath)) {
64
+ return sections
65
+ }
66
+
67
+ let content
68
+ try {
69
+ content = readFileSync(filePath, 'utf8')
70
+ } catch {
71
+ return sections
72
+ }
73
+
74
+ let currentSection = null
75
+ const lines = content.split(/\r?\n/)
76
+
77
+ for (const rawLine of lines) {
78
+ const line = rawLine.trim()
79
+
80
+ // Skip empty lines and comments
81
+ if (!line || line.startsWith('#') || line.startsWith(';')) {
82
+ continue
83
+ }
84
+
85
+ // Check for section header
86
+ const sectionMatch = line.match(/^\[([^\]]+)\]$/)
87
+ if (sectionMatch) {
88
+ currentSection = sectionMatch[1].trim()
89
+ if (!sections.has(currentSection)) {
90
+ sections.set(currentSection, {})
91
+ }
92
+ continue
93
+ }
94
+
95
+ // Parse key = value pairs
96
+ if (currentSection) {
97
+ const kvMatch = line.match(/^([^=]+?)=(.*)$/)
98
+ if (kvMatch) {
99
+ const key = kvMatch[1].trim()
100
+ const value = kvMatch[2].trim()
101
+ sections.get(currentSection)[key] = value
102
+ }
103
+ }
104
+ }
105
+
106
+ return sections
107
+ }
108
+
109
+ /**
110
+ * Extract profile names from a parsed INI Map.
111
+ * Handles both config-style ([profile X]) and credentials-style ([X]) sections.
112
+ *
113
+ * @param {Map<string, Object>} parsed - Parsed INI sections
114
+ * @param {boolean} [isConfig=false] - Whether this is a config file (uses [profile X] format)
115
+ * @returns {string[]} Array of profile names
116
+ */
117
+ _extractProfileNames(parsed, isConfig = false) {
118
+ const names = []
119
+
120
+ for (const sectionName of parsed.keys()) {
121
+ if (isConfig) {
122
+ // ~/.aws/config uses [profile name] except for [default]
123
+ if (sectionName === 'default') {
124
+ names.push('default')
125
+ } else if (sectionName.startsWith('profile ')) {
126
+ const name = sectionName.slice('profile '.length).trim()
127
+ if (name) {
128
+ names.push(name)
129
+ }
130
+ }
131
+ } else {
132
+ // ~/.aws/credentials uses [name] directly
133
+ names.push(sectionName)
134
+ }
135
+ }
136
+
137
+ return names
138
+ }
139
+
140
+ /**
141
+ * Get the path to the AWS config file.
142
+ *
143
+ * @returns {string} Absolute path to ~/.aws/config
144
+ */
145
+ _getConfigPath() {
146
+ return this._configPath || join(homedir(), '.aws', 'config')
147
+ }
148
+
149
+ /**
150
+ * Get the path to the AWS credentials file.
151
+ *
152
+ * @returns {string} Absolute path to ~/.aws/credentials
153
+ */
154
+ _getCredentialsPath() {
155
+ return this._credentialsPath || join(homedir(), '.aws', 'credentials')
156
+ }
157
+
158
+ /**
159
+ * Get profile names from the AWS config file.
160
+ *
161
+ * @returns {string[]} Profile names from ~/.aws/config
162
+ * @private
163
+ */
164
+ _getProfilesFromConfig() {
165
+ const configPath = this._getConfigPath()
166
+ const parsed = this._parseIniFile(configPath)
167
+ return this._extractProfileNames(parsed, true)
168
+ }
169
+
170
+ /**
171
+ * Get profile names from the AWS credentials file.
172
+ *
173
+ * @returns {string[]} Profile names from ~/.aws/credentials
174
+ * @private
175
+ */
176
+ _getProfilesFromCredentials() {
177
+ const credentialsPath = this._getCredentialsPath()
178
+ const parsed = this._parseIniFile(credentialsPath)
179
+ return this._extractProfileNames(parsed, false)
180
+ }
181
+ }