mlgym-deploy 3.2.0 → 3.2.3

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 CHANGED
@@ -17,7 +17,7 @@ import crypto from 'crypto';
17
17
  const execAsync = promisify(exec);
18
18
 
19
19
  // Current version of this MCP server - INCREMENT FOR WORKFLOW FIXES
20
- const CURRENT_VERSION = '3.2.0'; // Added mlgym_deploy_logs tool for viewing deployment history
20
+ const CURRENT_VERSION = '3.2.2'; // Added automatic validation and fixing for docker-compose and Dockerfile
21
21
  const PACKAGE_NAME = 'mlgym-deploy';
22
22
 
23
23
  // Debug logging configuration - ENABLED BY DEFAULT
@@ -1020,6 +1020,115 @@ async function detectDeploymentStrategy(projectPath) {
1020
1020
  return { type: 'none', reason: 'No Dockerfile or docker-compose.yml found' };
1021
1021
  }
1022
1022
 
1023
+ // Validate and potentially fix docker-compose.yml for Coolify compliance
1024
+ function validateAndFixDockerCompose(content) {
1025
+ const lines = content.split('\n');
1026
+ const issues = [];
1027
+ const fixes = [];
1028
+ let fixed = [...lines];
1029
+ let inServicesSection = false;
1030
+ let inPortsSection = false;
1031
+ let currentService = '';
1032
+ let currentIndent = '';
1033
+
1034
+ for (let i = 0; i < lines.length; i++) {
1035
+ const line = lines[i];
1036
+ const trimmed = line.trim();
1037
+
1038
+ // Detect services section
1039
+ if (trimmed === 'services:') {
1040
+ inServicesSection = true;
1041
+ continue;
1042
+ }
1043
+
1044
+ if (!inServicesSection) continue;
1045
+
1046
+ // Check for service definition
1047
+ if (trimmed && !line.startsWith(' ') && !line.startsWith('\t')) {
1048
+ inServicesSection = false;
1049
+ break;
1050
+ }
1051
+
1052
+ // New service
1053
+ if ((line.startsWith(' ') || line.startsWith('\t')) &&
1054
+ trimmed.endsWith(':') &&
1055
+ !trimmed.startsWith('-') &&
1056
+ !['ports:', 'expose:', 'environment:', 'volumes:', 'networks:'].includes(trimmed)) {
1057
+ currentService = trimmed.slice(0, -1);
1058
+ currentIndent = line.match(/^(\s*)/)[1];
1059
+ }
1060
+
1061
+ // Check for ports section
1062
+ if (trimmed === 'ports:') {
1063
+ inPortsSection = true;
1064
+ continue;
1065
+ }
1066
+
1067
+ // Process port entries
1068
+ if (inPortsSection && trimmed.startsWith('- ')) {
1069
+ const portEntry = trimmed.slice(2).replace(/['"]/g, '');
1070
+
1071
+ if (portEntry.includes(':')) {
1072
+ const [external, internal] = portEntry.split(':').map(p => p.trim());
1073
+ const webPorts = ['80', '8080', '3000', '4000', '5000', '8000', '9000'];
1074
+
1075
+ if (webPorts.includes(internal) && external !== '80') {
1076
+ issues.push({
1077
+ line: i + 1,
1078
+ service: currentService,
1079
+ issue: `Port mapping "${external}:${internal}" not allowed`,
1080
+ fix: `Use "80:${internal}" instead`
1081
+ });
1082
+
1083
+ // Fix the line - preserve internal port
1084
+ fixed[i] = line.replace(trimmed, `- "80:${internal}"`);
1085
+ fixes.push(`Fixed ${currentService}: ${external}:${internal} → 80:${internal}`);
1086
+ }
1087
+ }
1088
+ } else if (inPortsSection && !trimmed.startsWith('-')) {
1089
+ inPortsSection = false;
1090
+ }
1091
+ }
1092
+
1093
+ return {
1094
+ isValid: issues.length === 0,
1095
+ issues,
1096
+ fixes,
1097
+ fixedContent: fixed.join('\n'),
1098
+ hasChanges: fixes.length > 0
1099
+ };
1100
+ }
1101
+
1102
+ // Validate Dockerfile for Coolify compliance
1103
+ function validateDockerfile(content) {
1104
+ const lines = content.split('\n');
1105
+ const issues = [];
1106
+ let hasExpose80 = false;
1107
+
1108
+ for (let i = 0; i < lines.length; i++) {
1109
+ const trimmed = lines[i].trim().toUpperCase();
1110
+
1111
+ if (trimmed.startsWith('EXPOSE')) {
1112
+ // Check if it exposes port 80
1113
+ if (trimmed.includes('80') && !trimmed.includes('8080') && !trimmed.includes('8443')) {
1114
+ hasExpose80 = true;
1115
+ }
1116
+ }
1117
+ }
1118
+
1119
+ if (!hasExpose80) {
1120
+ issues.push({
1121
+ issue: 'Dockerfile does not EXPOSE port 80',
1122
+ fix: 'Add "EXPOSE 80" to your Dockerfile'
1123
+ });
1124
+ }
1125
+
1126
+ return {
1127
+ isValid: issues.length === 0,
1128
+ issues
1129
+ };
1130
+ }
1131
+
1023
1132
  // Analyze docker-compose.yml to determine deployment strategy
1024
1133
  async function analyzeComposeFile(composePath) {
1025
1134
  const content = await fs.readFile(composePath, 'utf-8');
@@ -1186,6 +1295,32 @@ async function initProject(args) {
1186
1295
  projectData.hostname = hostname;
1187
1296
  projectData.local_path = local_path;
1188
1297
 
1298
+ // Read docker-compose content if using docker-compose strategy (v3.2.1+)
1299
+ if (strategy.type === 'docker-compose') {
1300
+ const composeFiles = ['docker-compose.yml', 'docker-compose.yaml'];
1301
+ let composeContent = null;
1302
+
1303
+ for (const filename of composeFiles) {
1304
+ const composePath = path.join(local_path, filename);
1305
+ if (fs.existsSync(composePath)) {
1306
+ try {
1307
+ composeContent = fs.readFileSync(composePath, 'utf8');
1308
+ log.info(`MCP >>> [initProject] Read ${filename}: ${composeContent.length} bytes`);
1309
+ break;
1310
+ } catch (err) {
1311
+ log.error(`MCP >>> [initProject] Error reading ${filename}:`, err.message);
1312
+ }
1313
+ }
1314
+ }
1315
+
1316
+ if (composeContent) {
1317
+ projectData.docker_compose_content = composeContent;
1318
+ log.info(`MCP >>> [initProject] Sending docker-compose content: ${composeContent.length} bytes`);
1319
+ } else {
1320
+ log.warn('MCP >>> [initProject] docker-compose strategy but no compose file found!');
1321
+ }
1322
+ }
1323
+
1189
1324
  log.info(`MCP >>> [initProject] Sending deployment_type: ${strategy.type} to backend`);
1190
1325
  }
1191
1326
 
@@ -1872,6 +2007,83 @@ async function deployProject(args) {
1872
2007
  log.success('MCP >>> STEP 4: Project preparation complete');
1873
2008
  log.debug('MCP >>> Preparation actions:', prepResult);
1874
2009
 
2010
+ // Step 4.5: Validate and fix deployment files for Coolify compliance
2011
+ log.info('MCP >>> STEP 4.5: Validating deployment configuration...');
2012
+ const strategy = detectDeploymentStrategy(local_path);
2013
+
2014
+ if (strategy.type === 'docker-compose') {
2015
+ const composePath = path.join(local_path, 'docker-compose.yml');
2016
+ const composePathYAML = path.join(local_path, 'docker-compose.yaml');
2017
+ const actualPath = fs.existsSync(composePath) ? composePath : composePathYAML;
2018
+
2019
+ if (fs.existsSync(actualPath)) {
2020
+ const content = fs.readFileSync(actualPath, 'utf8');
2021
+ const validation = validateAndFixDockerCompose(content);
2022
+
2023
+ if (!validation.isValid) {
2024
+ log.warning('MCP >>> Docker-compose validation issues found:');
2025
+ validation.issues.forEach(issue => {
2026
+ log.warning(` - Line ${issue.line}: ${issue.issue} (${issue.fix})`);
2027
+ });
2028
+
2029
+ // Auto-fix the issues
2030
+ log.info('MCP >>> Auto-fixing docker-compose.yml port mappings...');
2031
+ fs.writeFileSync(actualPath, validation.fixedContent);
2032
+
2033
+ // Create backup
2034
+ const backupPath = actualPath + '.backup';
2035
+ fs.writeFileSync(backupPath, content);
2036
+ log.info(`MCP >>> Created backup at ${backupPath}`);
2037
+
2038
+ log.success('MCP >>> Fixed docker-compose.yml:');
2039
+ validation.fixes.forEach(fix => log.success(` - ${fix}`));
2040
+ } else {
2041
+ log.success('MCP >>> Docker-compose.yml is Coolify compliant');
2042
+ }
2043
+ }
2044
+ } else if (strategy.type === 'dockerfile') {
2045
+ const dockerfilePath = path.join(local_path, 'Dockerfile');
2046
+
2047
+ if (fs.existsSync(dockerfilePath)) {
2048
+ const content = fs.readFileSync(dockerfilePath, 'utf8');
2049
+ const validation = validateDockerfile(content);
2050
+
2051
+ if (!validation.isValid) {
2052
+ log.warning('MCP >>> Dockerfile validation issues:');
2053
+ validation.issues.forEach(issue => {
2054
+ log.warning(` - ${issue.issue}`);
2055
+ log.info(` Fix: ${issue.fix}`);
2056
+ });
2057
+
2058
+ // For Dockerfile, we'll add EXPOSE 80 if missing
2059
+ if (!content.includes('EXPOSE 80')) {
2060
+ log.info('MCP >>> Auto-fixing Dockerfile: adding EXPOSE 80...');
2061
+ const lines = content.split('\n');
2062
+
2063
+ // Find the last FROM or WORKDIR line to add EXPOSE after it
2064
+ let insertIndex = lines.length - 1;
2065
+ for (let i = lines.length - 1; i >= 0; i--) {
2066
+ if (lines[i].trim().startsWith('WORKDIR') || lines[i].trim().startsWith('FROM')) {
2067
+ insertIndex = i + 1;
2068
+ break;
2069
+ }
2070
+ }
2071
+
2072
+ lines.splice(insertIndex, 0, '', 'EXPOSE 80');
2073
+ const fixedContent = lines.join('\n');
2074
+
2075
+ // Create backup
2076
+ fs.writeFileSync(dockerfilePath + '.backup', content);
2077
+ fs.writeFileSync(dockerfilePath, fixedContent);
2078
+
2079
+ log.success('MCP >>> Fixed Dockerfile: added EXPOSE 80');
2080
+ }
2081
+ } else {
2082
+ log.success('MCP >>> Dockerfile is Coolify compliant');
2083
+ }
2084
+ }
2085
+ }
2086
+
1875
2087
  // Step 5: Create GitLab project and deploy
1876
2088
  log.info('MCP >>> STEP 5: Creating GitLab project and deploying...');
1877
2089
  log.debug('MCP >>> Project params:', { name: project_name, hostname: hostname || project_name, local_path });
@@ -0,0 +1,189 @@
1
+ // MCP Patch: Add docker-compose.yml content reading (matching CLI v2.0.14+)
2
+ //
3
+ // This patch ensures MCP reads docker-compose.yml content and sends it to the backend
4
+ // just like the CLI does, so the backend can store it in Coolify's database.
5
+
6
+ // Add this function after detectDeploymentStrategy function:
7
+ function readDockerComposeContent(projectPath) {
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ // Check both .yml and .yaml extensions
12
+ const composeFiles = ['docker-compose.yml', 'docker-compose.yaml'];
13
+
14
+ for (const filename of composeFiles) {
15
+ const composePath = path.join(projectPath, filename);
16
+ if (fs.existsSync(composePath)) {
17
+ try {
18
+ const content = fs.readFileSync(composePath, 'utf8');
19
+ console.log(`[MCP] Read ${filename}: ${content.length} bytes`);
20
+ return content;
21
+ } catch (err) {
22
+ console.error(`[MCP] Error reading ${filename}:`, err.message);
23
+ }
24
+ }
25
+ }
26
+
27
+ return null;
28
+ }
29
+
30
+ // MODIFICATION NEEDED in initProject function (around line 1180-1190):
31
+ //
32
+ // FIND THIS BLOCK:
33
+ // projectData.deployment_type = strategy.type; // ← NEW: Send detected type!
34
+ // log.info(`MCP >>> [initProject] Sending deployment_type: ${strategy.type} to backend`);
35
+ //
36
+ // ADD AFTER IT:
37
+ // // Read docker-compose content if using docker-compose strategy
38
+ // if (strategy.type === 'docker-compose') {
39
+ // const composeContent = readDockerComposeContent(projectPath);
40
+ // if (composeContent) {
41
+ // projectData.docker_compose_content = composeContent;
42
+ // log.info(`MCP >>> [initProject] Sending docker-compose content: ${composeContent.length} bytes`);
43
+ // } else {
44
+ // log.warn('MCP >>> [initProject] docker-compose strategy but no compose file found!');
45
+ // }
46
+ // }
47
+
48
+ // The complete section should look like:
49
+ /*
50
+ // CRITICAL: Send deployment_type so backend doesn't try to detect from empty repo
51
+ log.info(`MCP >>> [initProject] Using strategy: ${JSON.stringify(strategy)}`);
52
+ projectData.deployment_type = strategy.type; // ← NEW: Send detected type!
53
+
54
+ // Read docker-compose content if using docker-compose strategy (v3.2.1+)
55
+ if (strategy.type === 'docker-compose') {
56
+ const composeContent = readDockerComposeContent(projectPath);
57
+ if (composeContent) {
58
+ projectData.docker_compose_content = composeContent;
59
+ log.info(`MCP >>> [initProject] Sending docker-compose content: ${composeContent.length} bytes`);
60
+ } else {
61
+ log.warn('MCP >>> [initProject] docker-compose strategy but no compose file found!');
62
+ }
63
+ }
64
+
65
+ log.info(`MCP >>> [initProject] Sending deployment_type: ${strategy.type} to backend`);
66
+ */
67
+
68
+ // Also add validation function:
69
+ function validateDockerCompose(content) {
70
+ const issues = [];
71
+
72
+ try {
73
+ // Parse YAML to check structure
74
+ const lines = content.split('\n');
75
+
76
+ // Check for 'ports' vs 'expose'
77
+ const hasPortsMapping = content.includes('ports:');
78
+ const hasExposeOnly = content.includes('expose:') && !hasPortsMapping;
79
+
80
+ if (!hasPortsMapping) {
81
+ issues.push({
82
+ level: 'ERROR',
83
+ message: 'No service exposes ports for web traffic',
84
+ suggestion: 'At least one service must have "ports:" configuration for Coolify/Traefik routing'
85
+ });
86
+ }
87
+
88
+ if (hasExposeOnly) {
89
+ issues.push({
90
+ level: 'WARNING',
91
+ message: 'Service uses "expose" instead of "ports"',
92
+ suggestion: 'Change "expose" to "ports" for proper Traefik routing'
93
+ });
94
+ }
95
+
96
+ // Check for hardcoded passwords
97
+ const passwordPatterns = [
98
+ /password:\s*["'](?!.*\$\{).*["']/gi,
99
+ /PASSWORD=(?!.*\$\{)[^$\s]+/gi,
100
+ /secret:\s*["'](?!.*\$\{).*["']/gi
101
+ ];
102
+
103
+ for (const pattern of passwordPatterns) {
104
+ if (pattern.test(content)) {
105
+ issues.push({
106
+ level: 'WARNING',
107
+ message: 'Hardcoded password detected',
108
+ suggestion: 'Use environment variables: PASSWORD=${PASSWORD}'
109
+ });
110
+ break;
111
+ }
112
+ }
113
+
114
+ // Check for absolute volume paths
115
+ if (/volumes:\s*\n\s*-\s*\//m.test(content)) {
116
+ issues.push({
117
+ level: 'WARNING',
118
+ message: 'Absolute host path in volume mount',
119
+ suggestion: 'Use named volumes instead of absolute paths'
120
+ });
121
+ }
122
+
123
+ } catch (err) {
124
+ issues.push({
125
+ level: 'ERROR',
126
+ message: `Invalid docker-compose format: ${err.message}`,
127
+ suggestion: 'Fix YAML syntax errors'
128
+ });
129
+ }
130
+
131
+ return issues;
132
+ }
133
+
134
+ function validateDockerfile(projectPath) {
135
+ const fs = require('fs');
136
+ const path = require('path');
137
+ const dockerfilePath = path.join(projectPath, 'Dockerfile');
138
+
139
+ if (!fs.existsSync(dockerfilePath)) {
140
+ return [];
141
+ }
142
+
143
+ const issues = [];
144
+
145
+ try {
146
+ const content = fs.readFileSync(dockerfilePath, 'utf8');
147
+
148
+ // Check for EXPOSE directive
149
+ if (!content.includes('EXPOSE ')) {
150
+ issues.push({
151
+ level: 'WARNING',
152
+ message: 'Dockerfile has no EXPOSE directive',
153
+ suggestion: 'Add EXPOSE 80 or appropriate port'
154
+ });
155
+ }
156
+
157
+ // Check for USER directive (security)
158
+ if (!content.includes('USER ') || content.includes('USER root')) {
159
+ issues.push({
160
+ level: 'WARNING',
161
+ message: 'Dockerfile runs as root user',
162
+ suggestion: 'Add non-root USER for better security'
163
+ });
164
+ }
165
+
166
+ // Check for WORKDIR
167
+ if (!content.includes('WORKDIR ')) {
168
+ issues.push({
169
+ level: 'INFO',
170
+ message: 'No WORKDIR set in Dockerfile',
171
+ suggestion: 'Consider setting WORKDIR for organization'
172
+ });
173
+ }
174
+
175
+ } catch (err) {
176
+ // Ignore read errors
177
+ }
178
+
179
+ return issues;
180
+ }
181
+
182
+ // Export for testing
183
+ if (typeof module !== 'undefined') {
184
+ module.exports = {
185
+ readDockerComposeContent,
186
+ validateDockerCompose,
187
+ validateDockerfile
188
+ };
189
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mlgym-deploy",
3
- "version": "3.2.0",
4
- "description": "MCP server for MLGym - Added deployment logs viewer tool",
3
+ "version": "3.2.3",
4
+ "description": "MCP server for MLGym - Auto-validates and fixes docker-compose/Dockerfile for Coolify",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
7
  "bin": {
Binary file