mlgym-deploy 3.3.6 → 3.3.14

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 (2) hide show
  1. package/index.js +176 -32
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -18,7 +18,7 @@ import crypto from 'crypto';
18
18
  const execAsync = promisify(exec);
19
19
 
20
20
  // Current version of this MCP server - INCREMENT FOR WORKFLOW FIXES
21
- const CURRENT_VERSION = '3.3.6'; // Added mlgym_deploy_logs to package.json tools list for proper MCP registration
21
+ const CURRENT_VERSION = '3.3.13'; // Fix branch detection for git push (master vs main)
22
22
  const PACKAGE_NAME = 'mlgym-deploy';
23
23
 
24
24
  // Debug logging configuration - ENABLED BY DEFAULT
@@ -112,14 +112,15 @@ async function saveAuth(email, token) {
112
112
  );
113
113
  }
114
114
 
115
- // API client helper
116
- async function apiRequest(method, endpoint, data = null, useAuth = true) {
115
+ // API client helper with retry logic for 5xx errors
116
+ async function apiRequest(method, endpoint, data = null, useAuth = true, maxRetries = 3) {
117
117
  const config = {
118
118
  method,
119
119
  url: `${CONFIG.backend_url}${endpoint}`,
120
120
  headers: {
121
121
  'Content-Type': 'application/json'
122
- }
122
+ },
123
+ timeout: 120000 // 2 minute timeout for long operations
123
124
  };
124
125
 
125
126
  if (useAuth) {
@@ -133,13 +134,32 @@ async function apiRequest(method, endpoint, data = null, useAuth = true) {
133
134
  config.data = data;
134
135
  }
135
136
 
136
- try {
137
- const response = await axios(config);
138
- return { success: true, data: response.data };
139
- } catch (error) {
140
- const errorData = error.response?.data || { error: error.message };
141
- return { success: false, error: errorData.error || errorData.message || 'Request failed' };
137
+ let lastError = null;
138
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
139
+ try {
140
+ const response = await axios(config);
141
+ return { success: true, data: response.data };
142
+ } catch (error) {
143
+ lastError = error;
144
+ const statusCode = error.response?.status;
145
+
146
+ // Retry on 5xx errors (server errors) or network errors
147
+ if (statusCode >= 500 || !error.response) {
148
+ if (attempt < maxRetries) {
149
+ const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000); // Exponential backoff, max 10s
150
+ log.warning(`MCP >>> API request failed (attempt ${attempt}/${maxRetries}), status: ${statusCode || 'network error'}, retrying in ${delay}ms...`);
151
+ await new Promise(resolve => setTimeout(resolve, delay));
152
+ continue;
153
+ }
154
+ }
155
+
156
+ // Don't retry on 4xx errors (client errors)
157
+ break;
158
+ }
142
159
  }
160
+
161
+ const errorData = lastError?.response?.data || { error: lastError?.message };
162
+ return { success: false, error: errorData.error || errorData.message || 'Request failed' };
143
163
  }
144
164
 
145
165
  // Helper to generate random password
@@ -1022,6 +1042,9 @@ async function detectDeploymentStrategy(projectPath) {
1022
1042
  }
1023
1043
 
1024
1044
  // Validate and potentially fix docker-compose.yml for Coolify compliance
1045
+ // IMPORTANT: For Coolify/Traefik routing, we must use "expose:" instead of "ports:"
1046
+ // Using "ports: 80:3000" tries to bind host port 80, which conflicts with Traefik
1047
+ // Using "expose: 3000" lets Traefik route traffic via docker labels (correct approach)
1025
1048
  function validateAndFixDockerCompose(content) {
1026
1049
  const lines = content.split('\n');
1027
1050
  const issues = [];
@@ -1029,8 +1052,10 @@ function validateAndFixDockerCompose(content) {
1029
1052
  let fixed = [...lines];
1030
1053
  let inServicesSection = false;
1031
1054
  let inPortsSection = false;
1055
+ let portsLineIndex = -1;
1032
1056
  let currentService = '';
1033
1057
  let currentIndent = '';
1058
+ let portsIndent = '';
1034
1059
 
1035
1060
  for (let i = 0; i < lines.length; i++) {
1036
1061
  const line = lines[i];
@@ -1054,38 +1079,45 @@ function validateAndFixDockerCompose(content) {
1054
1079
  if ((line.startsWith(' ') || line.startsWith('\t')) &&
1055
1080
  trimmed.endsWith(':') &&
1056
1081
  !trimmed.startsWith('-') &&
1057
- !['ports:', 'expose:', 'environment:', 'volumes:', 'networks:'].includes(trimmed)) {
1082
+ !['ports:', 'expose:', 'environment:', 'volumes:', 'networks:', 'depends_on:', 'healthcheck:', 'build:', 'image:', 'command:', 'labels:'].includes(trimmed)) {
1058
1083
  currentService = trimmed.slice(0, -1);
1059
1084
  currentIndent = line.match(/^(\s*)/)[1];
1060
1085
  }
1061
1086
 
1062
- // Check for ports section
1087
+ // Check for ports section - we'll convert this to expose
1063
1088
  if (trimmed === 'ports:') {
1064
1089
  inPortsSection = true;
1090
+ portsLineIndex = i;
1091
+ portsIndent = line.match(/^(\s*)/)[1];
1092
+ // Change "ports:" to "expose:"
1093
+ fixed[i] = line.replace('ports:', 'expose:');
1094
+ fixes.push(`${currentService}: Changed "ports:" to "expose:" for Traefik compatibility`);
1065
1095
  continue;
1066
1096
  }
1067
1097
 
1068
- // Process port entries
1098
+ // Process port entries - convert "HOST:CONTAINER" to just "CONTAINER"
1069
1099
  if (inPortsSection && trimmed.startsWith('- ')) {
1070
1100
  const portEntry = trimmed.slice(2).replace(/['"]/g, '');
1071
1101
 
1072
1102
  if (portEntry.includes(':')) {
1103
+ // Has host:container mapping - extract just the container port
1073
1104
  const [external, internal] = portEntry.split(':').map(p => p.trim());
1074
- const webPorts = ['80', '8080', '3000', '4000', '5000', '8000', '9000'];
1105
+ const webPorts = ['80', '8080', '3000', '4000', '5000', '8000', '9000', '4567', '8888'];
1075
1106
 
1076
- if (webPorts.includes(internal) && external !== '80') {
1107
+ if (webPorts.includes(internal)) {
1077
1108
  issues.push({
1078
1109
  line: i + 1,
1079
1110
  service: currentService,
1080
- issue: `Port mapping "${external}:${internal}" not allowed`,
1081
- fix: `Use "80:${internal}" instead`
1111
+ issue: `Port mapping "${external}:${internal}" binds to host port`,
1112
+ fix: `Use expose: "${internal}" for Traefik routing`
1082
1113
  });
1083
1114
 
1084
- // Fix the line - preserve internal port
1085
- fixed[i] = line.replace(trimmed, `- "80:${internal}"`);
1086
- fixes.push(`Fixed ${currentService}: ${external}:${internal} → 80:${internal}`);
1115
+ // Fix the line - use only internal port for expose
1116
+ fixed[i] = line.replace(trimmed, `- "${internal}"`);
1117
+ fixes.push(`${currentService}: ${external}:${internal} → expose: ${internal}`);
1087
1118
  }
1088
1119
  }
1120
+ // If it's just a single port (no colon), leave it as-is for expose
1089
1121
  } else if (inPortsSection && !trimmed.startsWith('-')) {
1090
1122
  inPortsSection = false;
1091
1123
  }
@@ -1100,6 +1132,35 @@ function validateAndFixDockerCompose(content) {
1100
1132
  };
1101
1133
  }
1102
1134
 
1135
+ // Randomize volume names to prevent collisions across deployments
1136
+ function randomizeVolumeNames(content, suffix) {
1137
+ // Common volume patterns to look for
1138
+ const volumePatterns = [
1139
+ 'mariadb-data', 'mysql-data', 'postgres-data', 'postgresql-data',
1140
+ 'mongo-data', 'mongodb-data', 'redis-data', 'cassandra-data',
1141
+ 'elasticsearch-data', 'rabbitmq-data', 'kafka-data', 'cockroach-data',
1142
+ 'db-data', 'database-data', 'app-data', 'web-data', 'cache-data'
1143
+ ];
1144
+
1145
+ let result = content;
1146
+ let randomized = false;
1147
+
1148
+ for (const volumeName of volumePatterns) {
1149
+ // Check if this volume is used
1150
+ if (result.includes(volumeName)) {
1151
+ // Replace in volume declarations (e.g., "mariadb-data:")
1152
+ result = result.split(volumeName + ':').join(volumeName + '-' + suffix + ':');
1153
+ // Replace in volume references (e.g., "mariadb-data:/var/lib/mysql")
1154
+ result = result.split(volumeName + ':/').join(volumeName + '-' + suffix + ':/');
1155
+ // Replace in top-level volume declarations (with newline)
1156
+ result = result.split(' ' + volumeName + '\n').join(' ' + volumeName + '-' + suffix + '\n');
1157
+ randomized = true;
1158
+ }
1159
+ }
1160
+
1161
+ return { content: result, randomized };
1162
+ }
1163
+
1103
1164
  // Validate Dockerfile for Coolify compliance
1104
1165
  function validateDockerfile(content) {
1105
1166
  const lines = content.split('\n');
@@ -1476,12 +1537,23 @@ async function initProject(args) {
1476
1537
  log.info('MCP >>> [initProject] Pushing to GitLab...');
1477
1538
  log.debug('MCP >>> [initProject] Git remote URL:', gitUrl);
1478
1539
  gitSteps.push('Pushing to GitLab to trigger deployment');
1479
- console.error('Executing: git push -u mlgym main');
1540
+
1541
+ // Detect the current branch name (could be main or master)
1542
+ let branchName = 'main';
1543
+ try {
1544
+ const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: absolutePath });
1545
+ branchName = branchOutput.trim();
1546
+ log.info(`MCP >>> [initProject] Detected branch: ${branchName}`);
1547
+ } catch (branchError) {
1548
+ log.warning(`MCP >>> [initProject] Could not detect branch, using default: ${branchName}`);
1549
+ }
1550
+
1551
+ console.error(`Executing: git push -u mlgym ${branchName}`);
1480
1552
  const authInfo = await loadAuth();
1481
1553
  const sanitizedEmail = authInfo.email.replace('@', '_at_').replace(/[^a-zA-Z0-9_-]/g, '_');
1482
1554
  const sshKeyPath = path.join(os.homedir(), '.ssh', `mlgym_${sanitizedEmail}`);
1483
1555
  const gitSshCmd = `ssh -i "${sshKeyPath}"`;
1484
- await execAsync('git push -u mlgym main', { cwd: absolutePath, env: { ...process.env, GIT_SSH_COMMAND: gitSshCmd } });
1556
+ await execAsync(`git push -u mlgym ${branchName}`, { cwd: absolutePath, env: { ...process.env, GIT_SSH_COMMAND: gitSshCmd } });
1485
1557
  gitSteps.push('✅ Successfully pushed to GitLab');
1486
1558
  pushSucceeded = true;
1487
1559
  log.success('MCP >>> [initProject] ✅ Git push completed successfully!');
@@ -1794,6 +1866,10 @@ class DeploymentWorkflow {
1794
1866
  const auth = await loadAuth();
1795
1867
  if (auth.token) {
1796
1868
  this.authToken = auth.token;
1869
+
1870
+ // Also ensure SSH key exists locally and in GitLab
1871
+ await this.ensureSSHKey(auth.email);
1872
+
1797
1873
  this.updateLastStep('completed', 'Using cached authentication');
1798
1874
  return { authenticated: true, cached: true };
1799
1875
  }
@@ -1820,6 +1896,43 @@ class DeploymentWorkflow {
1820
1896
  }
1821
1897
  }
1822
1898
 
1899
+ // Ensure SSH key exists locally and is registered with GitLab
1900
+ async ensureSSHKey(email) {
1901
+ if (!email) return;
1902
+
1903
+ const sanitizedEmail = email.replace('@', '_at_').replace(/[^a-zA-Z0-9_-]/g, '_');
1904
+ const sshKeyPath = path.join(os.homedir(), '.ssh', `mlgym_${sanitizedEmail}`);
1905
+
1906
+ // Check if SSH key exists locally
1907
+ let keyExists = false;
1908
+ try {
1909
+ await fs.access(sshKeyPath);
1910
+ keyExists = true;
1911
+ log.info(`MCP >>> SSH key exists at ${sshKeyPath}`);
1912
+ } catch {
1913
+ log.info('MCP >>> SSH key does not exist locally, generating...');
1914
+ }
1915
+
1916
+ if (!keyExists) {
1917
+ // Generate SSH key pair
1918
+ const { publicKey, privateKeyPath } = await generateSSHKeyPair(email);
1919
+ log.success(`MCP >>> SSH key generated at ${privateKeyPath}`);
1920
+
1921
+ // Register key with GitLab via API
1922
+ const keyTitle = `mlgym-${new Date().toISOString().split('T')[0]}`;
1923
+ const keyResult = await apiRequest('POST', '/api/v1/keys', {
1924
+ title: keyTitle,
1925
+ key: publicKey
1926
+ }, true);
1927
+
1928
+ if (keyResult.success) {
1929
+ log.success('MCP >>> SSH key registered with GitLab');
1930
+ } else {
1931
+ log.warning(`MCP >>> Failed to register SSH key: ${keyResult.error}`);
1932
+ }
1933
+ }
1934
+ }
1935
+
1823
1936
  async analyzeProject(local_path) {
1824
1937
  this.currentStep = 'analysis';
1825
1938
  this.addStep('project_analysis', 'running');
@@ -1956,8 +2069,8 @@ async function deployProject(args) {
1956
2069
  const {
1957
2070
  email,
1958
2071
  password,
1959
- project_name,
1960
- project_description,
2072
+ project_name: argProjectName,
2073
+ project_description: argProjectDescription,
1961
2074
  hostname,
1962
2075
  local_path = '.',
1963
2076
  project_type,
@@ -1965,8 +2078,17 @@ async function deployProject(args) {
1965
2078
  package_manager = 'npm'
1966
2079
  } = args;
1967
2080
 
2081
+ // Derive project name from local_path if not provided
2082
+ const resolvedPath = path.resolve(local_path);
2083
+ const derivedName = path.basename(resolvedPath);
2084
+ const project_name = argProjectName || derivedName;
2085
+ const project_description = argProjectDescription || `${project_name} - deployed via MLGym MCP`;
2086
+
1968
2087
  log.info('MCP >>> Starting deployment workflow');
1969
2088
  log.debug('MCP >>> Arguments:', { project_name, hostname, local_path, project_type, framework, package_manager });
2089
+ if (!argProjectName) {
2090
+ log.info(`MCP >>> Derived project name from path: ${project_name}`);
2091
+ }
1970
2092
 
1971
2093
  try {
1972
2094
  // Step 1: Ensure authenticated
@@ -2018,7 +2140,7 @@ async function deployProject(args) {
2018
2140
 
2019
2141
  // Step 4.5: Validate and fix deployment files for Coolify compliance
2020
2142
  log.info('MCP >>> STEP 4.5: Validating deployment configuration...');
2021
- const strategy = detectDeploymentStrategy(local_path);
2143
+ const strategy = await detectDeploymentStrategy(local_path);
2022
2144
 
2023
2145
  if (strategy.type === 'dockercompose') {
2024
2146
  const composePath = path.join(local_path, 'docker-compose.yml');
@@ -2026,7 +2148,15 @@ async function deployProject(args) {
2026
2148
  const actualPath = fsSync.existsSync(composePath) ? composePath : composePathYAML;
2027
2149
 
2028
2150
  if (fsSync.existsSync(actualPath)) {
2029
- const content = fsSync.readFileSync(actualPath, 'utf8');
2151
+ let content = fsSync.readFileSync(actualPath, 'utf8');
2152
+ let fileModified = false;
2153
+
2154
+ // Create backup before any modifications
2155
+ const backupPath = actualPath + '.backup';
2156
+ fsSync.writeFileSync(backupPath, content);
2157
+ log.info(`MCP >>> Created backup at ${backupPath}`);
2158
+
2159
+ // Step 1: Validate and fix port mappings
2030
2160
  const validation = validateAndFixDockerCompose(content);
2031
2161
 
2032
2162
  if (!validation.isValid) {
@@ -2037,18 +2167,32 @@ async function deployProject(args) {
2037
2167
 
2038
2168
  // Auto-fix the issues
2039
2169
  log.info('MCP >>> Auto-fixing docker-compose.yml port mappings...');
2040
- fsSync.writeFileSync(actualPath, validation.fixedContent);
2041
-
2042
- // Create backup
2043
- const backupPath = actualPath + '.backup';
2044
- fsSync.writeFileSync(backupPath, content);
2045
- log.info(`MCP >>> Created backup at ${backupPath}`);
2170
+ content = validation.fixedContent;
2171
+ fileModified = true;
2046
2172
 
2047
2173
  log.success('MCP >>> Fixed docker-compose.yml:');
2048
2174
  validation.fixes.forEach(fix => log.success(` - ${fix}`));
2049
2175
  } else {
2050
2176
  log.success('MCP >>> Docker-compose.yml is Coolify compliant');
2051
2177
  }
2178
+
2179
+ // Step 2: Randomize volume names to prevent collisions across deployments
2180
+ // Generate unique suffix from timestamp + random chars
2181
+ const volumeSuffix = Date.now().toString(36) + Math.random().toString(36).substr(2, 4);
2182
+ const volumeResult = randomizeVolumeNames(content, volumeSuffix);
2183
+
2184
+ if (volumeResult.randomized) {
2185
+ log.info(`MCP >>> Randomizing volume names with suffix: ${volumeSuffix}`);
2186
+ content = volumeResult.content;
2187
+ fileModified = true;
2188
+ log.success('MCP >>> Volume names randomized to prevent collisions');
2189
+ }
2190
+
2191
+ // Write the modified content if anything changed
2192
+ if (fileModified) {
2193
+ fsSync.writeFileSync(actualPath, content);
2194
+ log.info('MCP >>> Saved modified docker-compose.yml');
2195
+ }
2052
2196
  }
2053
2197
  } else if (strategy.type === 'dockerfile') {
2054
2198
  const dockerfilePath = path.join(local_path, 'Dockerfile');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mlgym-deploy",
3
- "version": "3.3.6",
3
+ "version": "3.3.14",
4
4
  "description": "MCP server for MLGym - Complete deployment management: deploy, configure, monitor, and rollback applications",
5
5
  "main": "index.js",
6
6
  "type": "module",