mlgym-deploy 3.3.6 → 3.3.13

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 +154 -22
  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
@@ -1100,6 +1120,35 @@ function validateAndFixDockerCompose(content) {
1100
1120
  };
1101
1121
  }
1102
1122
 
1123
+ // Randomize volume names to prevent collisions across deployments
1124
+ function randomizeVolumeNames(content, suffix) {
1125
+ // Common volume patterns to look for
1126
+ const volumePatterns = [
1127
+ 'mariadb-data', 'mysql-data', 'postgres-data', 'postgresql-data',
1128
+ 'mongo-data', 'mongodb-data', 'redis-data', 'cassandra-data',
1129
+ 'elasticsearch-data', 'rabbitmq-data', 'kafka-data', 'cockroach-data',
1130
+ 'db-data', 'database-data', 'app-data', 'web-data', 'cache-data'
1131
+ ];
1132
+
1133
+ let result = content;
1134
+ let randomized = false;
1135
+
1136
+ for (const volumeName of volumePatterns) {
1137
+ // Check if this volume is used
1138
+ if (result.includes(volumeName)) {
1139
+ // Replace in volume declarations (e.g., "mariadb-data:")
1140
+ result = result.split(volumeName + ':').join(volumeName + '-' + suffix + ':');
1141
+ // Replace in volume references (e.g., "mariadb-data:/var/lib/mysql")
1142
+ result = result.split(volumeName + ':/').join(volumeName + '-' + suffix + ':/');
1143
+ // Replace in top-level volume declarations (with newline)
1144
+ result = result.split(' ' + volumeName + '\n').join(' ' + volumeName + '-' + suffix + '\n');
1145
+ randomized = true;
1146
+ }
1147
+ }
1148
+
1149
+ return { content: result, randomized };
1150
+ }
1151
+
1103
1152
  // Validate Dockerfile for Coolify compliance
1104
1153
  function validateDockerfile(content) {
1105
1154
  const lines = content.split('\n');
@@ -1476,12 +1525,23 @@ async function initProject(args) {
1476
1525
  log.info('MCP >>> [initProject] Pushing to GitLab...');
1477
1526
  log.debug('MCP >>> [initProject] Git remote URL:', gitUrl);
1478
1527
  gitSteps.push('Pushing to GitLab to trigger deployment');
1479
- console.error('Executing: git push -u mlgym main');
1528
+
1529
+ // Detect the current branch name (could be main or master)
1530
+ let branchName = 'main';
1531
+ try {
1532
+ const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: absolutePath });
1533
+ branchName = branchOutput.trim();
1534
+ log.info(`MCP >>> [initProject] Detected branch: ${branchName}`);
1535
+ } catch (branchError) {
1536
+ log.warning(`MCP >>> [initProject] Could not detect branch, using default: ${branchName}`);
1537
+ }
1538
+
1539
+ console.error(`Executing: git push -u mlgym ${branchName}`);
1480
1540
  const authInfo = await loadAuth();
1481
1541
  const sanitizedEmail = authInfo.email.replace('@', '_at_').replace(/[^a-zA-Z0-9_-]/g, '_');
1482
1542
  const sshKeyPath = path.join(os.homedir(), '.ssh', `mlgym_${sanitizedEmail}`);
1483
1543
  const gitSshCmd = `ssh -i "${sshKeyPath}"`;
1484
- await execAsync('git push -u mlgym main', { cwd: absolutePath, env: { ...process.env, GIT_SSH_COMMAND: gitSshCmd } });
1544
+ await execAsync(`git push -u mlgym ${branchName}`, { cwd: absolutePath, env: { ...process.env, GIT_SSH_COMMAND: gitSshCmd } });
1485
1545
  gitSteps.push('✅ Successfully pushed to GitLab');
1486
1546
  pushSucceeded = true;
1487
1547
  log.success('MCP >>> [initProject] ✅ Git push completed successfully!');
@@ -1794,6 +1854,10 @@ class DeploymentWorkflow {
1794
1854
  const auth = await loadAuth();
1795
1855
  if (auth.token) {
1796
1856
  this.authToken = auth.token;
1857
+
1858
+ // Also ensure SSH key exists locally and in GitLab
1859
+ await this.ensureSSHKey(auth.email);
1860
+
1797
1861
  this.updateLastStep('completed', 'Using cached authentication');
1798
1862
  return { authenticated: true, cached: true };
1799
1863
  }
@@ -1820,6 +1884,43 @@ class DeploymentWorkflow {
1820
1884
  }
1821
1885
  }
1822
1886
 
1887
+ // Ensure SSH key exists locally and is registered with GitLab
1888
+ async ensureSSHKey(email) {
1889
+ if (!email) return;
1890
+
1891
+ const sanitizedEmail = email.replace('@', '_at_').replace(/[^a-zA-Z0-9_-]/g, '_');
1892
+ const sshKeyPath = path.join(os.homedir(), '.ssh', `mlgym_${sanitizedEmail}`);
1893
+
1894
+ // Check if SSH key exists locally
1895
+ let keyExists = false;
1896
+ try {
1897
+ await fs.access(sshKeyPath);
1898
+ keyExists = true;
1899
+ log.info(`MCP >>> SSH key exists at ${sshKeyPath}`);
1900
+ } catch {
1901
+ log.info('MCP >>> SSH key does not exist locally, generating...');
1902
+ }
1903
+
1904
+ if (!keyExists) {
1905
+ // Generate SSH key pair
1906
+ const { publicKey, privateKeyPath } = await generateSSHKeyPair(email);
1907
+ log.success(`MCP >>> SSH key generated at ${privateKeyPath}`);
1908
+
1909
+ // Register key with GitLab via API
1910
+ const keyTitle = `mlgym-${new Date().toISOString().split('T')[0]}`;
1911
+ const keyResult = await apiRequest('POST', '/api/v1/keys', {
1912
+ title: keyTitle,
1913
+ key: publicKey
1914
+ }, true);
1915
+
1916
+ if (keyResult.success) {
1917
+ log.success('MCP >>> SSH key registered with GitLab');
1918
+ } else {
1919
+ log.warning(`MCP >>> Failed to register SSH key: ${keyResult.error}`);
1920
+ }
1921
+ }
1922
+ }
1923
+
1823
1924
  async analyzeProject(local_path) {
1824
1925
  this.currentStep = 'analysis';
1825
1926
  this.addStep('project_analysis', 'running');
@@ -1956,8 +2057,8 @@ async function deployProject(args) {
1956
2057
  const {
1957
2058
  email,
1958
2059
  password,
1959
- project_name,
1960
- project_description,
2060
+ project_name: argProjectName,
2061
+ project_description: argProjectDescription,
1961
2062
  hostname,
1962
2063
  local_path = '.',
1963
2064
  project_type,
@@ -1965,8 +2066,17 @@ async function deployProject(args) {
1965
2066
  package_manager = 'npm'
1966
2067
  } = args;
1967
2068
 
2069
+ // Derive project name from local_path if not provided
2070
+ const resolvedPath = path.resolve(local_path);
2071
+ const derivedName = path.basename(resolvedPath);
2072
+ const project_name = argProjectName || derivedName;
2073
+ const project_description = argProjectDescription || `${project_name} - deployed via MLGym MCP`;
2074
+
1968
2075
  log.info('MCP >>> Starting deployment workflow');
1969
2076
  log.debug('MCP >>> Arguments:', { project_name, hostname, local_path, project_type, framework, package_manager });
2077
+ if (!argProjectName) {
2078
+ log.info(`MCP >>> Derived project name from path: ${project_name}`);
2079
+ }
1970
2080
 
1971
2081
  try {
1972
2082
  // Step 1: Ensure authenticated
@@ -2018,7 +2128,7 @@ async function deployProject(args) {
2018
2128
 
2019
2129
  // Step 4.5: Validate and fix deployment files for Coolify compliance
2020
2130
  log.info('MCP >>> STEP 4.5: Validating deployment configuration...');
2021
- const strategy = detectDeploymentStrategy(local_path);
2131
+ const strategy = await detectDeploymentStrategy(local_path);
2022
2132
 
2023
2133
  if (strategy.type === 'dockercompose') {
2024
2134
  const composePath = path.join(local_path, 'docker-compose.yml');
@@ -2026,7 +2136,15 @@ async function deployProject(args) {
2026
2136
  const actualPath = fsSync.existsSync(composePath) ? composePath : composePathYAML;
2027
2137
 
2028
2138
  if (fsSync.existsSync(actualPath)) {
2029
- const content = fsSync.readFileSync(actualPath, 'utf8');
2139
+ let content = fsSync.readFileSync(actualPath, 'utf8');
2140
+ let fileModified = false;
2141
+
2142
+ // Create backup before any modifications
2143
+ const backupPath = actualPath + '.backup';
2144
+ fsSync.writeFileSync(backupPath, content);
2145
+ log.info(`MCP >>> Created backup at ${backupPath}`);
2146
+
2147
+ // Step 1: Validate and fix port mappings
2030
2148
  const validation = validateAndFixDockerCompose(content);
2031
2149
 
2032
2150
  if (!validation.isValid) {
@@ -2037,18 +2155,32 @@ async function deployProject(args) {
2037
2155
 
2038
2156
  // Auto-fix the issues
2039
2157
  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}`);
2158
+ content = validation.fixedContent;
2159
+ fileModified = true;
2046
2160
 
2047
2161
  log.success('MCP >>> Fixed docker-compose.yml:');
2048
2162
  validation.fixes.forEach(fix => log.success(` - ${fix}`));
2049
2163
  } else {
2050
2164
  log.success('MCP >>> Docker-compose.yml is Coolify compliant');
2051
2165
  }
2166
+
2167
+ // Step 2: Randomize volume names to prevent collisions across deployments
2168
+ // Generate unique suffix from timestamp + random chars
2169
+ const volumeSuffix = Date.now().toString(36) + Math.random().toString(36).substr(2, 4);
2170
+ const volumeResult = randomizeVolumeNames(content, volumeSuffix);
2171
+
2172
+ if (volumeResult.randomized) {
2173
+ log.info(`MCP >>> Randomizing volume names with suffix: ${volumeSuffix}`);
2174
+ content = volumeResult.content;
2175
+ fileModified = true;
2176
+ log.success('MCP >>> Volume names randomized to prevent collisions');
2177
+ }
2178
+
2179
+ // Write the modified content if anything changed
2180
+ if (fileModified) {
2181
+ fsSync.writeFileSync(actualPath, content);
2182
+ log.info('MCP >>> Saved modified docker-compose.yml');
2183
+ }
2052
2184
  }
2053
2185
  } else if (strategy.type === 'dockerfile') {
2054
2186
  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.13",
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",