diff --git a/src/server/routes/containers.ts b/src/server/routes/containers.ts index dec9bf1..5cb1190 100644 --- a/src/server/routes/containers.ts +++ b/src/server/routes/containers.ts @@ -787,6 +787,46 @@ function getInspectWorkingDir(inspect: unknown) { return null; } +export function getProjectEnvRuntimeCandidatePaths( + inspect: unknown, + mounts: DockerInspectMount[], + processWorkingDirs: string[] = [], +) { + const candidates = new Set(); + const workingDir = getInspectWorkingDir(inspect); + + processWorkingDirs.forEach((path) => { + if (isUsableMountPath(path)) { + candidates.add(pathPosix.join(normalizeRemotePath(path), ".env")); + } + }); + + if (workingDir) { + candidates.add(pathPosix.join(normalizeRemotePath(workingDir), ".env")); + } + + mounts.forEach((mount) => { + if (isUsableMountPath(mount.Destination)) { + candidates.add( + pathPosix.join(normalizeRemotePath(mount.Destination!), ".env"), + ); + } + }); + + [ + "/app", + "/workspace", + "/var/www/html", + "/bitnami/laravel", + "/laravel/laravel", + "/srv/app", + ].forEach((path) => { + candidates.add(pathPosix.join(path, ".env")); + }); + + return Array.from(candidates).slice(0, 12); +} + function redactSecret(message: string, secret?: string | null) { if (!secret) return message; return message.split(secret).join("[REDACTED]"); @@ -1967,8 +2007,9 @@ export async function containerRoutes(app: FastifyInstance) { const runtimeId = container.dockerId || container.name; try { - const containerCandidatePaths = new Set(); + const checkedContainerPaths: string[] = []; let inspect: unknown = null; + const processWorkingDirs: string[] = []; let projectMounts = parseStoredVolumeMounts(container.volumes); try { @@ -1977,27 +2018,57 @@ export async function containerRoutes(app: FastifyInstance) { // Keep stored deployment candidates and common fallback paths. } - if (inspect) { - projectMounts = [...getInspectMounts(inspect), ...projectMounts]; - const workingDir = getInspectWorkingDir(inspect); - if (workingDir) { - containerCandidatePaths.add(pathPosix.join(workingDir, ".env")); + if (container.status === "RUNNING") { + const mainProcessWorkingDir = + await ssh.getContainerMainProcessWorkingDirectory( + container.server, + runtimeId, + ); + + if (mainProcessWorkingDir) { + processWorkingDirs.push(mainProcessWorkingDir); } } - projectMounts.forEach((mount) => { - if (isUsableMountPath(mount.Destination)) { - containerCandidatePaths.add( - pathPosix.join(normalizeRemotePath(mount.Destination!), ".env"), - ); + if (inspect) { + projectMounts = [...getInspectMounts(inspect), ...projectMounts]; + } + + const containerCandidatePaths = getProjectEnvRuntimeCandidatePaths( + inspect, + projectMounts, + processWorkingDirs, + ); + + if (container.status === "RUNNING") { + for (const candidatePath of containerCandidatePaths) { + checkedContainerPaths.push(candidatePath); + try { + const file = await ssh.readContainerFile( + container.server, + runtimeId, + candidatePath, + ); + + return reply.send({ + success: true, + data: { + found: true, + path: file.path, + content: file.content, + checkedPaths: checkedContainerPaths, + source: "container", + message: + "Project .env file loaded from the active container filesystem.", + }, + }); + } catch { + // Try the next runtime project path. + } } - }); + } - containerCandidatePaths.add("/app/.env"); - containerCandidatePaths.add("/workspace/.env"); - containerCandidatePaths.add("/var/www/html/.env"); - - const mountCandidatePaths = Array.from(containerCandidatePaths) + const mountCandidatePaths = containerCandidatePaths .map((path) => resolveContainerPathToHostMountPath(path, projectMounts), ) @@ -2020,46 +2091,11 @@ export async function containerRoutes(app: FastifyInstance) { }); } - const checkedContainerPaths: string[] = []; - if (container.status === "RUNNING") { - for (const candidatePath of containerCandidatePaths) { - checkedContainerPaths.push(candidatePath); - try { - const file = await ssh.readContainerFile( - container.server, - runtimeId, - candidatePath, - ); - - return reply.send({ - success: true, - data: { - found: true, - path: file.path, - content: file.content, - checkedPaths: [ - ...data.checkedPaths, - ...checkedContainerPaths, - ], - source: "container", - message: - "Project .env file loaded from container filesystem.", - }, - }); - } catch { - // Try the next common app path. - } - } - } - return reply.send({ success: true, data: { ...data, - checkedPaths: [ - ...data.checkedPaths, - ...checkedContainerPaths, - ], + checkedPaths: [...data.checkedPaths, ...checkedContainerPaths], source: "missing", message: "No project .env file was found at the checked deployment, mounted project, or container paths.", diff --git a/src/server/services/ssh-services/docker-containers.ts b/src/server/services/ssh-services/docker-containers.ts index 9635c91..f26d2ce 100644 --- a/src/server/services/ssh-services/docker-containers.ts +++ b/src/server/services/ssh-services/docker-containers.ts @@ -1140,6 +1140,35 @@ export async function listContainerFiles( }; } +export async function getContainerMainProcessWorkingDirectory( + server: Server, + containerId: string, +): Promise { + const script = [ + 'CWD=$(readlink -f /proc/1/cwd 2>/dev/null || true)', + 'if [ -z "$CWD" ] || [ ! -d "$CWD" ]; then exit 0; fi', + 'CWD_B64=$(printf "%s" "$CWD" | base64 | tr -d "\\n")', + 'printf "__CWD__\t%s\n" "$CWD_B64"', + ].join("\n"); + + try { + const stdout = await execContainerShell( + server, + containerId, + script, + DOCKER_FILE_LIST_TIMEOUT_MS, + ); + const line = stdout + .split("\n") + .find((entry) => entry.startsWith("__CWD__\t")); + const encodedPath = line?.split("\t")[1]; + + return encodedPath ? decodeBase64Value(encodedPath) : null; + } catch { + return null; + } +} + export async function readContainerFile( server: Server, containerId: string, diff --git a/tests/backend/container-metrics-cache.test.ts b/tests/backend/container-metrics-cache.test.ts index de7286a..2eb9391 100644 --- a/tests/backend/container-metrics-cache.test.ts +++ b/tests/backend/container-metrics-cache.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import { clearContainerMetricsCacheForTest, + getProjectEnvRuntimeCandidatePaths, invalidateContainerMetricsCache, parseStoredVolumeMounts, readContainerMetricsWithCache, @@ -174,3 +175,42 @@ test("project env path resolver can use stored container volume bindings", () => ); assert.equal(mounts[0]?.Destination, "/bitnami/laravel"); }); + +test("project env runtime candidates prefer inspect paths before common fallbacks", () => { + const mounts = parseStoredVolumeMounts([ + "/srv/project:/runtime/project", + ]); + const paths = getProjectEnvRuntimeCandidatePaths( + { + Config: { + WorkingDir: "/runtime/project", + }, + }, + mounts, + ); + + assert.deepEqual(paths.slice(0, 4), [ + "/runtime/project/.env", + "/app/.env", + "/workspace/.env", + "/var/www/html/.env", + ]); +}); + +test("project env runtime candidates prefer the main process working directory", () => { + const paths = getProjectEnvRuntimeCandidatePaths( + { + Config: { + WorkingDir: "/image/default", + }, + }, + [], + ["/app"], + ); + + assert.deepEqual(paths.slice(0, 3), [ + "/app/.env", + "/image/default/.env", + "/workspace/.env", + ]); +});