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