fix: detect app-installer .env files through volume mounts

This commit is contained in:
KodekaTeam committed 2026-06-22 11:26:53 +07:00
1 parent 0f22a0caa0
commit 1435f5a6d7
4 files changed
+341 -118

No files matched your search

+17
View File
@@ -716,6 +716,12 @@ body {
border: none;
outline: none;
}
.btn:disabled,
.btn[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.48;
filter: grayscale(0.25);
}
.btn-sm {
max-height: 35px;
padding: 6px 12px;
@@ -733,6 +739,11 @@ body {
/* transform: translateY(-1px); */
/* box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); */
}
.btn-primary:disabled,
.btn-primary[aria-disabled="true"] {
background: rgba(59, 130, 246, 0.18);
color: rgba(255, 255, 255, 0.52);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
@@ -744,6 +755,12 @@ body {
color: var(--text-primary);
border-color: var(--border-hover);
}
.btn-ghost:disabled,
.btn-ghost[aria-disabled="true"] {
background: transparent;
color: var(--text-muted);
border-color: var(--border);
}
.btn-warning {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
@@ -6,6 +6,7 @@ import {
CheckCircle2,
ClipboardCheck,
Loader2,
FileWarning,
RotateCcw,
Save,
ShieldAlert,
@@ -159,6 +160,7 @@ export default function EnvironmentTabPanel({
[editorContent],
);
const editableSource = getEditableSource(environment.editor.source);
const editorDisabled = !environment.editor.found;
const canSave =
Boolean(environment.editor.found && environment.editor.path) &&
editableSource !== null;
@@ -235,32 +237,68 @@ export default function EnvironmentTabPanel({
}}
>
<div>
<textarea
value={editorContent}
onChange={(event) => {
setEditorDraft({
source: environment.editor.content,
content: event.target.value,
status: "idle",
});
setEditorNotice(null);
}}
spellCheck={false}
style={{
width: "100%",
minHeight: 480,
resize: "vertical",
border: "1px solid var(--border)",
borderRadius: 7,
background: "var(--terminal-surface)",
color: "var(--terminal-body)",
padding: 12,
fontFamily: "var(--font--code)",
fontSize: 12,
lineHeight: 1.6,
outline: "none",
}}
/>
{editorDisabled ? (
<div
style={{
minHeight: 480,
border: "1px solid var(--border)",
borderRadius: 7,
background: "var(--terminal-surface)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 10,
color: "var(--terminal-body)",
textAlign: "center",
padding: 24,
}}
>
<FileWarning
size={30}
style={{ color: "var(--accent-yellow)" }}
/>
<strong>Project .env file is unavailable</strong>
<span
style={{
color: "var(--text-muted)",
fontSize: 12,
lineHeight: 1.5,
maxWidth: 560,
}}
>
{environment.editor.message ||
"No project .env file was found in the deployment or mounted project path."}
</span>
</div>
) : (
<textarea
value={editorContent}
onChange={(event) => {
setEditorDraft({
source: environment.editor.content,
content: event.target.value,
status: "idle",
});
setEditorNotice(null);
}}
spellCheck={false}
style={{
width: "100%",
minHeight: 480,
resize: "vertical",
border: "1px solid var(--border)",
borderRadius: 7,
background: "var(--terminal-surface)",
color: "var(--terminal-body)",
padding: 12,
fontFamily: "var(--font--code)",
fontSize: 12,
lineHeight: 1.6,
outline: "none",
}}
/>
)}
<div
style={{
display: "flex",
@@ -279,7 +317,7 @@ export default function EnvironmentTabPanel({
<button
type="button"
className="btn btn-ghost"
disabled={saving}
disabled={saving || editorDisabled}
onClick={() => {
setEditorDraft({
source: editorDraft.source,
@@ -299,7 +337,7 @@ export default function EnvironmentTabPanel({
<button
type="button"
className="btn btn-ghost"
disabled={saving}
disabled={saving || editorDisabled}
onClick={() => {
const validation = validateDotenvContent(editorContent);
setEditorNotice(validation);
@@ -371,10 +409,10 @@ export default function EnvironmentTabPanel({
: editorDirty
? "You have unsaved editor changes."
: environment.editor.found
? environment.editor.source === "container"
? "Editor is loaded from container filesystem .env."
: "Editor is loaded from project .env file."
: "Project .env was not found. Runtime env is shown as read-only fallback."}
? environment.editor.source === "container"
? "Editor is loaded from container filesystem .env."
: "Editor is loaded from project .env file."
: "Editor is disabled because no project .env file was found."}
</p>
<p
style={{
+199 -86
View File
@@ -647,11 +647,15 @@ function toNullableValue(value?: string | null): string | null {
return toOptionalValue(value) ?? null;
}
function normalizeRemotePath(path: string) {
return pathPosix.normalize(path.replace(/\\/g, "/"));
}
function normalizeDeploymentChildPath(
deploymentPath: string,
relativePath?: string | null,
) {
const base = pathPosix.normalize(deploymentPath);
const base = normalizeRemotePath(deploymentPath);
const child = relativePath?.trim()
? pathPosix.normalize(pathPosix.join(base, relativePath, ".env"))
: pathPosix.join(base, ".env");
@@ -664,11 +668,108 @@ function normalizeDeploymentChildPath(
}
function isPathInside(basePath: string, targetPath: string) {
const base = pathPosix.normalize(basePath);
const target = pathPosix.normalize(targetPath);
const base = normalizeRemotePath(basePath);
const target = normalizeRemotePath(targetPath);
return target === base || target.startsWith(`${base}/`);
}
type DockerInspectMount = {
Source?: string;
Destination?: string;
RW?: boolean;
};
function getInspectMounts(inspect: unknown): DockerInspectMount[] {
if (
typeof inspect !== "object" ||
inspect === null ||
!("Mounts" in inspect) ||
!Array.isArray(inspect.Mounts)
) {
return [];
}
return inspect.Mounts.filter(
(mount): mount is DockerInspectMount =>
typeof mount === "object" && mount !== null,
);
}
function isUsableMountPath(value?: string) {
return Boolean(value?.trim().startsWith("/"));
}
export function resolveContainerPathToHostMountPath(
containerPath: string,
mounts: DockerInspectMount[],
) {
const normalizedContainerPath = normalizeRemotePath(containerPath);
const matchedMount = mounts
.filter(
(mount) =>
isUsableMountPath(mount.Source) &&
isUsableMountPath(mount.Destination),
)
.map((mount) => ({
source: normalizeRemotePath(mount.Source!),
destination: normalizeRemotePath(mount.Destination!),
}))
.filter(
(mount) =>
normalizedContainerPath === mount.destination ||
normalizedContainerPath.startsWith(`${mount.destination}/`),
)
.sort((left, right) => right.destination.length - left.destination.length)
.at(0);
if (!matchedMount) return null;
const relativePath =
normalizedContainerPath === matchedMount.destination
? ""
: normalizedContainerPath.slice(matchedMount.destination.length + 1);
return pathPosix.normalize(pathPosix.join(matchedMount.source, relativePath));
}
function getWritableMountRootsForPath(targetPath: string, mounts: DockerInspectMount[]) {
const normalizedTargetPath = normalizeRemotePath(targetPath);
return mounts
.filter(
(mount) =>
mount.RW !== false &&
isUsableMountPath(mount.Source) &&
isUsableMountPath(mount.Destination),
)
.map((mount) => normalizeRemotePath(mount.Source!))
.filter((source) => isPathInside(source, normalizedTargetPath));
}
export function parseStoredVolumeMounts(value: unknown): DockerInspectMount[] {
return parseStoredStringArray(value).flatMap((entry) => {
const [sourceRaw, destinationRaw, modeRaw] = entry
.split(":")
.map((part) => part.trim());
const source = sourceRaw ? normalizeRemotePath(sourceRaw) : "";
const destination = destinationRaw
? normalizeRemotePath(destinationRaw)
: "";
if (!source.startsWith("/") || !destination.startsWith("/")) {
return [];
}
return [
{
Source: source,
Destination: destination,
RW: modeRaw !== "ro",
},
];
});
}
function getInspectWorkingDir(inspect: unknown) {
if (
typeof inspect === "object" &&
@@ -1864,39 +1965,48 @@ export async function containerRoutes(app: FastifyInstance) {
(path): path is string => Boolean(path),
);
const runtimeId = container.dockerId || container.name;
const priorityContainerPaths = ["/app/.env"];
const checkedPriorityContainerPaths: string[] = [];
try {
for (const candidatePath of priorityContainerPaths) {
checkedPriorityContainerPaths.push(candidatePath);
try {
const file = await ssh.readContainerFile(
container.server,
runtimeId,
candidatePath,
);
const containerCandidatePaths = new Set<string>();
let inspect: unknown = null;
let projectMounts = parseStoredVolumeMounts(container.volumes);
return reply.send({
success: true,
data: {
found: true,
path: file.path,
content: file.content,
checkedPaths: checkedPriorityContainerPaths,
source: "container",
message: "Project .env file loaded from container filesystem.",
},
});
} catch {
// Continue to deployment path and other common container paths.
try {
inspect = await ssh.dockerInspect(container.server, runtimeId);
} catch {
// 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"));
}
}
const data = await ssh.readDeploymentEnvFile(
container.server,
candidatePaths,
);
projectMounts.forEach((mount) => {
if (isUsableMountPath(mount.Destination)) {
containerCandidatePaths.add(
pathPosix.join(normalizeRemotePath(mount.Destination!), ".env"),
);
}
});
containerCandidatePaths.add("/app/.env");
containerCandidatePaths.add("/workspace/.env");
containerCandidatePaths.add("/var/www/html/.env");
const mountCandidatePaths = Array.from(containerCandidatePaths)
.map((path) =>
resolveContainerPathToHostMountPath(path, projectMounts),
)
.filter((path): path is string => Boolean(path));
const data = await ssh.readDeploymentEnvFile(container.server, [
...candidatePaths,
...mountCandidatePaths,
]);
if (data.found) {
return reply.send({
@@ -1904,58 +2014,41 @@ export async function containerRoutes(app: FastifyInstance) {
data: {
...data,
source: "project",
message: "Project .env file loaded from deployment path.",
message:
"Project .env file loaded from deployment path or mounted project path.",
},
});
}
const containerCandidatePaths = new Set<string>();
priorityContainerPaths.forEach((path) =>
containerCandidatePaths.add(path),
);
try {
const inspect = await ssh.dockerInspect(container.server, runtimeId);
const workingDir = getInspectWorkingDir(inspect);
if (workingDir) {
containerCandidatePaths.add(pathPosix.join(workingDir, ".env"));
}
} catch {
// Keep fallback candidates below when inspect is unavailable.
}
containerCandidatePaths.add("/workspace/.env");
containerCandidatePaths.add("/var/www/html/.env");
const checkedContainerPaths: string[] = [];
for (const candidatePath of containerCandidatePaths) {
if (priorityContainerPaths.includes(candidatePath)) continue;
checkedContainerPaths.push(candidatePath);
try {
const file = await ssh.readContainerFile(
container.server,
runtimeId,
candidatePath,
);
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: [
...checkedPriorityContainerPaths,
...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: {
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.
}
}
}
@@ -1964,13 +2057,12 @@ export async function containerRoutes(app: FastifyInstance) {
data: {
...data,
checkedPaths: [
...checkedPriorityContainerPaths,
...data.checkedPaths,
...checkedContainerPaths,
],
source: "missing",
message:
"No project .env file was found at the checked deployment or container paths.",
"No project .env file was found at the checked deployment, mounted project, or container paths.",
},
});
} catch (err: unknown) {
@@ -2049,24 +2141,45 @@ export async function containerRoutes(app: FastifyInstance) {
const deploymentPath =
container.deploymentSource?.deploymentPath?.trim();
const normalizedEnvPath = normalizeRemotePath(body.data.path);
const storedMounts = parseStoredVolumeMounts(container.volumes);
let canWriteProjectEnv = deploymentPath
? isPathInside(deploymentPath, normalizedEnvPath)
: false;
if (!deploymentPath) {
return reply.status(400).send({
success: false,
error: "Deployment path is not available for this container",
});
if (!canWriteProjectEnv) {
canWriteProjectEnv =
getWritableMountRootsForPath(normalizedEnvPath, storedMounts)
.length > 0;
}
if (!isPathInside(deploymentPath, body.data.path)) {
if (!canWriteProjectEnv) {
try {
const inspect = await ssh.dockerInspect(
container.server,
container.dockerId || container.name,
);
canWriteProjectEnv =
getWritableMountRootsForPath(
normalizedEnvPath,
[...getInspectMounts(inspect), ...storedMounts],
).length > 0;
} catch {
canWriteProjectEnv = false;
}
}
if (!canWriteProjectEnv) {
return reply.status(400).send({
success: false,
error: "Project .env path is outside the deployment directory",
error:
"Project .env path is outside the deployment directory or writable mounted project path",
});
}
const data = await ssh.writeDeploymentEnvFile(
container.server,
body.data.path,
normalizedEnvPath,
body.data.content,
);
@@ -4,7 +4,9 @@ import test from "node:test";
import {
clearContainerMetricsCacheForTest,
invalidateContainerMetricsCache,
parseStoredVolumeMounts,
readContainerMetricsWithCache,
resolveContainerPathToHostMountPath,
} from "../../src/server/routes/containers";
const stats = {
@@ -119,3 +121,56 @@ test("container metrics cache does not retain failed samples", async () => {
assert.equal(loadCount, 2);
});
test("project env path resolver maps container paths to the most specific host mount", () => {
const mounts = [
{
Source: "/srv/apps/example",
Destination: "/app",
RW: true,
},
{
Source: "/srv/apps/example/storage",
Destination: "/app/storage",
RW: true,
},
];
assert.equal(
resolveContainerPathToHostMountPath("/app/.env", mounts),
"/srv/apps/example/.env",
);
assert.equal(
resolveContainerPathToHostMountPath("/app/storage/.env", mounts),
"/srv/apps/example/storage/.env",
);
assert.equal(
resolveContainerPathToHostMountPath("/workspace/.env", mounts),
null,
);
});
test("project env path resolver can use stored container volume bindings", () => {
const mounts = parseStoredVolumeMounts([
"/srv/laravel:/bitnami/laravel",
"named-volume:/ignored",
"/srv/read-only:/readonly:ro",
]);
assert.deepEqual(mounts, [
{
Source: "/srv/laravel",
Destination: "/bitnami/laravel",
RW: true,
},
{
Source: "/srv/read-only",
Destination: "/readonly",
RW: false,
},
]);
assert.equal(
resolveContainerPathToHostMountPath("/bitnami/laravel/.env", mounts),
"/srv/laravel/.env",
);
assert.equal(mounts[0]?.Destination, "/bitnami/laravel");
});