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.
- package/index.js +176 -32
- 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.
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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)
|
|
1107
|
+
if (webPorts.includes(internal)) {
|
|
1077
1108
|
issues.push({
|
|
1078
1109
|
line: i + 1,
|
|
1079
1110
|
service: currentService,
|
|
1080
|
-
issue: `Port mapping "${external}:${internal}"
|
|
1081
|
-
fix: `Use "
|
|
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 -
|
|
1085
|
-
fixed[i] = line.replace(trimmed, `- "
|
|
1086
|
-
fixes.push(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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