fix: detect app-installer .env files through volume mounts
This commit is contained in:
1 parent
0f22a0caa0
commit
1435f5a6d7
4 files changed
+341
-118
No files matched your search
@@ -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;
|
||||
|
||||
+70
-32
@@ -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
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user