code-engine-mcp-server 1.0.2 → 1.0.4
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 +1 -1
- package/README.md +368 -31
- package/build/index.js +864 -4
- package/build/index.js.map +1 -1
- package/docs/SETUP_INSTRUCTIONS.md +2 -0
- package/package.json +21 -3
package/build/index.js
CHANGED
|
@@ -58,10 +58,35 @@ function getApiKey() {
|
|
|
58
58
|
throw new Error('IBMCLOUD_API_KEY environment variable not set');
|
|
59
59
|
return apiKey;
|
|
60
60
|
}
|
|
61
|
+
// Helper: resolve project ID from a name or ID string.
|
|
62
|
+
// If the value looks like a UUID (contains hyphens and is long) treat it as an ID.
|
|
63
|
+
// Otherwise search all regions for a project whose name matches (case-insensitive).
|
|
64
|
+
async function resolveProjectId(nameOrId, token) {
|
|
65
|
+
// UUID pattern: 8-4-4-4-12
|
|
66
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(nameOrId)) {
|
|
67
|
+
return nameOrId;
|
|
68
|
+
}
|
|
69
|
+
const found = [];
|
|
70
|
+
await Promise.allSettled(CE_REGIONS.map(async (reg) => {
|
|
71
|
+
try {
|
|
72
|
+
const res = await axios.get(`https://api.${reg}.codeengine.cloud.ibm.com/v2/projects`, { headers: { Authorization: `Bearer ${token}` } });
|
|
73
|
+
(res.data.projects || []).forEach((p) => {
|
|
74
|
+
if (p.name.toLowerCase() === nameOrId.toLowerCase())
|
|
75
|
+
found.push({ id: p.id, name: p.name, region: reg });
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch { /* skip region */ }
|
|
79
|
+
}));
|
|
80
|
+
if (found.length === 0)
|
|
81
|
+
throw new Error(`No Code Engine project found with name "${nameOrId}". Use ce_list_projects to find it.`);
|
|
82
|
+
if (found.length > 1)
|
|
83
|
+
throw new Error(`Multiple projects named "${nameOrId}" found in regions: ${found.map(p => p.region).join(', ')}. Provide the project ID instead.`);
|
|
84
|
+
return found[0].id;
|
|
85
|
+
}
|
|
61
86
|
// Create MCP server
|
|
62
87
|
const server = new Server({
|
|
63
88
|
name: 'code-engine-mcp-server',
|
|
64
|
-
version: '1.0.
|
|
89
|
+
version: '1.0.3',
|
|
65
90
|
}, {
|
|
66
91
|
capabilities: {
|
|
67
92
|
tools: {},
|
|
@@ -162,6 +187,52 @@ const containerTools = [
|
|
|
162
187
|
},
|
|
163
188
|
},
|
|
164
189
|
},
|
|
190
|
+
{
|
|
191
|
+
name: 'ce_validate_dockerfile',
|
|
192
|
+
description: 'Validate a Dockerfile for IBM Code Engine compatibility. Checks architecture (linux/amd64 required), port configuration (8080 required), nginx port sed patterns, base image known issues, and USER/root warnings. Returns a list of errors, warnings, and info messages.',
|
|
193
|
+
inputSchema: {
|
|
194
|
+
type: 'object',
|
|
195
|
+
properties: {
|
|
196
|
+
dockerfile_path: { type: 'string', description: 'Absolute or relative path to the Dockerfile to validate' },
|
|
197
|
+
context_path: { type: 'string', description: 'Build context directory (used to check for .dockerignore, etc.)' },
|
|
198
|
+
expected_port: { type: 'number', description: 'Expected container port (default: 8080 for Code Engine)' },
|
|
199
|
+
},
|
|
200
|
+
required: ['dockerfile_path'],
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: 'icr_list_namespaces',
|
|
205
|
+
description: 'List IBM Container Registry (ICR) namespaces in your account',
|
|
206
|
+
inputSchema: {
|
|
207
|
+
type: 'object',
|
|
208
|
+
properties: {
|
|
209
|
+
region: { type: 'string', description: 'ICR region host (default: us.icr.io)', enum: ['us.icr.io', 'uk.icr.io', 'de.icr.io', 'au.icr.io', 'jp.icr.io', 'ca.icr.io'] },
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: 'icr_list_images',
|
|
215
|
+
description: 'List images in IBM Container Registry (ICR)',
|
|
216
|
+
inputSchema: {
|
|
217
|
+
type: 'object',
|
|
218
|
+
properties: {
|
|
219
|
+
namespace: { type: 'string', description: 'Filter by namespace (optional)' },
|
|
220
|
+
region: { type: 'string', description: 'ICR region host (default: us.icr.io)' },
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
name: 'icr_delete_image',
|
|
226
|
+
description: 'Delete an image from IBM Container Registry (ICR) by tag or digest',
|
|
227
|
+
inputSchema: {
|
|
228
|
+
type: 'object',
|
|
229
|
+
properties: {
|
|
230
|
+
image: { type: 'string', description: 'Full image reference to delete, e.g. us.icr.io/mynamespace/myapp:v1.0.0' },
|
|
231
|
+
region: { type: 'string', description: 'ICR region host (default: us.icr.io)' },
|
|
232
|
+
},
|
|
233
|
+
required: ['image'],
|
|
234
|
+
},
|
|
235
|
+
},
|
|
165
236
|
];
|
|
166
237
|
// Code Engine Tools
|
|
167
238
|
const codeEngineTools = [
|
|
@@ -244,6 +315,7 @@ const codeEngineTools = [
|
|
|
244
315
|
project_id: { type: 'string' },
|
|
245
316
|
name: { type: 'string' },
|
|
246
317
|
image: { type: 'string', description: 'Container image reference' },
|
|
318
|
+
image_secret: { type: 'string', description: 'Registry secret name for pulling the image (e.g. icr-pull-secret)' },
|
|
247
319
|
port: { type: 'number', description: 'Container port (default 8080)' },
|
|
248
320
|
scale_min_instances: { type: 'number', description: 'Min instances (default 0)' },
|
|
249
321
|
scale_max_instances: { type: 'number', description: 'Max instances (default 10)' },
|
|
@@ -263,6 +335,7 @@ const codeEngineTools = [
|
|
|
263
335
|
project_id: { type: 'string' },
|
|
264
336
|
app_name: { type: 'string' },
|
|
265
337
|
image: { type: 'string', description: 'New container image reference' },
|
|
338
|
+
image_secret: { type: 'string', description: 'Registry secret name for pulling the image' },
|
|
266
339
|
scale_min_instances: { type: 'number' },
|
|
267
340
|
scale_max_instances: { type: 'number' },
|
|
268
341
|
scale_cpu_limit: { type: 'string' },
|
|
@@ -295,6 +368,19 @@ const codeEngineTools = [
|
|
|
295
368
|
required: ['project_id', 'app_name'],
|
|
296
369
|
},
|
|
297
370
|
},
|
|
371
|
+
{
|
|
372
|
+
name: 'ce_get_app_instance',
|
|
373
|
+
description: 'Get status details for a specific running instance of a Code Engine application',
|
|
374
|
+
inputSchema: {
|
|
375
|
+
type: 'object',
|
|
376
|
+
properties: {
|
|
377
|
+
project_id: { type: 'string' },
|
|
378
|
+
app_name: { type: 'string' },
|
|
379
|
+
instance_name: { type: 'string', description: 'Instance name (from ce_list_app_instances)' },
|
|
380
|
+
},
|
|
381
|
+
required: ['project_id', 'app_name', 'instance_name'],
|
|
382
|
+
},
|
|
383
|
+
},
|
|
298
384
|
{
|
|
299
385
|
name: 'ce_get_app_logs',
|
|
300
386
|
description: 'Get logs for a specific Code Engine application instance',
|
|
@@ -614,6 +700,200 @@ const codeEngineTools = [
|
|
|
614
700
|
required: ['project_id', 'config_map_name'],
|
|
615
701
|
},
|
|
616
702
|
},
|
|
703
|
+
{
|
|
704
|
+
name: 'ce_list_domain_mappings',
|
|
705
|
+
description: 'List all custom domain mappings in a Code Engine project',
|
|
706
|
+
inputSchema: {
|
|
707
|
+
type: 'object',
|
|
708
|
+
properties: {
|
|
709
|
+
project_id: { type: 'string' },
|
|
710
|
+
},
|
|
711
|
+
required: ['project_id'],
|
|
712
|
+
},
|
|
713
|
+
},
|
|
714
|
+
{
|
|
715
|
+
name: 'ce_get_domain_mapping',
|
|
716
|
+
description: 'Get details of a specific custom domain mapping',
|
|
717
|
+
inputSchema: {
|
|
718
|
+
type: 'object',
|
|
719
|
+
properties: {
|
|
720
|
+
project_id: { type: 'string' },
|
|
721
|
+
domain_name: { type: 'string', description: 'The custom domain name (e.g. starwars.cranfordpub.ca)' },
|
|
722
|
+
},
|
|
723
|
+
required: ['project_id', 'domain_name'],
|
|
724
|
+
},
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
name: 'ce_create_domain_mapping',
|
|
728
|
+
description: 'Map a custom domain to a Code Engine application. Requires a TLS secret created with ce_create_tls_secret_from_pem. The domain CNAME must point to custom.<subdomain>.us-south.codeengine.appdomain.cloud (returned by this call).',
|
|
729
|
+
inputSchema: {
|
|
730
|
+
type: 'object',
|
|
731
|
+
properties: {
|
|
732
|
+
project_id: { type: 'string' },
|
|
733
|
+
domain_name: { type: 'string', description: 'Custom domain (e.g. starwars.cranfordpub.ca)' },
|
|
734
|
+
app_name: { type: 'string', description: 'Code Engine application to route traffic to' },
|
|
735
|
+
tls_secret: { type: 'string', description: 'Name of the TLS secret containing the certificate and key' },
|
|
736
|
+
},
|
|
737
|
+
required: ['project_id', 'domain_name', 'app_name', 'tls_secret'],
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
name: 'ce_create_tls_secret_from_pem',
|
|
742
|
+
description: 'Create a TLS secret in Code Engine by reading certificate and key PEM files from disk. Use this after obtaining a cert with certbot. The cert and key are read from the given file paths and stored as a Code Engine secret of format "tls".',
|
|
743
|
+
inputSchema: {
|
|
744
|
+
type: 'object',
|
|
745
|
+
properties: {
|
|
746
|
+
project_id: { type: 'string' },
|
|
747
|
+
secret_name: { type: 'string', description: 'Name for the TLS secret (e.g. my-domain-tls)' },
|
|
748
|
+
cert_pem_path: { type: 'string', description: 'Absolute path to the certificate PEM file (e.g. ~/certbot/config/live/<domain>/fullchain.pem)' },
|
|
749
|
+
key_pem_path: { type: 'string', description: 'Absolute path to the private key PEM file (e.g. ~/certbot/config/live/<domain>/privkey.pem)' },
|
|
750
|
+
},
|
|
751
|
+
required: ['project_id', 'secret_name', 'cert_pem_path', 'key_pem_path'],
|
|
752
|
+
},
|
|
753
|
+
},
|
|
754
|
+
{
|
|
755
|
+
name: 'ce_delete_domain_mapping',
|
|
756
|
+
description: 'Delete a custom domain mapping from a Code Engine project',
|
|
757
|
+
inputSchema: {
|
|
758
|
+
type: 'object',
|
|
759
|
+
properties: {
|
|
760
|
+
project_id: { type: 'string' },
|
|
761
|
+
domain_name: { type: 'string', description: 'The custom domain name to remove' },
|
|
762
|
+
},
|
|
763
|
+
required: ['project_id', 'domain_name'],
|
|
764
|
+
},
|
|
765
|
+
},
|
|
766
|
+
{
|
|
767
|
+
name: 'ce_update_secret',
|
|
768
|
+
description: 'Update an existing Code Engine secret in-place (PATCH). Use ce_renew_tls_secret_from_pem for TLS cert renewal. For generic secrets supply a data object with the new key/value pairs.',
|
|
769
|
+
inputSchema: {
|
|
770
|
+
type: 'object',
|
|
771
|
+
properties: {
|
|
772
|
+
project_id: { type: 'string' },
|
|
773
|
+
secret_name: { type: 'string', description: 'Name of the secret to update' },
|
|
774
|
+
format: { type: 'string', description: 'Secret format if changing: generic, registry, tls, ssh_auth, basic_auth' },
|
|
775
|
+
data: { type: 'object', description: 'New key/value data to replace the secret contents' },
|
|
776
|
+
},
|
|
777
|
+
required: ['project_id', 'secret_name', 'data'],
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
name: 'ce_renew_tls_secret_from_pem',
|
|
782
|
+
description: 'Renew an existing TLS secret in Code Engine by reading updated PEM files from disk. Use this when a Let\'s Encrypt cert has been renewed (every 90 days). Updates the secret in-place — no need to delete and recreate or update domain mappings.',
|
|
783
|
+
inputSchema: {
|
|
784
|
+
type: 'object',
|
|
785
|
+
properties: {
|
|
786
|
+
project_id: { type: 'string' },
|
|
787
|
+
secret_name: { type: 'string', description: 'Name of the existing TLS secret to renew' },
|
|
788
|
+
cert_pem_path: { type: 'string', description: 'Path to the renewed fullchain.pem (~ supported)' },
|
|
789
|
+
key_pem_path: { type: 'string', description: 'Path to the renewed privkey.pem (~ supported)' },
|
|
790
|
+
},
|
|
791
|
+
required: ['project_id', 'secret_name', 'cert_pem_path', 'key_pem_path'],
|
|
792
|
+
},
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
name: 'ce_wait_for_app_ready',
|
|
796
|
+
description: 'Poll a Code Engine application until its status becomes "ready" or a timeout is reached. Returns immediately when ready. Useful after ce_create_application or ce_update_application to confirm the new revision is live.',
|
|
797
|
+
inputSchema: {
|
|
798
|
+
type: 'object',
|
|
799
|
+
properties: {
|
|
800
|
+
project_id: { type: 'string' },
|
|
801
|
+
app_name: { type: 'string' },
|
|
802
|
+
timeout_seconds: { type: 'number', description: 'Max seconds to wait (default 120)' },
|
|
803
|
+
},
|
|
804
|
+
required: ['project_id', 'app_name'],
|
|
805
|
+
},
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
name: 'ce_wait_for_build_run',
|
|
809
|
+
description: 'Poll a Code Engine build run until it succeeds or fails. Returns when the build reaches a terminal state. Useful after ce_create_build_run to confirm the build completed before deploying.',
|
|
810
|
+
inputSchema: {
|
|
811
|
+
type: 'object',
|
|
812
|
+
properties: {
|
|
813
|
+
project_id: { type: 'string' },
|
|
814
|
+
build_run_name: { type: 'string' },
|
|
815
|
+
timeout_seconds: { type: 'number', description: 'Max seconds to wait (default 600)' },
|
|
816
|
+
},
|
|
817
|
+
required: ['project_id', 'build_run_name'],
|
|
818
|
+
},
|
|
819
|
+
},
|
|
820
|
+
{
|
|
821
|
+
name: 'icr_create_namespace',
|
|
822
|
+
description: 'Create a new namespace in IBM Container Registry. Required before pushing images for the first time. Uses the ICR REST API with your IBM Cloud API key.',
|
|
823
|
+
inputSchema: {
|
|
824
|
+
type: 'object',
|
|
825
|
+
properties: {
|
|
826
|
+
namespace: { type: 'string', description: 'Namespace name to create (lowercase, alphanumeric and hyphens)' },
|
|
827
|
+
region: { type: 'string', description: 'ICR host (default: us.icr.io)' },
|
|
828
|
+
},
|
|
829
|
+
required: ['namespace'],
|
|
830
|
+
},
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
name: 'iam_get_token_info',
|
|
834
|
+
description: 'Get information about the current IBM Cloud IAM token — account ID, subject, expiry time, and remaining validity. Useful for diagnosing authentication failures without exposing the token itself.',
|
|
835
|
+
inputSchema: {
|
|
836
|
+
type: 'object',
|
|
837
|
+
properties: {},
|
|
838
|
+
required: [],
|
|
839
|
+
},
|
|
840
|
+
},
|
|
841
|
+
// ─── Procedures (multi-step workflows) ───────────────────────────────────────
|
|
842
|
+
{
|
|
843
|
+
name: 'proc_build_push_deploy',
|
|
844
|
+
description: 'PROCEDURE: Full container pipeline in one step — auto-detects Podman or Docker, builds for linux/amd64, pushes to IBM Container Registry (ICR), creates or updates a Code Engine application, waits for ready, and returns the public URL. Accepts a project name or ID. Builds the full ICR image path from namespace + app name + tag so you do not need to know ICR URLs.',
|
|
845
|
+
inputSchema: {
|
|
846
|
+
type: 'object',
|
|
847
|
+
properties: {
|
|
848
|
+
context_path: { type: 'string', description: 'Path to the directory containing the Dockerfile (e.g. examples/starwars-splash or ./my-app)' },
|
|
849
|
+
project_id_or_name: { type: 'string', description: 'Code Engine project name (e.g. "my-project") or project ID (UUID). Use ce_list_projects if unsure.' },
|
|
850
|
+
app_name: { type: 'string', description: 'Name for the application in Code Engine (e.g. my-app)' },
|
|
851
|
+
image_secret: { type: 'string', description: 'Name of the registry pull secret in Code Engine (e.g. icr-pull-secret). Created with ce_create_secret format=registry.' },
|
|
852
|
+
icr_namespace: { type: 'string', description: 'Your IBM Container Registry namespace (e.g. my-namespace). Use icr_list_namespaces to find it.' },
|
|
853
|
+
image_tag: { type: 'string', description: 'Image version tag (e.g. v1.0.0 or latest). Defaults to "latest".' },
|
|
854
|
+
icr_host: { type: 'string', description: 'ICR host (default: us.icr.io — leave blank unless using a different region)' },
|
|
855
|
+
port: { type: 'number', description: 'Container port the app listens on (default 8080)' },
|
|
856
|
+
scale_min_instances: { type: 'number', description: 'Minimum running instances (default 0 = scale to zero)' },
|
|
857
|
+
scale_max_instances: { type: 'number', description: 'Maximum running instances (default 10)' },
|
|
858
|
+
env_vars: { type: 'object', description: 'Key/value environment variables to set on the app' },
|
|
859
|
+
timeout_seconds: { type: 'number', description: 'Max seconds to wait for app ready (default 180)' },
|
|
860
|
+
},
|
|
861
|
+
required: ['context_path', 'project_id_or_name', 'app_name', 'image_secret', 'icr_namespace'],
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
{
|
|
865
|
+
name: 'proc_setup_custom_domain',
|
|
866
|
+
description: 'PROCEDURE: Custom domain setup in one step — reads TLS certificate PEM files from disk (e.g. from certbot/Let\'s Encrypt), creates a TLS secret in Code Engine, then creates the domain mapping. Returns the CNAME target value to add in your DNS provider.',
|
|
867
|
+
inputSchema: {
|
|
868
|
+
type: 'object',
|
|
869
|
+
properties: {
|
|
870
|
+
project_id_or_name: { type: 'string', description: 'Code Engine project name or ID' },
|
|
871
|
+
app_name: { type: 'string', description: 'The Code Engine app to map the custom domain to' },
|
|
872
|
+
domain_name: { type: 'string', description: 'Your custom domain (e.g. myapp.example.com)' },
|
|
873
|
+
tls_secret_name: { type: 'string', description: 'Name to give the TLS secret in Code Engine (e.g. myapp-tls). Must be unique in the project.' },
|
|
874
|
+
cert_pem_path: { type: 'string', description: 'Path to the certificate chain PEM file — typically ~/certbot/config/live/<domain>/fullchain.pem' },
|
|
875
|
+
key_pem_path: { type: 'string', description: 'Path to the private key PEM file — typically ~/certbot/config/live/<domain>/privkey.pem' },
|
|
876
|
+
},
|
|
877
|
+
required: ['project_id_or_name', 'app_name', 'domain_name', 'tls_secret_name', 'cert_pem_path', 'key_pem_path'],
|
|
878
|
+
},
|
|
879
|
+
},
|
|
880
|
+
{
|
|
881
|
+
name: 'proc_build_run_and_deploy',
|
|
882
|
+
description: 'PROCEDURE: Code Engine source-to-image build + deploy in one step — starts a build run from an existing build configuration, polls until it succeeds, then creates or updates the application with the new image, and waits for it to become ready. Returns the public URL. The build configuration must already exist (use ce_create_build to set one up).',
|
|
883
|
+
inputSchema: {
|
|
884
|
+
type: 'object',
|
|
885
|
+
properties: {
|
|
886
|
+
project_id_or_name: { type: 'string', description: 'Code Engine project name or ID' },
|
|
887
|
+
build_name: { type: 'string', description: 'Name of the existing Code Engine build configuration to run (use ce_list_builds to find it)' },
|
|
888
|
+
app_name: { type: 'string', description: 'Application to create or update after the build succeeds' },
|
|
889
|
+
image_secret: { type: 'string', description: 'Registry pull secret name in Code Engine (e.g. icr-pull-secret)' },
|
|
890
|
+
port: { type: 'number', description: 'Container port the app listens on (default 8080)' },
|
|
891
|
+
build_timeout_seconds: { type: 'number', description: 'Max seconds to wait for the build to finish (default 600)' },
|
|
892
|
+
deploy_timeout_seconds: { type: 'number', description: 'Max seconds to wait for the app to become ready (default 180)' },
|
|
893
|
+
},
|
|
894
|
+
required: ['project_id_or_name', 'build_name', 'app_name', 'image_secret'],
|
|
895
|
+
},
|
|
896
|
+
},
|
|
617
897
|
];
|
|
618
898
|
// Register tools
|
|
619
899
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
@@ -635,6 +915,103 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
635
915
|
}
|
|
636
916
|
try {
|
|
637
917
|
switch (name) {
|
|
918
|
+
case 'ce_validate_dockerfile': {
|
|
919
|
+
const fs = await import('fs');
|
|
920
|
+
const path = await import('path');
|
|
921
|
+
const os = await import('os');
|
|
922
|
+
const resolvePath = (p) => p.startsWith('~') ? p.replace(/^~/, os.homedir()) : p;
|
|
923
|
+
const dfPath = resolvePath(args.dockerfile_path);
|
|
924
|
+
const expectedPort = args.expected_port || 8080;
|
|
925
|
+
if (!fs.existsSync(dfPath)) {
|
|
926
|
+
return { content: [{ type: 'text', text: JSON.stringify({ valid: false, errors: [`Dockerfile not found: ${dfPath}`], warnings: [], info: [] }, null, 2) }], isError: true };
|
|
927
|
+
}
|
|
928
|
+
const content = fs.readFileSync(dfPath, 'utf8');
|
|
929
|
+
const lines = content.split('\n');
|
|
930
|
+
const errors = [];
|
|
931
|
+
const warnings = [];
|
|
932
|
+
const info = [];
|
|
933
|
+
// ── 1. Architecture ────────────────────────────────────────────────────
|
|
934
|
+
// FROM --platform must target linux/amd64 or be absent (handled at build time)
|
|
935
|
+
const fromLines = lines.filter(l => /^\s*FROM\s/i.test(l));
|
|
936
|
+
const platformLines = fromLines.filter(l => /--platform/i.test(l));
|
|
937
|
+
const wrongPlatform = platformLines.filter(l => !/--platform\s+linux\/amd64/i.test(l));
|
|
938
|
+
if (wrongPlatform.length > 0) {
|
|
939
|
+
errors.push(`Architecture mismatch: Code Engine requires linux/amd64. Found: ${wrongPlatform.map(l => l.trim()).join(' | ')}`);
|
|
940
|
+
}
|
|
941
|
+
else if (platformLines.length === 0) {
|
|
942
|
+
info.push('No --platform in FROM. Ensure you build with --platform linux/amd64 (proc_build_push_deploy does this automatically).');
|
|
943
|
+
}
|
|
944
|
+
else {
|
|
945
|
+
info.push('Platform: linux/amd64 ✓');
|
|
946
|
+
}
|
|
947
|
+
// ── 2. EXPOSE port ─────────────────────────────────────────────────────
|
|
948
|
+
const exposeLines = lines.filter(l => /^\s*EXPOSE\s/i.test(l));
|
|
949
|
+
const exposedPorts = exposeLines.flatMap(l => l.replace(/^\s*EXPOSE\s+/i, '').trim().split(/\s+/).map(Number).filter(Boolean));
|
|
950
|
+
if (exposedPorts.length === 0) {
|
|
951
|
+
warnings.push(`No EXPOSE instruction found. Code Engine expects the app to listen on port ${expectedPort}.`);
|
|
952
|
+
}
|
|
953
|
+
else if (!exposedPorts.includes(expectedPort)) {
|
|
954
|
+
errors.push(`EXPOSE declares port(s) [${exposedPorts.join(', ')}] but Code Engine is configured for port ${expectedPort}. Add: EXPOSE ${expectedPort}`);
|
|
955
|
+
}
|
|
956
|
+
else {
|
|
957
|
+
info.push(`EXPOSE ${expectedPort} ✓`);
|
|
958
|
+
}
|
|
959
|
+
if (exposedPorts.includes(80)) {
|
|
960
|
+
warnings.push('Port 80 is EXPOSE\'d. Code Engine does not allow port 80. Use port 8080.');
|
|
961
|
+
}
|
|
962
|
+
// ── 3. nginx port sed patterns ─────────────────────────────────────────
|
|
963
|
+
const runLines = lines.filter(l => /^\s*RUN\s/i.test(l));
|
|
964
|
+
const sedLines = runLines.filter(l => /sed\s+-i/.test(l));
|
|
965
|
+
for (const sl of sedLines) {
|
|
966
|
+
// Check for exact-space patterns that won't match nginx:alpine's default.conf
|
|
967
|
+
if (/listen\s{2,}80;/.test(sl)) {
|
|
968
|
+
errors.push(`Fragile nginx sed pattern detected: "${sl.trim()}"\n → nginx:alpine uses variable whitespace. Use: sed -i 's/listen[[:space:]]*80;/listen 8080;/g'`);
|
|
969
|
+
}
|
|
970
|
+
if (/listen\s*80;/.test(sl) && !/\[\[:space:\]\]/.test(sl) && !/\\s/.test(sl)) {
|
|
971
|
+
warnings.push(`sed pattern for port 80 may not match nginx:alpine's whitespace. Safer: sed -i 's/listen[[:space:]]*80;/listen 8080;/g'`);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
// ── 4. Base image issues ───────────────────────────────────────────────
|
|
975
|
+
for (const fl of fromLines) {
|
|
976
|
+
// arm-only or architecture-specific tags
|
|
977
|
+
if (/arm64|aarch64|arm\//.test(fl)) {
|
|
978
|
+
errors.push(`Base image appears to be ARM-specific: "${fl.trim()}". Code Engine requires linux/amd64.`);
|
|
979
|
+
}
|
|
980
|
+
// python:latest, node:latest etc are fine but worth noting
|
|
981
|
+
if (/:latest\s*$/i.test(fl.trim()) || /\s+AS\s+/i.test(fl) === false && !fl.includes(':')) {
|
|
982
|
+
warnings.push(`Base image uses "latest" or no tag in "${fl.trim()}". Pin to a specific version for reproducible builds.`);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
// ── 5. Root user warning ──────────────────────────────────────────────
|
|
986
|
+
const userLines = lines.filter(l => /^\s*USER\s/i.test(l));
|
|
987
|
+
if (userLines.length === 0) {
|
|
988
|
+
warnings.push('No USER instruction. Container runs as root by default. Consider adding a non-root USER for security.');
|
|
989
|
+
}
|
|
990
|
+
else {
|
|
991
|
+
const rootUser = userLines.find(l => /\broot\b|USER\s+0\b/.test(l));
|
|
992
|
+
if (rootUser) {
|
|
993
|
+
warnings.push(`Explicit root user: "${rootUser.trim()}". Consider using a non-root user.`);
|
|
994
|
+
}
|
|
995
|
+
else {
|
|
996
|
+
info.push('Non-root USER instruction found ✓');
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
// ── 6. CMD / ENTRYPOINT check ─────────────────────────────────────────
|
|
1000
|
+
const cmdLines = lines.filter(l => /^\s*(CMD|ENTRYPOINT)\s/i.test(l));
|
|
1001
|
+
if (cmdLines.length === 0) {
|
|
1002
|
+
warnings.push('No CMD or ENTRYPOINT found. Code Engine needs the container to start a long-running process.');
|
|
1003
|
+
}
|
|
1004
|
+
// ── 7. Healthcheck (informational) ────────────────────────────────────
|
|
1005
|
+
const hasHealthcheck = lines.some(l => /^\s*HEALTHCHECK\s/i.test(l));
|
|
1006
|
+
if (!hasHealthcheck) {
|
|
1007
|
+
info.push('No HEALTHCHECK instruction. Code Engine uses TCP probe on the exposed port by default.');
|
|
1008
|
+
}
|
|
1009
|
+
const valid = errors.length === 0;
|
|
1010
|
+
const summary = valid
|
|
1011
|
+
? `Dockerfile is compatible with IBM Code Engine (${warnings.length} warning(s), ${info.length} info)`
|
|
1012
|
+
: `Dockerfile has ${errors.length} error(s) that will prevent correct operation on Code Engine`;
|
|
1013
|
+
return { content: [{ type: 'text', text: JSON.stringify({ valid, summary, errors, warnings, info, dockerfile: dfPath }, null, 2) }], ...(valid ? {} : { isError: true }) };
|
|
1014
|
+
}
|
|
638
1015
|
case 'detect_container_runtime': {
|
|
639
1016
|
const { stdout: dockerVersion } = await execAsync('docker --version').catch(() => ({ stdout: '' }));
|
|
640
1017
|
const { stdout: podmanVersion } = await execAsync('podman --version').catch(() => ({ stdout: '' }));
|
|
@@ -655,6 +1032,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
655
1032
|
const runtime = args.runtime || 'docker';
|
|
656
1033
|
const cmd = `${runtime} build -t ${args.image_name} -f ${args.dockerfile_path} ${args.context_path}`;
|
|
657
1034
|
const { stdout, stderr } = await execAsync(cmd);
|
|
1035
|
+
// Container runtimes write build progress to stderr — label it clearly
|
|
1036
|
+
const build_output = [stdout, stderr].filter(Boolean).join('\n').trim();
|
|
658
1037
|
return {
|
|
659
1038
|
content: [
|
|
660
1039
|
{
|
|
@@ -662,8 +1041,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
662
1041
|
text: JSON.stringify({
|
|
663
1042
|
success: true,
|
|
664
1043
|
command: cmd,
|
|
665
|
-
|
|
666
|
-
error: stderr
|
|
1044
|
+
build_output,
|
|
667
1045
|
}, null, 2),
|
|
668
1046
|
},
|
|
669
1047
|
],
|
|
@@ -771,6 +1149,47 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
771
1149
|
],
|
|
772
1150
|
};
|
|
773
1151
|
}
|
|
1152
|
+
case 'icr_list_namespaces': {
|
|
1153
|
+
const apiKey = getApiKey();
|
|
1154
|
+
const token = await getIAMToken(apiKey);
|
|
1155
|
+
const host = args.region || 'us.icr.io';
|
|
1156
|
+
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
|
|
1157
|
+
const accountId = payload?.account?.bss || '';
|
|
1158
|
+
const response = await axios.get(`https://${host}/api/v1/namespaces`, {
|
|
1159
|
+
headers: { Authorization: `Bearer ${token}`, Account: accountId },
|
|
1160
|
+
});
|
|
1161
|
+
return { content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }] };
|
|
1162
|
+
}
|
|
1163
|
+
case 'icr_list_images': {
|
|
1164
|
+
const apiKey = getApiKey();
|
|
1165
|
+
const token = await getIAMToken(apiKey);
|
|
1166
|
+
const host = args.region || 'us.icr.io';
|
|
1167
|
+
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
|
|
1168
|
+
const accountId = payload?.account?.bss || '';
|
|
1169
|
+
const params = { includeIBM: 'false' };
|
|
1170
|
+
if (args.namespace)
|
|
1171
|
+
params.namespace = args.namespace;
|
|
1172
|
+
const response = await axios.get(`https://${host}/api/v1/images`, {
|
|
1173
|
+
headers: { Authorization: `Bearer ${token}`, Account: accountId },
|
|
1174
|
+
params,
|
|
1175
|
+
});
|
|
1176
|
+
const images = response.data.map((img) => ({
|
|
1177
|
+
tags: img.RepoTags || [],
|
|
1178
|
+
digest: img.Id,
|
|
1179
|
+
size: img.Size,
|
|
1180
|
+
created: img.Created,
|
|
1181
|
+
}));
|
|
1182
|
+
return { content: [{ type: 'text', text: JSON.stringify(images, null, 2) }] };
|
|
1183
|
+
}
|
|
1184
|
+
case 'icr_delete_image': {
|
|
1185
|
+
const apiKey = getApiKey();
|
|
1186
|
+
const token = await getIAMToken(apiKey);
|
|
1187
|
+
const host = args.region || 'us.icr.io';
|
|
1188
|
+
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
|
|
1189
|
+
const accountId = payload?.account?.bss || '';
|
|
1190
|
+
const response = await axios.delete(`https://${host}/api/v1/images/${encodeURIComponent(args.image)}`, { headers: { Authorization: `Bearer ${token}`, Account: accountId } });
|
|
1191
|
+
return { content: [{ type: 'text', text: JSON.stringify({ deleted: args.image, status: response.status }, null, 2) }] };
|
|
1192
|
+
}
|
|
774
1193
|
case 'ce_list_projects': {
|
|
775
1194
|
const apiKey = getApiKey();
|
|
776
1195
|
const token = await getIAMToken(apiKey);
|
|
@@ -833,6 +1252,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
833
1252
|
scale_cpu_limit: args.scale_cpu_limit ?? '1',
|
|
834
1253
|
scale_memory_limit: args.scale_memory_limit ?? '4G',
|
|
835
1254
|
};
|
|
1255
|
+
if (args.image_secret)
|
|
1256
|
+
body.image_secret = args.image_secret;
|
|
836
1257
|
if (args.env_vars) {
|
|
837
1258
|
body.run_env_variables = Object.entries(args.env_vars).map(([name, value]) => ({
|
|
838
1259
|
type: 'literal', name, value,
|
|
@@ -852,6 +1273,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
852
1273
|
const patch = {};
|
|
853
1274
|
if (args.image)
|
|
854
1275
|
patch.image_reference = args.image;
|
|
1276
|
+
if (args.image_secret)
|
|
1277
|
+
patch.image_secret = args.image_secret;
|
|
855
1278
|
if (args.scale_min_instances !== undefined)
|
|
856
1279
|
patch.scale_min_instances = args.scale_min_instances;
|
|
857
1280
|
if (args.scale_max_instances !== undefined)
|
|
@@ -877,6 +1300,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
877
1300
|
const response = await axios.get(`${base}/apps/${args.app_name}/instances`, { headers });
|
|
878
1301
|
return { content: [{ type: 'text', text: JSON.stringify({ instances: response.data.instances || [], total: response.data.instances?.length || 0 }, null, 2) }] };
|
|
879
1302
|
}
|
|
1303
|
+
case 'ce_get_app_instance': {
|
|
1304
|
+
const token = await getIAMToken(getApiKey());
|
|
1305
|
+
const { base, headers } = await ceApi(args.project_id, token);
|
|
1306
|
+
const response = await axios.get(`${base}/apps/${args.app_name}/instances`, { headers });
|
|
1307
|
+
const instances = response.data.instances || [];
|
|
1308
|
+
const instance = instances.find((i) => i.name === args.instance_name);
|
|
1309
|
+
if (!instance) {
|
|
1310
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: `Instance '${args.instance_name}' not found`, available: instances.map((i) => i.name) }, null, 2) }] };
|
|
1311
|
+
}
|
|
1312
|
+
return { content: [{ type: 'text', text: JSON.stringify(instance, null, 2) }] };
|
|
1313
|
+
}
|
|
880
1314
|
case 'ce_get_app_logs': {
|
|
881
1315
|
const token = await getIAMToken(getApiKey());
|
|
882
1316
|
const { base, headers } = await ceApi(args.project_id, token);
|
|
@@ -1083,6 +1517,430 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1083
1517
|
await axios.delete(`${base}/config_maps/${args.config_map_name}`, { headers });
|
|
1084
1518
|
return { content: [{ type: 'text', text: JSON.stringify({ success: true, message: `ConfigMap ${args.config_map_name} deleted` }, null, 2) }] };
|
|
1085
1519
|
}
|
|
1520
|
+
case 'ce_list_domain_mappings': {
|
|
1521
|
+
const token = await getIAMToken(getApiKey());
|
|
1522
|
+
const { base, headers } = await ceApi(args.project_id, token);
|
|
1523
|
+
const response = await axios.get(`${base}/domain_mappings`, { headers });
|
|
1524
|
+
return { content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }] };
|
|
1525
|
+
}
|
|
1526
|
+
case 'ce_get_domain_mapping': {
|
|
1527
|
+
const token = await getIAMToken(getApiKey());
|
|
1528
|
+
const { base, headers } = await ceApi(args.project_id, token);
|
|
1529
|
+
const response = await axios.get(`${base}/domain_mappings/${encodeURIComponent(args.domain_name)}`, { headers });
|
|
1530
|
+
return { content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }] };
|
|
1531
|
+
}
|
|
1532
|
+
case 'ce_create_domain_mapping': {
|
|
1533
|
+
const token = await getIAMToken(getApiKey());
|
|
1534
|
+
const { base, headers } = await ceApi(args.project_id, token);
|
|
1535
|
+
const body = {
|
|
1536
|
+
name: args.domain_name,
|
|
1537
|
+
component: { resource_type: 'app_v2', name: args.app_name },
|
|
1538
|
+
tls_secret: args.tls_secret,
|
|
1539
|
+
};
|
|
1540
|
+
const response = await axios.post(`${base}/domain_mappings`, body, { headers });
|
|
1541
|
+
return { content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }] };
|
|
1542
|
+
}
|
|
1543
|
+
case 'ce_create_tls_secret_from_pem': {
|
|
1544
|
+
const fs = await import('fs');
|
|
1545
|
+
const os = await import('os');
|
|
1546
|
+
const resolvePath = (p) => p.replace(/^~/, os.homedir());
|
|
1547
|
+
const certPem = fs.readFileSync(resolvePath(args.cert_pem_path), 'utf8');
|
|
1548
|
+
const keyPem = fs.readFileSync(resolvePath(args.key_pem_path), 'utf8');
|
|
1549
|
+
const token = await getIAMToken(getApiKey());
|
|
1550
|
+
const { base, headers } = await ceApi(args.project_id, token);
|
|
1551
|
+
const body = { name: args.secret_name, format: 'tls', data: { tls_cert: certPem, tls_key: keyPem } };
|
|
1552
|
+
const response = await axios.post(`${base}/secrets`, body, { headers });
|
|
1553
|
+
return { content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }] };
|
|
1554
|
+
}
|
|
1555
|
+
case 'ce_delete_domain_mapping': {
|
|
1556
|
+
const token = await getIAMToken(getApiKey());
|
|
1557
|
+
const { base, headers } = await ceApi(args.project_id, token);
|
|
1558
|
+
await axios.delete(`${base}/domain_mappings/${encodeURIComponent(args.domain_name)}`, { headers });
|
|
1559
|
+
return { content: [{ type: 'text', text: JSON.stringify({ success: true, message: `Domain mapping ${args.domain_name} deleted` }, null, 2) }] };
|
|
1560
|
+
}
|
|
1561
|
+
case 'ce_update_secret': {
|
|
1562
|
+
const token = await getIAMToken(getApiKey());
|
|
1563
|
+
const { base, headers } = await ceApi(args.project_id, token);
|
|
1564
|
+
const current = await axios.get(`${base}/secrets/${args.secret_name}`, { headers });
|
|
1565
|
+
const etag = current.data.entity_tag;
|
|
1566
|
+
const patchHeaders = { ...headers, 'If-Match': etag };
|
|
1567
|
+
const body = { data: args.data };
|
|
1568
|
+
if (args.format)
|
|
1569
|
+
body.format = args.format;
|
|
1570
|
+
const response = await axios.patch(`${base}/secrets/${args.secret_name}`, body, { headers: patchHeaders });
|
|
1571
|
+
return { content: [{ type: 'text', text: JSON.stringify({ name: response.data.name, format: response.data.format, updated_at: response.data.updated_at, message: 'Secret updated successfully' }, null, 2) }] };
|
|
1572
|
+
}
|
|
1573
|
+
case 'ce_renew_tls_secret_from_pem': {
|
|
1574
|
+
const fs = await import('fs');
|
|
1575
|
+
const os = await import('os');
|
|
1576
|
+
const resolvePath = (p) => p.replace(/^~/, os.homedir());
|
|
1577
|
+
const certPem = fs.readFileSync(resolvePath(args.cert_pem_path), 'utf8');
|
|
1578
|
+
const keyPem = fs.readFileSync(resolvePath(args.key_pem_path), 'utf8');
|
|
1579
|
+
const token = await getIAMToken(getApiKey());
|
|
1580
|
+
const { base, headers } = await ceApi(args.project_id, token);
|
|
1581
|
+
const current = await axios.get(`${base}/secrets/${args.secret_name}`, { headers });
|
|
1582
|
+
const etag = current.data.entity_tag;
|
|
1583
|
+
const patchHeaders = { ...headers, 'If-Match': etag };
|
|
1584
|
+
const response = await axios.patch(`${base}/secrets/${args.secret_name}`, { data: { tls_cert: certPem, tls_key: keyPem } }, { headers: patchHeaders });
|
|
1585
|
+
return { content: [{ type: 'text', text: JSON.stringify({ name: response.data.name, format: response.data.format, updated_at: response.data.updated_at, message: 'TLS secret renewed — no domain mapping change needed' }, null, 2) }] };
|
|
1586
|
+
}
|
|
1587
|
+
case 'ce_wait_for_app_ready': {
|
|
1588
|
+
const token = await getIAMToken(getApiKey());
|
|
1589
|
+
const { base, headers } = await ceApi(args.project_id, token);
|
|
1590
|
+
const maxWait = (args.timeout_seconds || 120) * 1000;
|
|
1591
|
+
const interval = 5000;
|
|
1592
|
+
const start = Date.now();
|
|
1593
|
+
let last = {};
|
|
1594
|
+
const pollHistory = [];
|
|
1595
|
+
while (Date.now() - start < maxWait) {
|
|
1596
|
+
const response = await axios.get(`${base}/apps/${args.app_name}`, { headers });
|
|
1597
|
+
last = response.data;
|
|
1598
|
+
const snap = {
|
|
1599
|
+
elapsed_s: Math.round((Date.now() - start) / 1000),
|
|
1600
|
+
status: last.status,
|
|
1601
|
+
reason: last.status_details?.reason,
|
|
1602
|
+
revision: last.status_details?.latest_created_revision,
|
|
1603
|
+
};
|
|
1604
|
+
pollHistory.push(snap);
|
|
1605
|
+
if (last.status === 'ready') {
|
|
1606
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1607
|
+
status: 'ready',
|
|
1608
|
+
elapsed_seconds: snap.elapsed_s,
|
|
1609
|
+
endpoint: last.endpoint,
|
|
1610
|
+
latest_ready_revision: last.status_details?.latest_ready_revision,
|
|
1611
|
+
poll_history: pollHistory,
|
|
1612
|
+
}, null, 2) }] };
|
|
1613
|
+
}
|
|
1614
|
+
if (last.status === 'failed') {
|
|
1615
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1616
|
+
status: 'failed',
|
|
1617
|
+
elapsed_seconds: snap.elapsed_s,
|
|
1618
|
+
reason: last.status_details?.reason,
|
|
1619
|
+
status_details: last.status_details,
|
|
1620
|
+
poll_history: pollHistory,
|
|
1621
|
+
}, null, 2) }], isError: true };
|
|
1622
|
+
}
|
|
1623
|
+
await new Promise(r => setTimeout(r, interval));
|
|
1624
|
+
}
|
|
1625
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1626
|
+
status: 'timeout',
|
|
1627
|
+
elapsed_seconds: Math.round(maxWait / 1000),
|
|
1628
|
+
last_status: last.status,
|
|
1629
|
+
reason: last.status_details?.reason,
|
|
1630
|
+
poll_history: pollHistory,
|
|
1631
|
+
}, null, 2) }], isError: true };
|
|
1632
|
+
}
|
|
1633
|
+
case 'ce_wait_for_build_run': {
|
|
1634
|
+
const token = await getIAMToken(getApiKey());
|
|
1635
|
+
const { base, headers } = await ceApi(args.project_id, token);
|
|
1636
|
+
const maxWait = (args.timeout_seconds || 600) * 1000;
|
|
1637
|
+
const interval = 10000;
|
|
1638
|
+
const start = Date.now();
|
|
1639
|
+
let last = {};
|
|
1640
|
+
const pollHistory = [];
|
|
1641
|
+
while (Date.now() - start < maxWait) {
|
|
1642
|
+
const response = await axios.get(`${base}/build_runs/${args.build_run_name}`, { headers });
|
|
1643
|
+
last = response.data;
|
|
1644
|
+
const snap = {
|
|
1645
|
+
elapsed_s: Math.round((Date.now() - start) / 1000),
|
|
1646
|
+
status: last.status,
|
|
1647
|
+
reason: last.status_details?.reason,
|
|
1648
|
+
};
|
|
1649
|
+
// only add to history when status changes
|
|
1650
|
+
if (pollHistory.length === 0 || pollHistory[pollHistory.length - 1].status !== snap.status) {
|
|
1651
|
+
pollHistory.push(snap);
|
|
1652
|
+
}
|
|
1653
|
+
if (last.status === 'succeeded') {
|
|
1654
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1655
|
+
status: 'succeeded',
|
|
1656
|
+
elapsed_seconds: snap.elapsed_s,
|
|
1657
|
+
output_image: last.output_image,
|
|
1658
|
+
build_run_name: last.name,
|
|
1659
|
+
poll_history: pollHistory,
|
|
1660
|
+
}, null, 2) }] };
|
|
1661
|
+
}
|
|
1662
|
+
if (last.status === 'failed') {
|
|
1663
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1664
|
+
status: 'failed',
|
|
1665
|
+
elapsed_seconds: snap.elapsed_s,
|
|
1666
|
+
reason: last.status_details?.reason,
|
|
1667
|
+
status_details: last.status_details,
|
|
1668
|
+
poll_history: pollHistory,
|
|
1669
|
+
}, null, 2) }], isError: true };
|
|
1670
|
+
}
|
|
1671
|
+
await new Promise(r => setTimeout(r, interval));
|
|
1672
|
+
}
|
|
1673
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1674
|
+
status: 'timeout',
|
|
1675
|
+
elapsed_seconds: Math.round(maxWait / 1000),
|
|
1676
|
+
last_status: last.status,
|
|
1677
|
+
reason: last.status_details?.reason,
|
|
1678
|
+
poll_history: pollHistory,
|
|
1679
|
+
}, null, 2) }], isError: true };
|
|
1680
|
+
}
|
|
1681
|
+
case 'icr_create_namespace': {
|
|
1682
|
+
const apiKey = getApiKey();
|
|
1683
|
+
const token = await getIAMToken(apiKey);
|
|
1684
|
+
const host = args.region || 'us.icr.io';
|
|
1685
|
+
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
|
|
1686
|
+
const accountId = payload?.account?.bss || '';
|
|
1687
|
+
const response = await axios.put(`https://${host}/api/v1/namespaces/${encodeURIComponent(args.namespace)}`, {}, { headers: { Authorization: `Bearer ${token}`, Account: accountId, 'Content-Type': 'application/json' } });
|
|
1688
|
+
return { content: [{ type: 'text', text: JSON.stringify(response.data, null, 2) }] };
|
|
1689
|
+
}
|
|
1690
|
+
case 'iam_get_token_info': {
|
|
1691
|
+
const token = await getIAMToken(getApiKey());
|
|
1692
|
+
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
|
|
1693
|
+
const expMs = payload.exp * 1000;
|
|
1694
|
+
const nowMs = Date.now();
|
|
1695
|
+
const info = {
|
|
1696
|
+
account_id: payload?.account?.bss || payload?.account,
|
|
1697
|
+
iam_id: payload?.iam_id,
|
|
1698
|
+
sub: payload?.sub,
|
|
1699
|
+
email: payload?.email,
|
|
1700
|
+
expires_at: new Date(expMs).toISOString(),
|
|
1701
|
+
expires_in_seconds: Math.floor((expMs - nowMs) / 1000),
|
|
1702
|
+
valid: expMs > nowMs,
|
|
1703
|
+
scopes: payload?.scope,
|
|
1704
|
+
};
|
|
1705
|
+
return { content: [{ type: 'text', text: JSON.stringify(info, null, 2) }] };
|
|
1706
|
+
}
|
|
1707
|
+
// ─── Procedures ──────────────────────────────────────────────────────────
|
|
1708
|
+
case 'proc_build_push_deploy': {
|
|
1709
|
+
const steps = [];
|
|
1710
|
+
// 0) validate Dockerfile before doing anything else
|
|
1711
|
+
const contextPathRaw = args.context_path;
|
|
1712
|
+
const fsM = await import('fs');
|
|
1713
|
+
const pathM = await import('path');
|
|
1714
|
+
const osM = await import('os');
|
|
1715
|
+
const resolveProcPath = (p) => p.startsWith('~') ? p.replace(/^~/, osM.homedir()) : p;
|
|
1716
|
+
const resolvedContext = resolveProcPath(contextPathRaw);
|
|
1717
|
+
const dockerfilePath = pathM.join(resolvedContext, 'Dockerfile');
|
|
1718
|
+
if (fsM.existsSync(dockerfilePath)) {
|
|
1719
|
+
const dfContent = fsM.readFileSync(dockerfilePath, 'utf8');
|
|
1720
|
+
const dfLines = dfContent.split('\n');
|
|
1721
|
+
const expectedPort = args.port || 8080;
|
|
1722
|
+
const valErrors = [];
|
|
1723
|
+
const valWarnings = [];
|
|
1724
|
+
// Architecture check
|
|
1725
|
+
const fromPlatformLines = dfLines.filter(l => /^\s*FROM\s.*--platform/i.test(l));
|
|
1726
|
+
const wrongArch = fromPlatformLines.filter(l => !/--platform\s+linux\/amd64/i.test(l));
|
|
1727
|
+
if (wrongArch.length > 0)
|
|
1728
|
+
valErrors.push(`Architecture: FROM --platform must be linux/amd64. Found: ${wrongArch.map(l => l.trim()).join(' | ')}`);
|
|
1729
|
+
// Port check
|
|
1730
|
+
const exposedPorts = dfLines.filter(l => /^\s*EXPOSE\s/i.test(l))
|
|
1731
|
+
.flatMap(l => l.replace(/^\s*EXPOSE\s+/i, '').trim().split(/\s+/).map(Number).filter(Boolean));
|
|
1732
|
+
if (exposedPorts.length > 0 && !exposedPorts.includes(expectedPort)) {
|
|
1733
|
+
valErrors.push(`Port mismatch: EXPOSE declares [${exposedPorts.join(', ')}] but app port is ${expectedPort}.`);
|
|
1734
|
+
}
|
|
1735
|
+
if (exposedPorts.includes(80))
|
|
1736
|
+
valErrors.push('Port 80 is not allowed in Code Engine. Use port 8080.');
|
|
1737
|
+
// nginx sed pattern check
|
|
1738
|
+
dfLines.filter(l => /sed\s+-i/.test(l) && /listen/.test(l)).forEach(sl => {
|
|
1739
|
+
if (/listen\s{2,}80;/.test(sl)) {
|
|
1740
|
+
valWarnings.push(`Fragile nginx sed pattern: "${sl.trim()}" — use 's/listen[[:space:]]*80;/listen 8080;/g' to handle variable whitespace in nginx:alpine`);
|
|
1741
|
+
}
|
|
1742
|
+
});
|
|
1743
|
+
if (valErrors.length > 0) {
|
|
1744
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1745
|
+
status: 'aborted',
|
|
1746
|
+
reason: 'Dockerfile validation failed — fix errors before building',
|
|
1747
|
+
dockerfile_errors: valErrors,
|
|
1748
|
+
dockerfile_warnings: valWarnings,
|
|
1749
|
+
steps,
|
|
1750
|
+
}, null, 2) }], isError: true };
|
|
1751
|
+
}
|
|
1752
|
+
const validLine = `[0/5] Dockerfile validated ✓${valWarnings.length ? ` (${valWarnings.length} warning(s): ${valWarnings.join('; ')})` : ''}`;
|
|
1753
|
+
steps.push(validLine);
|
|
1754
|
+
}
|
|
1755
|
+
else {
|
|
1756
|
+
steps.push(`[0/5] No Dockerfile found at ${dockerfilePath} — skipping pre-flight validation`);
|
|
1757
|
+
}
|
|
1758
|
+
// 1) detect runtime
|
|
1759
|
+
let runtime = 'podman';
|
|
1760
|
+
try {
|
|
1761
|
+
await execAsync('podman --version');
|
|
1762
|
+
}
|
|
1763
|
+
catch {
|
|
1764
|
+
runtime = 'docker';
|
|
1765
|
+
}
|
|
1766
|
+
steps.push(`[1/5] Using container runtime: ${runtime}`);
|
|
1767
|
+
// 2) resolve project
|
|
1768
|
+
const token1 = await getIAMToken(getApiKey());
|
|
1769
|
+
const projectId1 = await resolveProjectId(args.project_id_or_name, token1);
|
|
1770
|
+
steps.push(`[2/5] Resolved project: ${projectId1}`);
|
|
1771
|
+
// 3) build the image name from namespace + app + tag
|
|
1772
|
+
const icrHost = args.icr_host || 'us.icr.io';
|
|
1773
|
+
const imageTag = args.image_tag || 'latest';
|
|
1774
|
+
const imageName = `${icrHost}/${args.icr_namespace}/${args.app_name}:${imageTag}`;
|
|
1775
|
+
const contextPath = contextPathRaw;
|
|
1776
|
+
const buildCmd = `${runtime} build --platform linux/amd64 -t ${imageName} ${contextPath}`;
|
|
1777
|
+
const { stdout: buildStdout, stderr: buildStderr } = await execAsync(buildCmd);
|
|
1778
|
+
const buildOutput = [buildStdout, buildStderr].filter(Boolean).join('\n').trim();
|
|
1779
|
+
// show last 20 lines of build output so it's not overwhelming
|
|
1780
|
+
const buildLines = buildOutput.split('\n');
|
|
1781
|
+
const buildSummary = buildLines.length > 20 ? `...${buildLines.slice(-20).join('\n')}` : buildOutput;
|
|
1782
|
+
steps.push(`[3/5] Built ${imageName} for linux/amd64:\n${buildSummary}`);
|
|
1783
|
+
// 4) push
|
|
1784
|
+
const pushCmd = `${runtime} push ${imageName}`;
|
|
1785
|
+
const { stdout: pushStdout, stderr: pushStderr } = await execAsync(pushCmd);
|
|
1786
|
+
const pushOutput = [pushStdout, pushStderr].filter(Boolean).join('\n').trim();
|
|
1787
|
+
steps.push(`[4/5] Pushed to ${icrHost}:\n${pushOutput}`);
|
|
1788
|
+
// 5) create or update CE app
|
|
1789
|
+
const { base: base1, headers: headers1 } = await ceApi(projectId1, token1);
|
|
1790
|
+
const appPayload1 = {
|
|
1791
|
+
image_reference: imageName,
|
|
1792
|
+
image_secret: args.image_secret,
|
|
1793
|
+
scale_initial_instances: 1,
|
|
1794
|
+
scale_min_instances: args.scale_min_instances ?? 0,
|
|
1795
|
+
scale_max_instances: args.scale_max_instances ?? 10,
|
|
1796
|
+
image_port: args.port ?? 8080,
|
|
1797
|
+
};
|
|
1798
|
+
if (args.env_vars) {
|
|
1799
|
+
appPayload1.run_env_variables = Object.entries(args.env_vars).map(([k, v]) => ({ type: 'literal', name: k, value: v }));
|
|
1800
|
+
}
|
|
1801
|
+
let appEndpoint1 = '';
|
|
1802
|
+
try {
|
|
1803
|
+
const existing = await axios.get(`${base1}/apps/${args.app_name}`, { headers: headers1 });
|
|
1804
|
+
const patchHeaders = { ...headers1, 'If-Match': existing.data.entity_tag };
|
|
1805
|
+
const updated = await axios.patch(`${base1}/apps/${args.app_name}`, appPayload1, { headers: patchHeaders });
|
|
1806
|
+
appEndpoint1 = updated.data.endpoint || '';
|
|
1807
|
+
steps.push(`[5/5] App updated (revision ${updated.data.status_details?.latest_created_revision})`);
|
|
1808
|
+
}
|
|
1809
|
+
catch {
|
|
1810
|
+
const created = await axios.post(`${base1}/apps`, { name: args.app_name, ...appPayload1 }, { headers: headers1 });
|
|
1811
|
+
appEndpoint1 = created.data.endpoint || '';
|
|
1812
|
+
steps.push(`[5/5] App created`);
|
|
1813
|
+
}
|
|
1814
|
+
// wait for ready
|
|
1815
|
+
const maxWait1 = (args.timeout_seconds || 180) * 1000;
|
|
1816
|
+
const start1 = Date.now();
|
|
1817
|
+
let last1 = {};
|
|
1818
|
+
const pollHistory1 = [];
|
|
1819
|
+
while (Date.now() - start1 < maxWait1) {
|
|
1820
|
+
const res = await axios.get(`${base1}/apps/${args.app_name}`, { headers: headers1 });
|
|
1821
|
+
last1 = res.data;
|
|
1822
|
+
const snap1 = { elapsed_s: Math.round((Date.now() - start1) / 1000), status: last1.status, reason: last1.status_details?.reason, revision: last1.status_details?.latest_created_revision };
|
|
1823
|
+
if (pollHistory1.length === 0 || pollHistory1[pollHistory1.length - 1].status !== snap1.status)
|
|
1824
|
+
pollHistory1.push(snap1);
|
|
1825
|
+
if (last1.status === 'ready')
|
|
1826
|
+
break;
|
|
1827
|
+
if (last1.status === 'failed') {
|
|
1828
|
+
return { content: [{ type: 'text', text: JSON.stringify({ steps, status: 'failed', reason: last1.status_details?.reason, status_details: last1.status_details, poll_history: pollHistory1 }, null, 2) }], isError: true };
|
|
1829
|
+
}
|
|
1830
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
1831
|
+
}
|
|
1832
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1833
|
+
status: last1.status,
|
|
1834
|
+
endpoint: last1.endpoint || appEndpoint1,
|
|
1835
|
+
image: imageName,
|
|
1836
|
+
latest_ready_revision: last1.status_details?.latest_ready_revision,
|
|
1837
|
+
elapsed_seconds: Math.round((Date.now() - start1) / 1000),
|
|
1838
|
+
steps,
|
|
1839
|
+
poll_history: pollHistory1,
|
|
1840
|
+
}, null, 2) }] };
|
|
1841
|
+
}
|
|
1842
|
+
case 'proc_setup_custom_domain': {
|
|
1843
|
+
const fs = await import('fs');
|
|
1844
|
+
const os = await import('os');
|
|
1845
|
+
const resolvePath = (p) => p.replace(/^~/, os.homedir());
|
|
1846
|
+
const certPem = fs.readFileSync(resolvePath(args.cert_pem_path), 'utf8');
|
|
1847
|
+
const keyPem = fs.readFileSync(resolvePath(args.key_pem_path), 'utf8');
|
|
1848
|
+
const token2 = await getIAMToken(getApiKey());
|
|
1849
|
+
const projectId2 = await resolveProjectId(args.project_id_or_name, token2);
|
|
1850
|
+
const { base: base2, headers: headers2 } = await ceApi(projectId2, token2);
|
|
1851
|
+
const secretBody = { name: args.tls_secret_name, format: 'tls', data: { tls_cert: certPem, tls_key: keyPem } };
|
|
1852
|
+
const secretRes = await axios.post(`${base2}/secrets`, secretBody, { headers: headers2 });
|
|
1853
|
+
const mappingBody = {
|
|
1854
|
+
name: args.domain_name,
|
|
1855
|
+
component: { resource_type: 'app_v2', name: args.app_name },
|
|
1856
|
+
tls_secret: args.tls_secret_name,
|
|
1857
|
+
};
|
|
1858
|
+
const mappingRes = await axios.post(`${base2}/domain_mappings`, mappingBody, { headers: headers2 });
|
|
1859
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1860
|
+
tls_secret: { name: secretRes.data.name, created_at: secretRes.data.created_at },
|
|
1861
|
+
domain_mapping: { name: mappingRes.data.name, status: mappingRes.data.status, cname_target: mappingRes.data.cname_target },
|
|
1862
|
+
next_step: `In your DNS provider, add a CNAME record: ${args.domain_name} → ${mappingRes.data.cname_target}`,
|
|
1863
|
+
}, null, 2) }] };
|
|
1864
|
+
}
|
|
1865
|
+
case 'proc_build_run_and_deploy': {
|
|
1866
|
+
const token3 = await getIAMToken(getApiKey());
|
|
1867
|
+
const projectId3 = await resolveProjectId(args.project_id_or_name, token3);
|
|
1868
|
+
const { base: base3, headers: headers3 } = await ceApi(projectId3, token3);
|
|
1869
|
+
const steps3 = [];
|
|
1870
|
+
steps3.push(`[1/4] Resolved project: ${projectId3}`);
|
|
1871
|
+
// get build config to find output image
|
|
1872
|
+
const buildConfig = await axios.get(`${base3}/builds/${args.build_name}`, { headers: headers3 });
|
|
1873
|
+
const outputImage = buildConfig.data.output_image;
|
|
1874
|
+
steps3.push(`[2/4] Build config found. Output image: ${outputImage}`);
|
|
1875
|
+
// start build run
|
|
1876
|
+
const buildRunRes = await axios.post(`${base3}/build_runs`, { build_name: args.build_name }, { headers: headers3 });
|
|
1877
|
+
const buildRunName = buildRunRes.data.name;
|
|
1878
|
+
steps3.push(`[3/4] Build run started: ${buildRunName}`);
|
|
1879
|
+
// wait for build run
|
|
1880
|
+
const buildMaxWait = (args.build_timeout_seconds || 600) * 1000;
|
|
1881
|
+
const buildStart = Date.now();
|
|
1882
|
+
let buildLast = {};
|
|
1883
|
+
const buildPollHistory = [];
|
|
1884
|
+
while (Date.now() - buildStart < buildMaxWait) {
|
|
1885
|
+
const res = await axios.get(`${base3}/build_runs/${buildRunName}`, { headers: headers3 });
|
|
1886
|
+
buildLast = res.data;
|
|
1887
|
+
const bsnap = { elapsed_s: Math.round((Date.now() - buildStart) / 1000), status: buildLast.status, reason: buildLast.status_details?.reason };
|
|
1888
|
+
if (buildPollHistory.length === 0 || buildPollHistory[buildPollHistory.length - 1].status !== bsnap.status)
|
|
1889
|
+
buildPollHistory.push(bsnap);
|
|
1890
|
+
if (buildLast.status === 'succeeded')
|
|
1891
|
+
break;
|
|
1892
|
+
if (buildLast.status === 'failed') {
|
|
1893
|
+
return { content: [{ type: 'text', text: JSON.stringify({ steps: steps3, build_status: 'failed', reason: buildLast.status_details?.reason, status_details: buildLast.status_details, build_poll_history: buildPollHistory }, null, 2) }], isError: true };
|
|
1894
|
+
}
|
|
1895
|
+
await new Promise(r => setTimeout(r, 10000));
|
|
1896
|
+
}
|
|
1897
|
+
if (buildLast.status !== 'succeeded') {
|
|
1898
|
+
return { content: [{ type: 'text', text: JSON.stringify({ steps: steps3, build_status: 'timeout', last_status: buildLast.status, build_poll_history: buildPollHistory }, null, 2) }], isError: true };
|
|
1899
|
+
}
|
|
1900
|
+
steps3.push(`Build done in ${Math.round((Date.now() - buildStart) / 1000)}s (history: ${buildPollHistory.map(h => `${h.elapsed_s}s→${h.status}`).join(', ')})`);
|
|
1901
|
+
// create or update app
|
|
1902
|
+
const appPayload3 = {
|
|
1903
|
+
image_reference: outputImage,
|
|
1904
|
+
image_secret: args.image_secret,
|
|
1905
|
+
image_port: args.port ?? 8080,
|
|
1906
|
+
};
|
|
1907
|
+
try {
|
|
1908
|
+
const existing = await axios.get(`${base3}/apps/${args.app_name}`, { headers: headers3 });
|
|
1909
|
+
const patchHeaders = { ...headers3, 'If-Match': existing.data.entity_tag };
|
|
1910
|
+
const updated = await axios.patch(`${base3}/apps/${args.app_name}`, appPayload3, { headers: patchHeaders });
|
|
1911
|
+
steps3.push(`[4/4] App updated (revision ${updated.data.status_details?.latest_created_revision})`);
|
|
1912
|
+
}
|
|
1913
|
+
catch {
|
|
1914
|
+
await axios.post(`${base3}/apps`, { name: args.app_name, ...appPayload3 }, { headers: headers3 });
|
|
1915
|
+
steps3.push(`[4/4] App created`);
|
|
1916
|
+
}
|
|
1917
|
+
// wait for app ready
|
|
1918
|
+
const appMaxWait = (args.deploy_timeout_seconds || 180) * 1000;
|
|
1919
|
+
const appStart = Date.now();
|
|
1920
|
+
let appLast = {};
|
|
1921
|
+
const appPollHistory = [];
|
|
1922
|
+
while (Date.now() - appStart < appMaxWait) {
|
|
1923
|
+
const res = await axios.get(`${base3}/apps/${args.app_name}`, { headers: headers3 });
|
|
1924
|
+
appLast = res.data;
|
|
1925
|
+
const asnap = { elapsed_s: Math.round((Date.now() - appStart) / 1000), status: appLast.status, reason: appLast.status_details?.reason, revision: appLast.status_details?.latest_created_revision };
|
|
1926
|
+
if (appPollHistory.length === 0 || appPollHistory[appPollHistory.length - 1].status !== asnap.status)
|
|
1927
|
+
appPollHistory.push(asnap);
|
|
1928
|
+
if (appLast.status === 'ready')
|
|
1929
|
+
break;
|
|
1930
|
+
if (appLast.status === 'failed') {
|
|
1931
|
+
return { content: [{ type: 'text', text: JSON.stringify({ steps: steps3, deploy_status: 'failed', reason: appLast.status_details?.reason, status_details: appLast.status_details, app_poll_history: appPollHistory }, null, 2) }], isError: true };
|
|
1932
|
+
}
|
|
1933
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
1934
|
+
}
|
|
1935
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1936
|
+
status: appLast.status,
|
|
1937
|
+
endpoint: appLast.endpoint,
|
|
1938
|
+
image: outputImage,
|
|
1939
|
+
latest_ready_revision: appLast.status_details?.latest_ready_revision,
|
|
1940
|
+
steps: steps3,
|
|
1941
|
+
app_poll_history: appPollHistory,
|
|
1942
|
+
}, null, 2) }] };
|
|
1943
|
+
}
|
|
1086
1944
|
default:
|
|
1087
1945
|
throw new Error(`Unknown tool: ${name}`);
|
|
1088
1946
|
}
|
|
@@ -1095,7 +1953,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1095
1953
|
text: JSON.stringify({
|
|
1096
1954
|
error: error.message,
|
|
1097
1955
|
stderr: error.stderr,
|
|
1098
|
-
stdout: error.stdout
|
|
1956
|
+
stdout: error.stdout,
|
|
1957
|
+
response_data: error.response?.data,
|
|
1958
|
+
status: error.response?.status,
|
|
1099
1959
|
}, null, 2),
|
|
1100
1960
|
},
|
|
1101
1961
|
],
|