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.
- package/index.js +154 -22
- 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
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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