@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,681 @@
1
+ #!/bin/bash
2
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ set -e
6
+ set -u
7
+ set -o pipefail
8
+
9
+ # Source configuration
10
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ source "${SCRIPT_DIR}/config"
12
+
13
+ # ============================================================
14
+ # CI Integration Harness — local entry point
15
+ # Subcommands: report, status, trigger, dashboard
16
+ # ============================================================
17
+
18
+ CI_TABLE_NAME="${CI_TABLE_NAME:-mlcc-ci-table}"
19
+ CI_LOG_GROUP="${CI_LOG_GROUP:-ml-container-creator-ci}"
20
+
21
+ # 15 known deployment configurations from the catalog
22
+ KNOWN_CONFIGS=(
23
+ "transformers-vllm"
24
+ "transformers-sglang"
25
+ "transformers-lmi"
26
+ "transformers-djl"
27
+ "transformers-tensorrt-llm"
28
+ "http-flask"
29
+ "http-fastapi"
30
+ "http-nginx"
31
+ "triton-fil"
32
+ "triton-python"
33
+ "triton-onnx"
34
+ "triton-tensorrt"
35
+ "diffusors-vllm"
36
+ "diffusors-sglang"
37
+ "diffusors-comfyui"
38
+ )
39
+
40
+ # ============================================================
41
+ # Prerequisite check: CI infrastructure must be provisioned
42
+ # ============================================================
43
+
44
+ check_ci_infrastructure() {
45
+ if ! aws dynamodb describe-table --table-name "${CI_TABLE_NAME}" --region "${AWS_REGION}" &>/dev/null; then
46
+ echo "❌ CI infrastructure not provisioned."
47
+ echo " Run 'ml-container-creator bootstrap' with CI enabled."
48
+ exit 1
49
+ fi
50
+ }
51
+
52
+ # ============================================================
53
+ # ci_usage — display help when no subcommand is given
54
+ # ============================================================
55
+
56
+ ci_usage() {
57
+ echo "Usage: ./do/ci <subcommand> [options]"
58
+ echo ""
59
+ echo "Subcommands:"
60
+ echo " report Show coverage report across all deployment configurations"
61
+ echo " status Show current CI system status summary"
62
+ echo " trigger Manually invoke the CI scanner to queue stale records"
63
+ echo " dashboard Start a local web dashboard for CI results"
64
+ echo ""
65
+ echo "Options:"
66
+ echo " report --json Output report in machine-parseable JSON"
67
+ echo " dashboard --port <N> Start dashboard on a custom port (default: 3939)"
68
+ echo ""
69
+ echo "Examples:"
70
+ echo " ./do/ci report"
71
+ echo " ./do/ci report --json"
72
+ echo " ./do/ci status"
73
+ echo " ./do/ci trigger"
74
+ echo " ./do/ci dashboard"
75
+ echo " ./do/ci dashboard --port 8080"
76
+ }
77
+
78
+
79
+ # ============================================================
80
+ # ci_report — coverage report across deployment configurations
81
+ # ============================================================
82
+
83
+ ci_report() {
84
+ local json_output=false
85
+
86
+ # Parse report-specific flags
87
+ while [[ $# -gt 0 ]]; do
88
+ case "$1" in
89
+ --json)
90
+ json_output=true
91
+ shift
92
+ ;;
93
+ *)
94
+ echo "⚠️ Unknown option for report: $1"
95
+ echo "Usage: ./do/ci report [--json]"
96
+ exit 1
97
+ ;;
98
+ esac
99
+ done
100
+
101
+ echo "📊 CI Coverage Report"
102
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
103
+ echo ""
104
+
105
+ # Scan all records from CI_Table
106
+ local scan_result
107
+ scan_result=$(aws dynamodb scan \
108
+ --table-name "${CI_TABLE_NAME}" \
109
+ --region "${AWS_REGION}" \
110
+ --output json 2>/dev/null) || {
111
+ echo "❌ Failed to scan CI table: ${CI_TABLE_NAME}"
112
+ exit 1
113
+ }
114
+
115
+ local item_count
116
+ item_count=$(echo "${scan_result}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('Count', 0))" 2>/dev/null || echo "0")
117
+
118
+ if [ "${item_count}" -eq 0 ]; then
119
+ echo "No test configurations registered. Use './do/register --ci' to add configurations."
120
+ return 0
121
+ fi
122
+
123
+ # Process records with python3 for grouping, regression detection, and summary
124
+ local json_flag="False"
125
+ if [ "${json_output}" = true ]; then
126
+ json_flag="True"
127
+ fi
128
+
129
+ echo "${scan_result}" | python3 -c "
130
+ import sys, json
131
+
132
+ data = json.load(sys.stdin)
133
+ items = data.get('Items', [])
134
+ json_output = ${json_flag}
135
+
136
+ known_configs = [
137
+ 'transformers-vllm', 'transformers-sglang', 'transformers-lmi',
138
+ 'transformers-djl', 'transformers-tensorrt-llm',
139
+ 'http-flask', 'http-fastapi', 'http-nginx',
140
+ 'triton-fil', 'triton-python', 'triton-onnx', 'triton-tensorrt',
141
+ 'diffusors-vllm', 'diffusors-sglang', 'diffusors-comfyui'
142
+ ]
143
+
144
+ # Group records by deploymentConfig
145
+ groups = {}
146
+ for item in items:
147
+ dc = item.get('deploymentConfig', {}).get('S', 'unknown')
148
+ if dc not in groups:
149
+ groups[dc] = []
150
+ groups[dc].append(item)
151
+
152
+ # Compute per-config status (latest record wins based on lastTestTimestamp)
153
+ config_status = {}
154
+ for dc, records in groups.items():
155
+ # Sort by lastTestTimestamp descending to get latest
156
+ sorted_records = sorted(records, key=lambda r: r.get('lastTestTimestamp', {}).get('S', ''), reverse=True)
157
+ latest = sorted_records[0]
158
+ status = latest.get('testStatus', {}).get('S', 'untested')
159
+ project = latest.get('projectName', {}).get('S', '')
160
+ last_ts = latest.get('lastTestTimestamp', {}).get('S', '')
161
+ duration = latest.get('lastTestDuration', {}).get('N', '0')
162
+ config_status[dc] = {
163
+ 'status': status,
164
+ 'project': project,
165
+ 'lastTest': last_ts,
166
+ 'duration': int(duration) if duration else 0,
167
+ 'recordCount': len(records)
168
+ }
169
+
170
+ # Detect regressions: configs that have a previous 'pass' but latest is 'fail-*'
171
+ regressions = []
172
+ for dc, records in groups.items():
173
+ sorted_records = sorted(records, key=lambda r: r.get('lastTestTimestamp', {}).get('S', ''))
174
+ latest_status = config_status[dc]['status']
175
+ if latest_status.startswith('fail-'):
176
+ # Check if any previous record had 'pass'
177
+ for r in sorted_records[:-1]:
178
+ if r.get('testStatus', {}).get('S', '') == 'pass':
179
+ regressions.append(dc)
180
+ break
181
+
182
+ # Compute summary
183
+ total = len(known_configs)
184
+ tested_configs = set(groups.keys()) & set(known_configs)
185
+ tested = len(tested_configs)
186
+ untested = total - tested
187
+ passing = sum(1 for dc in tested_configs if config_status.get(dc, {}).get('status') == 'pass')
188
+ failing = sum(1 for dc in tested_configs if config_status.get(dc, {}).get('status', '').startswith('fail-'))
189
+ coverage_pct = (tested / total * 100) if total > 0 else 0
190
+
191
+ if json_output:
192
+ report = {
193
+ 'summary': {
194
+ 'total': total,
195
+ 'tested': tested,
196
+ 'passing': passing,
197
+ 'failing': failing,
198
+ 'untested': untested,
199
+ 'coveragePercent': round(coverage_pct, 1)
200
+ },
201
+ 'configurations': {},
202
+ 'regressions': regressions,
203
+ 'untestedConfigs': [c for c in known_configs if c not in groups]
204
+ }
205
+ for dc in known_configs:
206
+ if dc in config_status:
207
+ report['configurations'][dc] = config_status[dc]
208
+ else:
209
+ report['configurations'][dc] = {'status': 'untested', 'project': '', 'lastTest': '', 'duration': 0, 'recordCount': 0}
210
+ # Include non-catalog configs
211
+ for dc in config_status:
212
+ if dc not in known_configs:
213
+ report['configurations'][dc] = config_status[dc]
214
+ print(json.dumps(report, indent=2))
215
+ else:
216
+ # Human-readable table
217
+ print(f' {\"Config\":<30} {\"Status\":<16} {\"Project\":<20} {\"Last Test\":<22} {\"Duration\":>10}')
218
+ print(' ' + '-' * 100)
219
+ for dc in known_configs:
220
+ if dc in config_status:
221
+ cs = config_status[dc]
222
+ status_str = cs['status']
223
+ if dc in regressions:
224
+ status_str += ' ⚠️ REGRESSION'
225
+ dur = f\"{cs['duration']}s\" if cs['duration'] > 0 else '-'
226
+ last = cs['lastTest'] if cs['lastTest'] and cs['lastTest'] != '1970-01-01T00:00:00Z' else '-'
227
+ print(f' {dc:<30} {status_str:<16} {cs[\"project\"]:<20} {last:<22} {dur:>10}')
228
+ else:
229
+ print(f' {dc:<30} {\"untested\":<16} {\"-\":<20} {\"-\":<22} {\"-\":>10}')
230
+
231
+ # Show non-catalog configs if any
232
+ extra = [dc for dc in config_status if dc not in known_configs]
233
+ if extra:
234
+ print()
235
+ print(' Non-catalog configurations:')
236
+ print(' ' + '-' * 100)
237
+ for dc in extra:
238
+ cs = config_status[dc]
239
+ dur = f\"{cs['duration']}s\" if cs['duration'] > 0 else '-'
240
+ last = cs['lastTest'] if cs['lastTest'] and cs['lastTest'] != '1970-01-01T00:00:00Z' else '-'
241
+ print(f' {dc:<30} {cs[\"status\"]:<16} {cs[\"project\"]:<20} {last:<22} {dur:>10}')
242
+
243
+ print()
244
+ if regressions:
245
+ print(' ⚠️ Regressions detected:')
246
+ for r in regressions:
247
+ print(f' • {r}')
248
+ print()
249
+
250
+ print(f' Summary: {total} total | {tested} tested | {passing} passing | {failing} failing | {untested} untested | {coverage_pct:.1f}% coverage')
251
+ print()
252
+ "
253
+ }
254
+
255
+
256
+ # ============================================================
257
+ # ci_status — current CI system status summary
258
+ # ============================================================
259
+
260
+ ci_status() {
261
+ echo "📋 CI System Status"
262
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
263
+ echo ""
264
+
265
+ # Scan all records from CI_Table
266
+ local scan_result
267
+ scan_result=$(aws dynamodb scan \
268
+ --table-name "${CI_TABLE_NAME}" \
269
+ --region "${AWS_REGION}" \
270
+ --output json 2>/dev/null) || {
271
+ echo "❌ Failed to scan CI table: ${CI_TABLE_NAME}"
272
+ exit 1
273
+ }
274
+
275
+ local item_count
276
+ item_count=$(echo "${scan_result}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('Count', 0))" 2>/dev/null || echo "0")
277
+
278
+ if [ "${item_count}" -eq 0 ]; then
279
+ echo "CI table is empty. Register test configurations with './do/register --ci'."
280
+ return 0
281
+ fi
282
+
283
+ # Process records with python3 for status counts
284
+ echo "${scan_result}" | python3 -c "
285
+ import sys, json
286
+
287
+ data = json.load(sys.stdin)
288
+ items = data.get('Items', [])
289
+
290
+ total = len(items)
291
+ running = 0
292
+ passing = 0
293
+ failing = 0
294
+ untested = 0
295
+ last_completed = ''
296
+
297
+ for item in items:
298
+ status = item.get('testStatus', {}).get('S', 'untested')
299
+ ts = item.get('lastTestTimestamp', {}).get('S', '')
300
+
301
+ if status == 'running':
302
+ running += 1
303
+ elif status == 'pass':
304
+ passing += 1
305
+ elif status.startswith('fail-'):
306
+ failing += 1
307
+ elif status == 'untested':
308
+ untested += 1
309
+
310
+ # Track last completed test (exclude running and untested, exclude epoch)
311
+ if status not in ('running', 'untested') and ts and ts != '1970-01-01T00:00:00Z':
312
+ if not last_completed or ts > last_completed:
313
+ last_completed = ts
314
+
315
+ print(f' Total records: {total}')
316
+ print(f' Running: {running}')
317
+ print(f' Passing: {passing}')
318
+ print(f' Failing: {failing}')
319
+ print(f' Untested: {untested}')
320
+ print(f' Last completed: {last_completed if last_completed else \"N/A\"}')
321
+ print()
322
+ "
323
+ }
324
+
325
+
326
+ # ============================================================
327
+ # ci_trigger — manually invoke the Scanner Lambda
328
+ # ============================================================
329
+
330
+ ci_trigger() {
331
+ echo "🚀 Triggering CI Scanner Lambda"
332
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
333
+ echo ""
334
+
335
+ local response_file
336
+ response_file=$(mktemp /tmp/ci-trigger-XXXXXX.json)
337
+
338
+ if aws lambda invoke \
339
+ --function-name "mlcc-ci-scanner" \
340
+ --region "${AWS_REGION}" \
341
+ --log-type Tail \
342
+ "${response_file}" &>/dev/null; then
343
+
344
+ echo "✅ Scanner Lambda invoked successfully"
345
+ echo ""
346
+
347
+ # Display response payload
348
+ if [ -s "${response_file}" ]; then
349
+ echo " Response:"
350
+ python3 -c "
351
+ import sys, json
352
+ try:
353
+ with open('${response_file}') as f:
354
+ data = json.load(f)
355
+ print(json.dumps(data, indent=2))
356
+ except:
357
+ with open('${response_file}') as f:
358
+ print(f.read())
359
+ " 2>/dev/null || cat "${response_file}"
360
+ fi
361
+ else
362
+ echo "❌ Failed to invoke Scanner Lambda"
363
+ echo " Check that:"
364
+ echo " • CI infrastructure is provisioned"
365
+ echo " • Your IAM credentials have lambda:InvokeFunction permission"
366
+ echo " • The function 'mlcc-ci-scanner' exists in region: ${AWS_REGION}"
367
+ fi
368
+
369
+ rm -f "${response_file}"
370
+ }
371
+
372
+
373
+ # ============================================================
374
+ # ci_dashboard — local web dashboard for CI results
375
+ # ============================================================
376
+
377
+ ci_dashboard() {
378
+ local port=3939
379
+
380
+ # Parse dashboard-specific flags
381
+ while [[ $# -gt 0 ]]; do
382
+ case "$1" in
383
+ --port)
384
+ port="$2"
385
+ shift 2
386
+ ;;
387
+ --port=*)
388
+ port="${1#*=}"
389
+ shift
390
+ ;;
391
+ *)
392
+ echo "⚠️ Unknown option for dashboard: $1"
393
+ echo "Usage: ./do/ci dashboard [--port <N>]"
394
+ exit 1
395
+ ;;
396
+ esac
397
+ done
398
+
399
+ echo "🖥️ Starting CI Dashboard"
400
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
401
+ echo " Table: ${CI_TABLE_NAME}"
402
+ echo " Region: ${AWS_REGION}"
403
+ echo " Port: ${port}"
404
+ echo ""
405
+ echo " Open http://localhost:${port} in your browser"
406
+ echo " Press Ctrl+C to stop"
407
+ echo ""
408
+
409
+ # Export environment variables for the Node.js server
410
+ export DASHBOARD_PORT="${port}"
411
+ export CI_TABLE_NAME="${CI_TABLE_NAME}"
412
+ export AWS_REGION="${AWS_REGION}"
413
+ export CI_LOG_GROUP="${CI_LOG_GROUP}"
414
+
415
+ # Write the server script directly to a temp file (avoids heredoc-in-subshell bash parsing issues)
416
+ local tmp_script
417
+ tmp_script=$(mktemp /tmp/mlcc-ci-dashboard-XXXXXX.js)
418
+ trap "rm -f '${tmp_script}'" EXIT
419
+
420
+ cat > "${tmp_script}" <<'DASHBOARD_EOF'
421
+ const http = require('http');
422
+ const { execSync } = require('child_process');
423
+
424
+ const PORT = parseInt(process.env.DASHBOARD_PORT || '3939', 10);
425
+ const CI_TABLE_NAME = process.env.CI_TABLE_NAME || 'mlcc-ci-table';
426
+ const AWS_REGION = process.env.AWS_REGION || 'us-east-1';
427
+ const CI_LOG_GROUP = process.env.CI_LOG_GROUP || 'ml-container-creator-ci';
428
+
429
+ function fetchCIData() {
430
+ try {
431
+ const raw = execSync(
432
+ `aws dynamodb scan --table-name "${CI_TABLE_NAME}" --region "${AWS_REGION}" --output json`,
433
+ { encoding: 'utf-8', timeout: 30000 }
434
+ );
435
+ const data = JSON.parse(raw);
436
+ const items = (data.Items || []).map(item => ({
437
+ configId: (item.configId || {}).S || '',
438
+ configJson: (item.configJson || {}).S || '{}',
439
+ projectName: (item.projectName || {}).S || '',
440
+ deploymentConfig: (item.deploymentConfig || {}).S || '',
441
+ testStatus: (item.testStatus || {}).S || 'untested',
442
+ lastTestTimestamp: (item.lastTestTimestamp || {}).S || '',
443
+ lastTestDuration: parseInt((item.lastTestDuration || {}).N || '0', 10),
444
+ stageResults: item.stageResults ? parseStageResults(item.stageResults) : {},
445
+ baseImage: (item.baseImage || {}).S || '',
446
+ errorMessage: (item.errorMessage || {}).S || ''
447
+ }));
448
+ return items;
449
+ } catch (e) {
450
+ return [];
451
+ }
452
+ }
453
+
454
+ function parseStageResults(attr) {
455
+ if (!attr || !attr.M) return {};
456
+ const result = {};
457
+ for (const [stage, val] of Object.entries(attr.M)) {
458
+ if (val.M) {
459
+ result[stage] = {
460
+ status: (val.M.status || {}).S || '',
461
+ durationSeconds: parseInt((val.M.durationSeconds || {}).N || '0', 10),
462
+ logPointer: (val.M.logPointer || {}).S || ''
463
+ };
464
+ }
465
+ }
466
+ return result;
467
+ }
468
+
469
+ function statusColor(status) {
470
+ if (status === 'pass') return '#22c55e';
471
+ if (status && status.startsWith('fail')) return '#ef4444';
472
+ if (status === 'running') return '#eab308';
473
+ return '#9ca3af';
474
+ }
475
+
476
+ function statusBadge(status) {
477
+ const color = statusColor(status);
478
+ return `<span style="display:inline-block;padding:2px 8px;border-radius:4px;background:${color};color:#fff;font-size:12px;font-weight:600;">${status}</span>`;
479
+ }
480
+
481
+ function formatDuration(seconds) {
482
+ if (!seconds || seconds === 0) return '-';
483
+ if (seconds < 60) return `${seconds}s`;
484
+ const m = Math.floor(seconds / 60);
485
+ const s = seconds % 60;
486
+ return `${m}m ${s}s`;
487
+ }
488
+
489
+ function logLink(logPointer) {
490
+ if (!logPointer) return '-';
491
+ const parts = logPointer.split(':');
492
+ const group = parts[0] || CI_LOG_GROUP;
493
+ const stream = parts.slice(1).join(':') || logPointer;
494
+ const encodedGroup = encodeURIComponent(encodeURIComponent(group));
495
+ const encodedStream = encodeURIComponent(encodeURIComponent(stream));
496
+ const url = `https://console.aws.amazon.com/cloudwatch/home?region=${AWS_REGION}#logsV2:log-groups/log-group/${encodedGroup}/log-events/${encodedStream}`;
497
+ return `<a href="${url}" target="_blank" style="color:#3b82f6;text-decoration:none;">logs</a>`;
498
+ }
499
+
500
+ function renderStages(stageResults) {
501
+ const stageOrder = ['generate', 'validate', 'build', 'deploy_test', 'register', 'teardown', 'update'];
502
+ if (!stageResults || Object.keys(stageResults).length === 0) return '<span style="color:#9ca3af;">-</span>';
503
+ return stageOrder.map(stage => {
504
+ const sr = stageResults[stage];
505
+ if (!sr) return `<span style="color:#9ca3af;" title="${stage}: N/A">⬜</span>`;
506
+ const color = statusColor(sr.status);
507
+ const dur = formatDuration(sr.durationSeconds);
508
+ const link = sr.logPointer ? ` (${logLink(sr.logPointer)})` : '';
509
+ return `<span style="color:${color};" title="${stage}: ${sr.status} ${dur}${sr.logPointer ? ' — click for logs' : ''}">${sr.status === 'pass' ? '✅' : sr.status === 'fail' ? '❌' : sr.status === 'skip' ? '⏭️' : '⬜'}</span>`;
510
+ }).join(' ');
511
+ }
512
+
513
+ function buildHTML(items) {
514
+ const sorted = items.sort((a, b) => a.deploymentConfig.localeCompare(b.deploymentConfig));
515
+ const rows = sorted.map(item => {
516
+ const lastTest = item.lastTestTimestamp && item.lastTestTimestamp !== '1970-01-01T00:00:00Z'
517
+ ? item.lastTestTimestamp.replace('T', ' ').replace('Z', '')
518
+ : '-';
519
+ const escapedConfig = (item.configJson || '{}').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
520
+ return `<tr class="clickable-row" data-config="${escapedConfig}" data-project="${item.projectName || '-'}" data-id="${item.configId || ''}">
521
+ <td>${item.projectName || '-'}</td>
522
+ <td><code>${item.deploymentConfig || '-'}</code></td>
523
+ <td>${statusBadge(item.testStatus)}</td>
524
+ <td>${lastTest}</td>
525
+ <td>${formatDuration(item.lastTestDuration)}</td>
526
+ <td>${renderStages(item.stageResults)}</td>
527
+ </tr>`;
528
+ }).join('\n');
529
+
530
+ const total = items.length;
531
+ const passing = items.filter(i => i.testStatus === 'pass').length;
532
+ const failing = items.filter(i => i.testStatus && i.testStatus.startsWith('fail')).length;
533
+ const running = items.filter(i => i.testStatus === 'running').length;
534
+ const untested = items.filter(i => i.testStatus === 'untested').length;
535
+
536
+ return `<!DOCTYPE html>
537
+ <html lang="en">
538
+ <head>
539
+ <meta charset="UTF-8">
540
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
541
+ <title>MLCC CI Dashboard</title>
542
+ <meta http-equiv="refresh" content="60">
543
+ <style>
544
+ * { margin: 0; padding: 0; box-sizing: border-box; }
545
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 20px; }
546
+ h1 { font-size: 24px; margin-bottom: 8px; }
547
+ .subtitle { color: #94a3b8; margin-bottom: 20px; font-size: 14px; }
548
+ .summary { display: flex; gap: 16px; margin-bottom: 24px; flex-wrap: wrap; }
549
+ .stat { background: #1e293b; padding: 12px 20px; border-radius: 8px; text-align: center; min-width: 100px; }
550
+ .stat .value { font-size: 28px; font-weight: 700; }
551
+ .stat .label { font-size: 12px; color: #94a3b8; margin-top: 4px; }
552
+ table { width: 100%; border-collapse: collapse; background: #1e293b; border-radius: 8px; overflow: hidden; }
553
+ th { background: #334155; padding: 10px 12px; text-align: left; font-size: 13px; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.5px; }
554
+ td { padding: 10px 12px; border-top: 1px solid #334155; font-size: 14px; }
555
+ tr:hover td { background: #263348; }
556
+ tr.clickable-row { cursor: pointer; }
557
+ code { background: #334155; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
558
+ a { color: #3b82f6; }
559
+ .footer { margin-top: 20px; color: #64748b; font-size: 12px; text-align: center; }
560
+ .modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 1000; justify-content: center; align-items: center; }
561
+ .modal-overlay.active { display: flex; }
562
+ .modal { background: #1e293b; border-radius: 12px; padding: 24px; max-width: 700px; width: 90%; max-height: 80vh; overflow-y: auto; position: relative; border: 1px solid #334155; }
563
+ .modal h2 { font-size: 18px; margin-bottom: 4px; }
564
+ .modal .modal-subtitle { color: #94a3b8; font-size: 13px; margin-bottom: 16px; }
565
+ .modal pre { background: #0f172a; padding: 16px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.5; color: #a5f3fc; }
566
+ .modal-close { position: absolute; top: 12px; right: 16px; background: none; border: none; color: #94a3b8; font-size: 24px; cursor: pointer; }
567
+ .modal-close:hover { color: #e2e8f0; }
568
+ </style>
569
+ </head>
570
+ <body>
571
+ <h1>🔬 MLCC CI Dashboard</h1>
572
+ <p class="subtitle">Table: ${CI_TABLE_NAME} | Region: ${AWS_REGION} | Auto-refresh: 60s</p>
573
+ <div class="summary">
574
+ <div class="stat"><div class="value">${total}</div><div class="label">Total</div></div>
575
+ <div class="stat"><div class="value" style="color:#22c55e;">${passing}</div><div class="label">Passing</div></div>
576
+ <div class="stat"><div class="value" style="color:#ef4444;">${failing}</div><div class="label">Failing</div></div>
577
+ <div class="stat"><div class="value" style="color:#eab308;">${running}</div><div class="label">Running</div></div>
578
+ <div class="stat"><div class="value" style="color:#9ca3af;">${untested}</div><div class="label">Untested</div></div>
579
+ </div>
580
+ <table>
581
+ <thead>
582
+ <tr><th>Project</th><th>Config</th><th>Status</th><th>Last Test</th><th>Duration</th><th>Stages</th></tr>
583
+ </thead>
584
+ <tbody>
585
+ ${rows || '<tr><td colspan="6" style="text-align:center;color:#94a3b8;padding:40px;">No records found</td></tr>'}
586
+ </tbody>
587
+ </table>
588
+ <div class="footer">Generated at ${new Date().toISOString()} | Refresh page or wait for auto-refresh</div>
589
+ <div class="modal-overlay" id="configModal">
590
+ <div class="modal">
591
+ <button class="modal-close" onclick="closeModal()">&times;</button>
592
+ <h2 id="modalTitle">Configuration</h2>
593
+ <p class="modal-subtitle" id="modalSubtitle"></p>
594
+ <pre id="modalContent"></pre>
595
+ </div>
596
+ </div>
597
+ <script>
598
+ document.querySelectorAll('.clickable-row').forEach(row => {
599
+ row.addEventListener('click', () => {
600
+ const config = row.getAttribute('data-config');
601
+ const project = row.getAttribute('data-project');
602
+ const configId = row.getAttribute('data-id');
603
+ let formatted;
604
+ try { formatted = JSON.stringify(JSON.parse(config), null, 2); }
605
+ catch { formatted = config; }
606
+ document.getElementById('modalTitle').textContent = project || 'Configuration';
607
+ document.getElementById('modalSubtitle').textContent = 'configId: ' + (configId || 'unknown');
608
+ document.getElementById('modalContent').textContent = formatted;
609
+ document.getElementById('configModal').classList.add('active');
610
+ });
611
+ });
612
+ function closeModal() { document.getElementById('configModal').classList.remove('active'); }
613
+ document.getElementById('configModal').addEventListener('click', (e) => {
614
+ if (e.target === e.currentTarget) closeModal();
615
+ });
616
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
617
+ </script>
618
+ </body>
619
+ </html>`;
620
+ }
621
+
622
+ const server = http.createServer((req, res) => {
623
+ if (req.url === '/' || req.url === '/index.html') {
624
+ const items = fetchCIData();
625
+ const html = buildHTML(items);
626
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
627
+ res.end(html);
628
+ } else if (req.url === '/api/data') {
629
+ const items = fetchCIData();
630
+ res.writeHead(200, { 'Content-Type': 'application/json' });
631
+ res.end(JSON.stringify(items, null, 2));
632
+ } else {
633
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
634
+ res.end('Not Found');
635
+ }
636
+ });
637
+
638
+ server.on('error', (err) => {
639
+ if (err.code === 'EADDRINUSE') {
640
+ console.error(`Port ${PORT} in use, try --port <N>`);
641
+ process.exit(1);
642
+ }
643
+ console.error('Server error:', err.message);
644
+ process.exit(1);
645
+ });
646
+
647
+ server.listen(PORT, () => {
648
+ console.log(`CI Dashboard running at http://localhost:${PORT}`);
649
+ });
650
+ DASHBOARD_EOF
651
+
652
+ node "${tmp_script}"
653
+ }
654
+
655
+
656
+ # ============================================================
657
+ # Subcommand routing
658
+ # ============================================================
659
+
660
+ # Check CI infrastructure before any subcommand
661
+ check_ci_infrastructure
662
+
663
+ case "${1:-}" in
664
+ report)
665
+ shift
666
+ ci_report "$@"
667
+ ;;
668
+ status)
669
+ ci_status
670
+ ;;
671
+ trigger)
672
+ ci_trigger
673
+ ;;
674
+ dashboard)
675
+ shift
676
+ ci_dashboard "$@"
677
+ ;;
678
+ *)
679
+ ci_usage
680
+ ;;
681
+ esac