diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 2bec34d..617d7c3 100755 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -94,7 +94,8 @@ if [ "$(id -u)" = "0" ]; then chown -R nodejs:nodejs /app/data fi # Switch to nodejs user with proper exec chain for signal propagation - exec su -s /bin/sh nodejs -c "exec $*" + # Preserve environment variables when switching user + exec su -s /bin/sh nodejs -c "export MCP_MODE='$MCP_MODE'; export NODE_DB_PATH='$NODE_DB_PATH'; export AUTH_TOKEN='$AUTH_TOKEN'; export AUTH_TOKEN_FILE='$AUTH_TOKEN_FILE'; exec $*" fi # Handle special commands @@ -105,6 +106,11 @@ if [ "$1" = "n8n-mcp" ] && [ "$2" = "serve" ]; then set -- node /app/dist/mcp/index.js "$@" fi +# Export NODE_DB_PATH so it's visible to child processes +if [ -n "$DB_PATH" ]; then + export NODE_DB_PATH="$DB_PATH" +fi + # Execute the main command directly with exec # This ensures our Node.js process becomes PID 1 and receives signals directly if [ "$MCP_MODE" = "stdio" ]; then diff --git a/docker/n8n-mcp b/docker/n8n-mcp index c328b44..0a175f3 100644 --- a/docker/n8n-mcp +++ b/docker/n8n-mcp @@ -32,6 +32,11 @@ if [ "$1" = "serve" ]; then # Validate remaining arguments validate_args "$@" + # For testing purposes, output the environment variable if requested + if [ "$DEBUG_ENV" = "true" ]; then + echo "MCP_MODE=$MCP_MODE" >&2 + fi + exec node /app/dist/mcp/index.js "$@" else # For non-serve commands, pass through without validation diff --git a/docker/parse-config.js b/docker/parse-config.js index 8d7c273..f118005 100644 --- a/docker/parse-config.js +++ b/docker/parse-config.js @@ -40,6 +40,11 @@ function sanitizeKey(key) { return 'EMPTY_KEY'; } + // Special handling for NODE_DB_PATH to preserve exact casing + if (keyStr === 'NODE_DB_PATH') { + return 'NODE_DB_PATH'; + } + const sanitized = keyStr .toUpperCase() .replace(/[^A-Z0-9]+/g, '_') diff --git a/tests/integration/docker/docker-config.test.ts b/tests/integration/docker/docker-config.test.ts index 1d6b7ef..f1c9358 100644 --- a/tests/integration/docker/docker-config.test.ts +++ b/tests/integration/docker/docker-config.test.ts @@ -1,11 +1,9 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; -import { execSync, spawn, exec as execCallback } from 'child_process'; +import { execSync, spawn } from 'child_process'; import path from 'path'; import fs from 'fs'; import os from 'os'; -import { promisify } from 'util'; - -const exec = promisify(execCallback); +import { exec, waitForHealthy, isRunningInHttpMode, getProcessEnv } from './test-helpers'; // Skip tests if not in CI or if Docker is not available const SKIP_DOCKER_TESTS = process.env.CI !== 'true' && !process.env.RUN_DOCKER_TESTS; @@ -207,14 +205,21 @@ describeDocker('Docker Config File Integration', () => { containers.push(containerName); // Run container with n8n-mcp serve command - // The wrapper script should set MCP_MODE=http when "serve" is used + // Start the container in detached mode + await exec( + `docker run -d --name ${containerName} -e AUTH_TOKEN=test-token -p 13001:3000 ${imageName} n8n-mcp serve` + ); + + // Give it time to start + await new Promise(resolve => setTimeout(resolve, 3000)); + + // Verify it's running in HTTP mode by checking the health endpoint const { stdout } = await exec( - `docker run --name ${containerName} -e AUTH_TOKEN=test-token ${imageName} sh -c "n8n-mcp serve & sleep 2 && ps aux | grep -v grep | grep 'node.*index.js' && echo 'MCP_MODE='\\$MCP_MODE"` + `docker exec ${containerName} curl -s http://localhost:3000/health || echo 'Server not responding'` ); - // Check that the process is running and MCP_MODE is set - expect(stdout).toMatch(/node.*index\.js/); - expect(stdout.trim()).toContain('MCP_MODE=http'); + // If HTTP mode is active, health endpoint should respond + expect(stdout).toContain('ok'); }); it('should preserve additional arguments when using "n8n-mcp serve"', async () => { @@ -263,9 +268,17 @@ describeDocker('Docker Config File Integration', () => { }; fs.writeFileSync(configPath, JSON.stringify(config)); - // Run container and check the environment variable + // Run container in detached mode to check environment after initialization + await exec( + `docker run -d --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName}` + ); + + // Give it time to load config and start + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Check the actual process environment const { stdout } = await exec( - `docker run --name ${containerName} -v "${configPath}:/app/config.json:ro" ${imageName} sh -c "env | grep NODE_DB_PATH"` + `docker exec ${containerName} sh -c "cat /proc/1/environ | tr '\\0' '\\n' | grep NODE_DB_PATH || echo 'NODE_DB_PATH not found'"` ); expect(stdout.trim()).toBe('NODE_DB_PATH=/app/data/custom/custom.db'); diff --git a/tests/integration/docker/docker-entrypoint.test.ts b/tests/integration/docker/docker-entrypoint.test.ts index a622880..7842220 100644 --- a/tests/integration/docker/docker-entrypoint.test.ts +++ b/tests/integration/docker/docker-entrypoint.test.ts @@ -1,11 +1,9 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; -import { exec as execCallback, execSync } from 'child_process'; -import { promisify } from 'util'; +import { execSync } from 'child_process'; import path from 'path'; import fs from 'fs'; import os from 'os'; - -const exec = promisify(execCallback); +import { exec, waitForHealthy, isRunningInHttpMode, getProcessEnv } from './test-helpers'; // Skip tests if not in CI or if Docker is not available const SKIP_DOCKER_TESTS = process.env.CI !== 'true' && !process.env.RUN_DOCKER_TESTS; @@ -183,11 +181,14 @@ describeDocker('Docker Entrypoint Script', () => { expect(psOutput).toContain('node'); expect(psOutput).toContain('/app/dist/mcp/index.js'); - // Check environment variable to confirm HTTP mode - const { stdout: envOutput } = await exec(`docker exec ${containerName} sh -c "env | grep MCP_MODE || echo 'MCP_MODE not set'"`); + // Check that the server is actually running in HTTP mode + // We can verify this by checking if the HTTP server is listening + const { stdout: curlOutput } = await exec( + `docker exec ${containerName} sh -c "curl -s http://localhost:3000/health || echo 'Server not responding'"` + ); - // Should have MCP_MODE=http - expect(envOutput.trim()).toBe('MCP_MODE=http'); + // If running in HTTP mode, the health endpoint should respond + expect(curlOutput).toContain('ok'); } catch (error) { console.error('Test error:', error); throw error; @@ -238,12 +239,19 @@ describeDocker('Docker Entrypoint Script', () => { containers.push(containerName); // Use a path that the nodejs user can create - const { stdout, stderr } = await exec( - `docker run --name ${containerName} -e NODE_DB_PATH=/tmp/custom/test.db ${imageName} sh -c "env | grep NODE_DB_PATH"` + // We need to check the environment inside the running process, not the initial shell + await exec( + `docker run -d --name ${containerName} -e NODE_DB_PATH=/tmp/custom/test.db -e AUTH_TOKEN=test ${imageName}` + ); + + // Give it time to start + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Check the actual process environment + const { stdout } = await exec( + `docker exec ${containerName} sh -c "cat /proc/1/environ | tr '\0' '\n' | grep NODE_DB_PATH || echo 'NODE_DB_PATH not found'"` ); - // The script validates that NODE_DB_PATH ends with .db and should not error - expect(stderr).not.toContain('ERROR: NODE_DB_PATH must end with .db'); expect(stdout.trim()).toBe('NODE_DB_PATH=/tmp/custom/test.db'); }); @@ -272,13 +280,21 @@ describeDocker('Docker Entrypoint Script', () => { const containerName = generateContainerName('root-permissions'); containers.push(containerName); - // Run as root and check that database directory permissions are fixed + // Run as root and let the container initialize + await exec( + `docker run -d --name ${containerName} --user root ${imageName}` + ); + + // Give entrypoint time to fix permissions + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Check directory ownership const { stdout } = await exec( - `docker run --name ${containerName} --user root ${imageName} sh -c "sleep 1 && ls -ld /app/data 2>/dev/null | awk '{print \\$3}' || echo 'ownership-check-failed'"` + `docker exec ${containerName} ls -ld /app/data | awk '{print $3}'` ); // Directory should be owned by nodejs user after entrypoint runs - expect(stdout.trim()).toMatch(/nodejs|ownership-check-failed/); + expect(stdout.trim()).toBe('nodejs'); }); it('should switch to nodejs user when running as root', async () => { diff --git a/tests/integration/docker/test-helpers.ts b/tests/integration/docker/test-helpers.ts new file mode 100644 index 0000000..3242375 --- /dev/null +++ b/tests/integration/docker/test-helpers.ts @@ -0,0 +1,59 @@ +import { promisify } from 'util'; +import { exec as execCallback } from 'child_process'; + +export const exec = promisify(execCallback); + +/** + * Wait for a container to be healthy by checking the health endpoint + */ +export async function waitForHealthy(containerName: string, timeout = 10000): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + try { + const { stdout } = await exec( + `docker exec ${containerName} curl -s http://localhost:3000/health` + ); + + if (stdout.includes('ok')) { + return true; + } + } catch (error) { + // Container might not be ready yet + } + + await new Promise(resolve => setTimeout(resolve, 500)); + } + + return false; +} + +/** + * Check if a container is running in HTTP mode by verifying the server is listening + */ +export async function isRunningInHttpMode(containerName: string): Promise { + try { + const { stdout } = await exec( + `docker exec ${containerName} sh -c "netstat -tln 2>/dev/null | grep :3000 || echo 'Not listening'"` + ); + + return stdout.includes(':3000'); + } catch { + return false; + } +} + +/** + * Get process environment variables from inside a running container + */ +export async function getProcessEnv(containerName: string, varName: string): Promise { + try { + const { stdout } = await exec( + `docker exec ${containerName} sh -c "cat /proc/1/environ | tr '\\0' '\\n' | grep '^${varName}=' | cut -d= -f2-"` + ); + + return stdout.trim() || null; + } catch { + return null; + } +} \ No newline at end of file