homeio
PublikFork dari doctor-io/homeio
compose-runner.ts
import "server-only";
import { execFile } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { serverEnv } from "@/lib/server/env";
import {
summarizeContainers,
toContainerHealth,
type AppConditionSummary,
} from "@/lib/shared/app-condition";
import { logServerAction, withServerTiming } from "@/lib/server/logging/logger";
import {
applyBindMountOwnershipOverrides,
classifyServiceBindMountOwnership,
type BindMountOwnershipOverride,
} from "@/lib/server/modules/docker/app-data-ownership";
import {
ensureDataRootDirectories,
resolveStoreAppDataRoot,
resolveStoreStacksRoot,
} from "@/lib/server/storage/data-root";
import { parseEnvFileContent } from "@/lib/shared/env-file";
const execFileAsync = promisify(execFile);
type ComposeCommandInput = {
composePath: string;
envPath: string;
stackName: string;
args: string[];
};
export type MaterializedStack = {
stackDir: string;
composePath: string;
envPath: string;
stackName: string;
webUiPort: number | null;
};
export type ComposeStorageMappingStrategy =
| "legacy_named_source"
| "app_target_path";
type ParsedListItem = {
prefix: string;
spec: string;
quote: "'" | '"' | null;
comment: string;
};
type ParsedComposeVolumeSpec = {
source: string;
target: string;
mode: string | null;
};
type ComposeStorageReferences = {
bindMountDirectories: Set<string>;
namedVolumeSources: Set<string>;
};
type ServiceOwnershipContext = {
image: string | null;
user: string | null;
env: Record<string, string>;
};
type ParsedKeyValueLine = {
key: string;
value: string;
comment: string;
quote: "'" | '"' | null;
};
type ParsedLongFormVolumeItem = {
end: number;
fields: Partial<Record<"type" | "source" | "target", string>>;
fieldLines: Partial<Record<"type" | "source" | "target", number>>;
};
function sanitizeSegment(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function sanitizeStackName(appId: string, displayName?: string) {
const source = displayName && displayName.trim().length > 0 ? displayName : appId;
const sanitized = sanitizeSegment(source);
if (!sanitized) {
return `app-${Date.now()}`;
}
return sanitized.slice(0, 63);
}
function serializeEnvFile(env: Record<string, string>) {
return Object.keys(env)
.sort((a, b) => a.localeCompare(b))
.map((key) => `${key}=${env[key] ?? ""}`)
.join("\n");
}
function interpolateComposeVariables(
composeContent: string,
env: Record<string, string>,
) {
return composeContent.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (match, key: string) => {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
return match;
}
return env[key] ?? "";
});
}
export function applyWebUiPortOverride(composeContent: string, webUiPort: number) {
const lines = composeContent.split("\n");
let replaced = false;
const updated = lines.map((line) => {
if (replaced) return line;
const match = line.match(/^(\s*-\s*["']?)(\d+)(:\d+(?::\d+)?(?:\/[a-z]+)?["']?\s*)$/i);
if (!match) return line;
replaced = true;
return `${match[1]}${webUiPort}${match[3]}`;
});
// Some stacks (for example `network_mode: host`) intentionally do not publish ports.
// In that case keep compose as-is and persist the port in installed stack metadata.
if (!replaced) {
return composeContent;
}
return updated.join("\n");
}
function isBlankOrComment(line: string) {
const trimmed = line.trim();
return trimmed.length === 0 || trimmed.startsWith("#");
}
function getIndentation(line: string) {
return line.length - line.trimStart().length;
}
function parseListItem(line: string): ParsedListItem | null {
const match = line.match(/^(\s*-\s+)(.+)$/);
if (!match) return null;
const prefix = match[1];
let remainder = match[2].trimEnd();
let quote: "'" | '"' | null = null;
let comment = "";
let quoteState: "'" | '"' | null = null;
let commentStart = -1;
for (let index = 0; index < remainder.length; index += 1) {
const char = remainder[index];
if (char === "'" || char === '"') {
if (quoteState === char) {
quoteState = null;
} else if (quoteState === null) {
quoteState = char;
}
continue;
}
if (char === "#" && quoteState === null && index > 0 && /\s/.test(remainder[index - 1])) {
commentStart = index;
break;
}
}
if (commentStart >= 0) {
comment = remainder.slice(commentStart - 1);
remainder = remainder.slice(0, commentStart - 1).trimEnd();
}
if (
(remainder.startsWith('"') && remainder.endsWith('"')) ||
(remainder.startsWith("'") && remainder.endsWith("'"))
) {
quote = remainder[0] as "'" | '"';
remainder = remainder.slice(1, -1);
}
const spec = remainder.trim();
if (!spec) return null;
return {
prefix,
spec,
quote,
comment,
};
}
function parseComposeVolumeSpec(spec: string): ParsedComposeVolumeSpec | null {
if (spec.includes(": ")) {
return null;
}
const parts = spec.split(":");
if (parts.length < 2) {
return null;
}
const source = parts[0]?.trim();
const target = parts[1]?.trim();
if (!source || !target || !target.startsWith("/")) {
return null;
}
const modeRaw = parts.slice(2).join(":").trim();
return {
source,
target,
mode: modeRaw.length > 0 ? modeRaw : null,
};
}
function parseKeyValueLine(line: string): ParsedKeyValueLine | null {
const trimmed = line.trim();
const match = trimmed.match(/^([A-Za-z0-9_.-]+):\s*(.*?)\s*$/);
if (!match) {
return null;
}
let remainder = match[2] ?? "";
let quote: "'" | '"' | null = null;
let comment = "";
let quoteState: "'" | '"' | null = null;
let commentStart = -1;
for (let index = 0; index < remainder.length; index += 1) {
const char = remainder[index];
if (char === "'" || char === '"') {
if (quoteState === char) {
quoteState = null;
} else if (quoteState === null) {
quoteState = char;
}
continue;
}
if (char === "#" && quoteState === null && index > 0 && /\s/.test(remainder[index - 1])) {
commentStart = index;
break;
}
}
if (commentStart >= 0) {
comment = remainder.slice(commentStart - 1);
remainder = remainder.slice(0, commentStart - 1).trimEnd();
}
if (
(remainder.startsWith('"') && remainder.endsWith('"')) ||
(remainder.startsWith("'") && remainder.endsWith("'"))
) {
quote = remainder[0] as "'" | '"';
remainder = remainder.slice(1, -1);
}
return {
key: match[1],
value: remainder.trim(),
comment,
quote,
};
}
function isLikelyNamedVolumeSource(source: string) {
if (!source) return false;
if (source.startsWith("/")) return false;
if (source.startsWith("./") || source.startsWith("../")) return false;
if (source === "." || source === "..") return false;
if (source.startsWith("~")) return false;
if (source.startsWith("$")) return false;
if (source.includes("/")) return false;
return true;
}
function isPathWithinRoot(candidate: string, root: string) {
const relative = path.relative(root, candidate);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function normalizeContainerTargetPath(target: string) {
const normalizedTarget = path.posix.normalize(target);
const segments = normalizedTarget
.split("/")
.filter((segment) => segment.length > 0 && segment !== "." && segment !== "..");
if (segments.length === 0) {
return "data";
}
return path.join(...segments);
}
function resolveNamedVolumeBindSource(input: {
appDataRoot: string;
appId: string;
source: string;
target: string;
strategy: ComposeStorageMappingStrategy;
}) {
if (input.strategy === "legacy_named_source") {
return path.join(input.appDataRoot, input.source);
}
if (input.appId.trim().length === 0) {
throw new Error("appId is required for app_target_path storage mapping");
}
const appScopedRoot = path.join(input.appDataRoot, input.appId);
const relativeTargetPath = normalizeContainerTargetPath(input.target);
const bindSource = path.join(appScopedRoot, relativeTargetPath);
if (!isPathWithinRoot(bindSource, appScopedRoot)) {
throw new Error(
`Generated bind mount path escapes app root for appId "${input.appId}"`,
);
}
return bindSource;
}
function resolveAppScopedBindSource(appDataRoot: string, appId: string, target: string) {
return resolveNamedVolumeBindSource({
appDataRoot,
appId,
source: "__ignored__",
target,
strategy: "app_target_path",
});
}
function shouldRewriteAppDataBindSource(input: {
appDataRoot: string;
appId: string;
source: string;
target: string;
strategy: ComposeStorageMappingStrategy;
}) {
if (input.strategy !== "app_target_path") {
return false;
}
if (!path.isAbsolute(input.source) || !isPathWithinRoot(input.source, input.appDataRoot)) {
return false;
}
const appScopedRoot = path.join(input.appDataRoot, input.appId);
if (isPathWithinRoot(input.source, appScopedRoot)) {
return false;
}
const desiredSource = resolveAppScopedBindSource(input.appDataRoot, input.appId, input.target);
return path.resolve(input.source) !== path.resolve(desiredSource);
}
function parseLongFormVolumeItem(lines: string[], startIndex: number): ParsedLongFormVolumeItem {
const startLine = lines[startIndex];
const itemIndent = getIndentation(startLine);
let end = startIndex + 1;
while (end < lines.length) {
const line = lines[end];
if (isBlankOrComment(line)) {
end += 1;
continue;
}
if (getIndentation(line) <= itemIndent) {
break;
}
end += 1;
}
const fields: ParsedLongFormVolumeItem["fields"] = {};
const fieldLines: ParsedLongFormVolumeItem["fieldLines"] = {};
const firstInline = parseKeyValueLine(startLine.replace(/^(\s*-\s+)/, ""));
if (firstInline && (firstInline.key === "type" || firstInline.key === "source" || firstInline.key === "target")) {
fields[firstInline.key] = firstInline.value;
fieldLines[firstInline.key] = startIndex;
}
for (let index = startIndex + 1; index < end; index += 1) {
const parsed = parseKeyValueLine(lines[index]);
if (!parsed) continue;
if (parsed.key === "type" || parsed.key === "source" || parsed.key === "target") {
fields[parsed.key] = parsed.value;
fieldLines[parsed.key] = index;
}
}
return {
end,
fields,
fieldLines,
};
}
function collectServiceOwnershipContext(lines: string[]) {
const serviceContexts = new Map<string, ServiceOwnershipContext>();
const pathStack: Array<{ indent: number; key: string }> = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const indent = getIndentation(line);
while (pathStack.length > 0 && indent <= pathStack[pathStack.length - 1].indent) {
pathStack.pop();
}
const keyMatch = trimmed.match(/^([A-Za-z0-9_.-]+):(\s*#.*)?$/);
if (keyMatch) {
pathStack.push({
indent,
key: keyMatch[1],
});
continue;
}
const pathKeys = pathStack.map((entry) => entry.key);
if (pathKeys.length < 2 || pathKeys[0] !== "services") {
continue;
}
const serviceName = pathKeys[1];
const context = serviceContexts.get(serviceName) ?? {
image: null,
user: null,
env: {},
};
const parsed = parseKeyValueLine(line);
if (
pathKeys.length === 2 &&
parsed &&
parsed.value.length > 0 &&
(parsed.key === "image" || parsed.key === "user")
) {
if (parsed.key === "image") {
context.image = parsed.value;
} else {
context.user = parsed.value;
}
serviceContexts.set(serviceName, context);
continue;
}
if (pathKeys.length === 3 && pathKeys[2] === "environment") {
if (parsed && parsed.key.length > 0) {
context.env[parsed.key] = parsed.value;
serviceContexts.set(serviceName, context);
continue;
}
const listItem = parseListItem(line);
if (!listItem) {
continue;
}
const separatorIndex = listItem.spec.indexOf("=");
if (separatorIndex <= 0) {
continue;
}
const key = listItem.spec.slice(0, separatorIndex).trim();
const value = listItem.spec.slice(separatorIndex + 1).trim();
if (!key) {
continue;
}
context.env[key] = value;
serviceContexts.set(serviceName, context);
}
}
return serviceContexts;
}
function resolveBindMountOwnershipOverride(
context: ServiceOwnershipContext,
target: string,
) {
const resolution = classifyServiceBindMountOwnership({
image: context.image,
user: context.user,
env: context.env,
targetPath: target,
});
if (resolution.source === "fallback") {
return null;
}
return {
uid: resolution.uid,
gid: resolution.gid,
};
}
export function collectComposeBindMountOwnershipOverrides(composeContent: string) {
const lines = composeContent.split(/\r?\n/);
const serviceContexts = collectServiceOwnershipContext(lines);
const pathStack: Array<{ indent: number; key: string }> = [];
const overrides = new Map<string, BindMountOwnershipOverride>();
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const indent = getIndentation(line);
while (pathStack.length > 0 && indent <= pathStack[pathStack.length - 1].indent) {
pathStack.pop();
}
const keyMatch = trimmed.match(/^([A-Za-z0-9_.-]+):(\s*#.*)?$/);
if (keyMatch) {
pathStack.push({
indent,
key: keyMatch[1],
});
continue;
}
const pathKeys = pathStack.map((entry) => entry.key);
const isServiceVolumeList =
pathKeys.length === 3 &&
pathKeys[0] === "services" &&
pathKeys[2] === "volumes";
if (!isServiceVolumeList) {
continue;
}
const serviceContext = serviceContexts.get(pathKeys[1]);
if (!serviceContext) {
continue;
}
const listItem = parseListItem(line);
if (!listItem) {
continue;
}
const volumeSpec = parseComposeVolumeSpec(listItem.spec);
if (volumeSpec) {
if (!path.isAbsolute(volumeSpec.source)) {
continue;
}
const ownership = resolveBindMountOwnershipOverride(serviceContext, volumeSpec.target);
if (!ownership) {
continue;
}
overrides.set(volumeSpec.source, {
directoryPath: volumeSpec.source,
uid: ownership.uid,
gid: ownership.gid,
});
continue;
}
const longForm = parseLongFormVolumeItem(lines, index);
const source = longForm.fields.source;
const target = longForm.fields.target;
if (!source || !target || !path.isAbsolute(source)) {
index = longForm.end - 1;
continue;
}
const ownership = resolveBindMountOwnershipOverride(serviceContext, target);
if (ownership) {
overrides.set(source, {
directoryPath: source,
uid: ownership.uid,
gid: ownership.gid,
});
}
index = longForm.end - 1;
}
return Array.from(overrides.values());
}
export function resolveComposeBindMountOwnershipOverrides(
composeContent: string,
env: Record<string, string>,
) {
return collectComposeBindMountOwnershipOverrides(
interpolateComposeVariables(composeContent, env),
);
}
export function collectMaterializationDirectories(
bindMountDirectories: Iterable<string>,
ownershipOverrides: Iterable<BindMountOwnershipOverride>,
) {
const directoriesToEnsure = new Set(bindMountDirectories);
for (const { directoryPath } of ownershipOverrides) {
directoriesToEnsure.add(directoryPath);
}
return directoriesToEnsure;
}
function rewriteKeyValueLine(
line: string,
expectedKey: string,
nextValue: string,
quote?: "'" | '"' | null,
) {
const prefixMatch = line.match(/^(\s*-\s+|\s*)/);
const prefix = prefixMatch?.[1] ?? "";
const lineWithoutPrefix = prefix.trim() === "-" ? line.slice(prefix.length) : line.trimStart();
const parsed = parseKeyValueLine(lineWithoutPrefix.trim());
const trailingComment = parsed?.comment ?? "";
const quoteChar = quote ?? parsed?.quote ?? null;
const serializedValue = quoteChar ? `${quoteChar}${nextValue}${quoteChar}` : nextValue;
return `${prefix}${expectedKey}: ${serializedValue}${trailingComment}`;
}
function findTopLevelSectionEnd(lines: string[], startIndex: number) {
for (let index = startIndex; index < lines.length; index += 1) {
const line = lines[index];
if (isBlankOrComment(line)) continue;
if (getIndentation(line) === 0) {
return index;
}
}
return lines.length;
}
function removeTopLevelVolumeDefinitions(lines: string[], namesToRemove: Set<string>) {
if (namesToRemove.size === 0) {
return;
}
let index = 0;
while (index < lines.length) {
const line = lines[index];
const trimmed = line.trim();
if (!(getIndentation(line) === 0 && /^volumes:(\s*#.*)?$/.test(trimmed))) {
index += 1;
continue;
}
const sectionStart = index;
const sectionEnd = findTopLevelSectionEnd(lines, index + 1);
const entries: Array<{ name: string; start: number; end: number }> = [];
let cursor = sectionStart + 1;
while (cursor < sectionEnd) {
const entryLine = lines[cursor];
if (isBlankOrComment(entryLine)) {
cursor += 1;
continue;
}
const indent = getIndentation(entryLine);
const entryMatch = entryLine.trim().match(/^([A-Za-z0-9_.-]+):(\s*#.*)?$/);
if (indent !== 2 || !entryMatch) {
cursor += 1;
continue;
}
let end = cursor + 1;
while (end < sectionEnd) {
const maybeNested = lines[end];
if (isBlankOrComment(maybeNested)) {
end += 1;
continue;
}
if (getIndentation(maybeNested) <= 2) {
break;
}
end += 1;
}
entries.push({
name: entryMatch[1],
start: cursor,
end,
});
cursor = end;
}
if (entries.length === 0) {
index = sectionEnd;
continue;
}
const removable = entries.filter((entry) => namesToRemove.has(entry.name));
if (removable.length === 0) {
index = sectionEnd;
continue;
}
if (removable.length === entries.length) {
lines.splice(sectionStart, sectionEnd - sectionStart);
continue;
}
removable
.sort((left, right) => right.start - left.start)
.forEach((entry) => {
lines.splice(entry.start, entry.end - entry.start);
});
index = sectionStart + 1;
}
}
function shouldCollectBindMountSource(source: string, cleanupRoots: string[]) {
if (!path.isAbsolute(source)) {
return false;
}
return cleanupRoots.some((root) => isPathWithinRoot(source, root));
}
function collectComposeStorageReferences(
composeContent: string,
cleanupRoots: string[],
): ComposeStorageReferences {
const lines = composeContent.split(/\r?\n/);
const pathStack: Array<{ indent: number; key: string }> = [];
const bindMountDirectories = new Set<string>();
const namedVolumeSources = new Set<string>();
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const indent = getIndentation(line);
while (pathStack.length > 0 && indent <= pathStack[pathStack.length - 1].indent) {
pathStack.pop();
}
const keyMatch = trimmed.match(/^([A-Za-z0-9_.-]+):(\s*#.*)?$/);
if (keyMatch) {
pathStack.push({
indent,
key: keyMatch[1],
});
continue;
}
const listItem = parseListItem(line);
if (!listItem) {
continue;
}
const pathKeys = pathStack.map((entry) => entry.key);
const isServiceVolumeList =
pathKeys.length === 3 &&
pathKeys[0] === "services" &&
pathKeys[2] === "volumes";
if (!isServiceVolumeList) {
continue;
}
const volumeSpec = parseComposeVolumeSpec(listItem.spec);
if (volumeSpec) {
if (shouldCollectBindMountSource(volumeSpec.source, cleanupRoots)) {
bindMountDirectories.add(volumeSpec.source);
continue;
}
if (isLikelyNamedVolumeSource(volumeSpec.source)) {
namedVolumeSources.add(volumeSpec.source);
}
continue;
}
const longForm = parseLongFormVolumeItem(lines, index);
const source = longForm.fields.source;
if (!source) {
index = longForm.end - 1;
continue;
}
if (shouldCollectBindMountSource(source, cleanupRoots)) {
bindMountDirectories.add(source);
index = longForm.end - 1;
continue;
}
if (isLikelyNamedVolumeSource(source)) {
namedVolumeSources.add(source);
}
index = longForm.end - 1;
}
return {
bindMountDirectories,
namedVolumeSources,
};
}
export function normalizeComposeStorageBindings(
composeContent: string,
stacksRoot: string,
appDataRoot: string,
options?: {
appId?: string;
strategy?: ComposeStorageMappingStrategy;
},
) {
const strategy = options?.strategy ?? "legacy_named_source";
const appId = options?.appId ?? "";
const lines = composeContent.split(/\r?\n/);
const pathStack: Array<{ indent: number; key: string }> = [];
const bindMountDirectories = new Set<string>();
const convertedNamedVolumes = new Set<string>();
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const indent = getIndentation(line);
while (pathStack.length > 0 && indent <= pathStack[pathStack.length - 1].indent) {
pathStack.pop();
}
const keyMatch = trimmed.match(/^([A-Za-z0-9_.-]+):(\s*#.*)?$/);
if (keyMatch) {
pathStack.push({
indent,
key: keyMatch[1],
});
continue;
}
const listItem = parseListItem(line);
if (!listItem) {
continue;
}
const pathKeys = pathStack.map((entry) => entry.key);
const isServiceVolumeList =
pathKeys.length === 3 &&
pathKeys[0] === "services" &&
pathKeys[2] === "volumes";
if (!isServiceVolumeList) {
continue;
}
const volumeSpec = parseComposeVolumeSpec(listItem.spec);
if (volumeSpec) {
let bindSource: string | null = null;
if (isLikelyNamedVolumeSource(volumeSpec.source)) {
bindSource = resolveNamedVolumeBindSource({
appDataRoot,
appId,
source: volumeSpec.source,
target: volumeSpec.target,
strategy,
});
convertedNamedVolumes.add(volumeSpec.source);
} else if (shouldRewriteAppDataBindSource({
appDataRoot,
appId,
source: volumeSpec.source,
target: volumeSpec.target,
strategy,
})) {
bindSource = resolveAppScopedBindSource(appDataRoot, appId, volumeSpec.target);
}
if (!bindSource) {
continue;
}
const rewrittenSpec = volumeSpec.mode
? `${bindSource}:${volumeSpec.target}:${volumeSpec.mode}`
: `${bindSource}:${volumeSpec.target}`;
const quotedSpec = listItem.quote ? `${listItem.quote}${rewrittenSpec}${listItem.quote}` : rewrittenSpec;
lines[index] = `${listItem.prefix}${quotedSpec}${listItem.comment}`;
bindMountDirectories.add(bindSource);
continue;
}
const longForm = parseLongFormVolumeItem(lines, index);
const source = longForm.fields.source;
const target = longForm.fields.target;
if (!source || !target) {
index = longForm.end - 1;
continue;
}
let bindSource: string | null = null;
let convertedNamedVolume = false;
if (isLikelyNamedVolumeSource(source)) {
bindSource = resolveNamedVolumeBindSource({
appDataRoot,
appId,
source,
target,
strategy,
});
convertedNamedVolume = true;
convertedNamedVolumes.add(source);
} else if (shouldRewriteAppDataBindSource({
appDataRoot,
appId,
source,
target,
strategy,
})) {
bindSource = resolveAppScopedBindSource(appDataRoot, appId, target);
}
let itemEnd = longForm.end;
if (bindSource) {
const sourceLineIndex = longForm.fieldLines.source;
if (typeof sourceLineIndex === "number") {
lines[sourceLineIndex] = rewriteKeyValueLine(lines[sourceLineIndex], "source", bindSource);
}
const typeLineIndex = longForm.fieldLines.type;
if (typeof typeLineIndex === "number") {
lines[typeLineIndex] = rewriteKeyValueLine(lines[typeLineIndex], "type", "bind");
} else if (convertedNamedVolume && typeof sourceLineIndex === "number") {
const indent = " ".repeat(getIndentation(lines[sourceLineIndex]));
lines.splice(sourceLineIndex, 0, `${indent}type: bind`);
itemEnd += 1;
}
bindMountDirectories.add(bindSource);
}
index = itemEnd - 1;
}
removeTopLevelVolumeDefinitions(lines, convertedNamedVolumes);
return {
composeContent: lines.join("\n"),
bindMountDirectories,
convertedNamedVolumes,
};
}
export async function materializeInlineStackFiles(input: {
appId: string;
stackName: string;
composeContent: string;
env: Record<string, string>;
webUiPort: number | null;
storageMappingStrategy?: ComposeStorageMappingStrategy;
}) {
let composeContent = input.composeContent;
if (input.webUiPort !== null) {
composeContent = applyWebUiPortOverride(composeContent, input.webUiPort);
}
await ensureDataRootDirectories();
const stacksRoot = resolveStoreStacksRoot();
const appDataRoot = resolveStoreAppDataRoot();
const normalized = normalizeComposeStorageBindings(composeContent, stacksRoot, appDataRoot, {
appId: input.appId,
strategy: input.storageMappingStrategy,
});
const ownershipOverrides = resolveComposeBindMountOwnershipOverrides(normalized.composeContent, {
...input.env,
AppID: input.appId,
});
const directoriesToEnsure = collectMaterializationDirectories(
normalized.bindMountDirectories,
ownershipOverrides,
);
const stackDir = path.join(stacksRoot, input.appId);
const composePath = path.join(stackDir, "docker-compose.yml");
const envPath = path.join(stackDir, ".env");
await mkdir(stackDir, { recursive: true });
await Promise.all(
Array.from(directoriesToEnsure).map((directoryPath) =>
mkdir(directoryPath, { recursive: true }),
),
);
await applyBindMountOwnershipOverrides(ownershipOverrides);
await writeFile(composePath, normalized.composeContent, "utf8");
await writeFile(envPath, serializeEnvFile(input.env), "utf8");
return {
stackDir,
composePath,
envPath,
stackName: input.stackName,
webUiPort: input.webUiPort,
} satisfies MaterializedStack;
}
async function runComposeCommand(input: ComposeCommandInput) {
return withServerTiming(
{
layer: "system",
action: "store.compose.exec",
meta: {
stackName: input.stackName,
args: input.args,
},
},
async () => {
const args = [
"compose",
"-f",
input.composePath,
"--env-file",
input.envPath,
"-p",
input.stackName,
...input.args,
];
const timeoutMs = serverEnv.DOCKER_COMPOSE_TIMEOUT_MS;
const { stdout } = await execFileAsync("docker", args, {
cwd: path.dirname(input.composePath),
timeout: timeoutMs,
// Buffer cap protects against runaway compose output on Pi where
// a stuck `docker compose logs` could exhaust memory.
maxBuffer: 10 * 1024 * 1024,
}).catch((err: unknown) => {
// Detect Node's child_process timeout (SIGTERM + killed) before
// anything else so the user sees a clear "timed out" message
// instead of a truncated stderr.
const errorObject = err as {
killed?: boolean;
signal?: string;
code?: string | number;
stderr?: string;
};
const timedOut =
errorObject?.killed === true ||
errorObject?.signal === "SIGTERM" ||
errorObject?.code === "ETIMEDOUT";
if (timedOut) {
throw new Error(
`Docker compose command timed out after ${Math.round(timeoutMs / 1000)}s. ` +
`Check Docker daemon status and network connectivity, then retry.`,
);
}
// Extract Docker's stderr for a more meaningful error message.
const stderr =
typeof errorObject.stderr === "string"
? errorObject.stderr.trim()
: "";
// Detect hardware device requirements not present on this host
// (e.g. Raspberry Pi GPU /dev/vcsm in some CasaOS templates).
const deviceMatch = stderr.match(
/adding custom device "([^"]+)".*no such file or directory/i,
);
if (deviceMatch) {
throw new Error(
`This app requires a hardware device (${deviceMatch[1]}) that is not available on this host. ` +
`The app template is designed for specific hardware (e.g. Raspberry Pi). ` +
`You can edit the app's compose file to remove the device requirement if it is optional.`,
);
}
// For all other failures, surface Docker's stderr so the user sees
// the real reason instead of a generic "Command failed" message.
if (stderr) {
throw new Error(stderr);
}
throw err;
});
return stdout;
},
);
}
export async function extractComposeImages(input: {
composePath: string;
envPath: string;
stackName: string;
}) {
const stdout = await runComposeCommand({
...input,
args: ["config", "--images"],
});
return Array.from(
new Set(
stdout
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0),
),
);
}
export async function runComposePull(input: {
composePath: string;
envPath: string;
stackName: string;
}) {
await runComposeCommand({
...input,
args: ["pull"],
});
}
export async function runComposeUp(input: {
composePath: string;
envPath: string;
stackName: string;
}) {
await runComposeCommand({
...input,
args: ["up", "-d"],
});
}
export async function runComposeDown(input: {
composePath: string;
envPath: string;
stackName: string;
removeVolumes?: boolean;
}) {
const args = ["down"];
if (input.removeVolumes) {
args.push("-v");
}
await runComposeCommand({
...input,
args,
});
}
export async function runComposeStart(input: {
composePath: string;
envPath: string;
stackName: string;
}) {
await runComposeCommand({
...input,
args: ["start"],
});
}
export async function runComposeStop(input: {
composePath: string;
envPath: string;
stackName: string;
}) {
await runComposeCommand({
...input,
args: ["stop"],
});
}
export async function runComposeRestart(input: {
composePath: string;
envPath: string;
stackName: string;
}) {
await runComposeCommand({
...input,
args: ["restart"],
});
}
export async function getComposeStatus(input: {
composePath: string;
envPath: string;
stackName: string;
}): Promise<"running" | "partial" | "paused" | "stopped" | "unknown"> {
const info = await getComposeRuntimeInfo(input);
return info.status;
}
type ComposePsEntry = {
State?: string;
Name?: string;
ExitCode?: number;
Health?: string;
};
function parseComposePsOutput(stdout: string) {
const trimmed = stdout.trim();
if (!trimmed) return [] as ComposePsEntry[];
if (trimmed.startsWith("[")) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed.filter((entry): entry is ComposePsEntry => {
return Boolean(entry && typeof entry === "object");
});
}
} catch {
// fallback to line-delimited parser
}
}
return trimmed
.split("\n")
.map((line) => {
try {
return JSON.parse(line) as ComposePsEntry;
} catch {
return null;
}
})
.filter((entry): entry is ComposePsEntry => entry !== null);
}
export async function getComposeRuntimeInfo(input: {
composePath: string;
envPath: string;
stackName: string;
}): Promise<{
status: "running" | "partial" | "paused" | "stopped" | "unknown";
lifecycleStatus: "running" | "paused" | "restarting" | "stopped" | "unknown";
containerNames: string[];
primaryContainerName: string | null;
condition: AppConditionSummary;
}> {
try {
// -a includes stopped containers, so a crash shows up with its exit code
// instead of looking like a clean stop.
const stdout = await runComposeCommand({
...input,
args: ["ps", "-a", "--format", "json"],
});
const allContainers = stdout.trim() ? parseComposePsOutput(stdout) : [];
const condition = summarizeContainers(
allContainers.map((entry) => ({
state: entry.State ?? "",
exitCode: typeof entry.ExitCode === "number" ? entry.ExitCode : null,
health: toContainerHealth(entry.Health),
})),
);
// status and lifecycleStatus keep their meaning from before -a: they are
// computed from the containers that are up, not the stopped ones.
const containers = allContainers.filter(
(entry) => !["exited", "created", "dead"].includes((entry.State ?? "").trim().toLowerCase()),
);
if (containers.length === 0) {
return {
status: "stopped",
lifecycleStatus: "stopped",
containerNames: [],
primaryContainerName: null,
condition,
};
}
const normalizedStates = containers.map((container) =>
(container.State ?? "").trim().toLowerCase(),
);
const allRunning = normalizedStates.every((state) => state === "running");
const anyRunning = containers.some((c) => c.State === "running");
const anyPaused = normalizedStates.some((state) => state === "paused");
const anyRestarting = normalizedStates.some((state) => state.includes("restart"));
const containerNames = containers
.map((c) => c.Name?.trim() ?? "")
.filter((name) => name.length > 0);
const primaryRunning = containers.find((c) => c.State === "running" && c.Name);
const primaryPaused = containers.find((c) => c.State === "paused" && c.Name);
const primaryContainerName =
primaryRunning?.Name?.trim() ??
primaryPaused?.Name?.trim() ??
containerNames[0] ??
null;
const status = allRunning ? "running" : anyRunning ? "partial" : anyPaused ? "paused" : "stopped";
const lifecycleStatus = allRunning || anyRunning
? "running"
: anyPaused
? "paused"
: anyRestarting
? "restarting"
: "stopped";
return {
status,
lifecycleStatus,
containerNames,
primaryContainerName,
condition,
};
} catch {
return {
status: "unknown",
lifecycleStatus: "unknown",
containerNames: [],
primaryContainerName: null,
condition: { condition: "unknown", exitCode: null },
};
}
}
export async function cleanupComposeDataOnUninstall(input: {
composePath: string;
removeVolumes?: boolean;
}) {
const cleanupSummary: {
bindMountDirectories: string[];
namedVolumeSources: string[];
removedStackDir: string | null;
composeMissing: boolean;
} = {
bindMountDirectories: [],
namedVolumeSources: [],
removedStackDir: null,
composeMissing: false,
};
await withServerTiming(
{
layer: "system",
action: "store.compose.cleanup",
meta: {
composePath: input.composePath,
removeVolumes: input.removeVolumes ?? false,
},
onSuccessMeta: () => ({
removeVolumes: input.removeVolumes ?? false,
bindMountDirectoryCount: cleanupSummary.bindMountDirectories.length,
namedVolumeCount: cleanupSummary.namedVolumeSources.length,
removedStackDir: cleanupSummary.removedStackDir,
composeMissing: cleanupSummary.composeMissing,
}),
},
async () => {
const stacksRoot = resolveStoreStacksRoot();
const appDataRoot = resolveStoreAppDataRoot();
const stackDir = path.dirname(input.composePath);
const envPath = path.join(stackDir, ".env");
const stackAppId = path.basename(stackDir);
let composeContent: string | null = null;
try {
composeContent = await readFile(input.composePath, "utf8");
} catch (error) {
const errorWithCode = error as NodeJS.ErrnoException;
if (errorWithCode.code !== "ENOENT") {
throw error;
}
cleanupSummary.composeMissing = true;
}
if (input.removeVolumes && composeContent !== null) {
let interpolationEnv: Record<string, string> = {
AppID: stackAppId,
};
try {
const envContent = await readFile(envPath, "utf8");
interpolationEnv = {
...interpolationEnv,
...parseEnvFileContent(envContent),
};
} catch (error) {
const errorWithCode = error as NodeJS.ErrnoException;
if (errorWithCode.code !== "ENOENT") {
throw error;
}
}
const storage = collectComposeStorageReferences(composeContent, [
stacksRoot,
appDataRoot,
]);
const interpolatedStorage = collectComposeStorageReferences(
interpolateComposeVariables(composeContent, interpolationEnv),
[stacksRoot, appDataRoot],
);
cleanupSummary.bindMountDirectories = Array.from(storage.bindMountDirectories).sort((a, b) => a.localeCompare(b));
cleanupSummary.namedVolumeSources = Array.from(storage.namedVolumeSources).sort((a, b) => a.localeCompare(b));
if (interpolatedStorage.bindMountDirectories.size > 0) {
cleanupSummary.bindMountDirectories = Array.from(
interpolatedStorage.bindMountDirectories,
).sort((a, b) => a.localeCompare(b));
}
if (interpolatedStorage.namedVolumeSources.size > 0) {
cleanupSummary.namedVolumeSources = Array.from(
interpolatedStorage.namedVolumeSources,
).sort((a, b) => a.localeCompare(b));
}
logServerAction({
layer: "system",
action: "store.compose.cleanup.targets",
status: "info",
message: "Resolved compose cleanup targets",
meta: {
composePath: input.composePath,
bindMountDirectories: cleanupSummary.bindMountDirectories,
namedVolumeSources: cleanupSummary.namedVolumeSources,
},
});
await Promise.all(
cleanupSummary.bindMountDirectories.map((directoryPath) =>
rm(directoryPath, {
recursive: true,
force: true,
}),
),
);
for (const volumeName of cleanupSummary.namedVolumeSources) {
try {
await execFileAsync("docker", ["volume", "rm", volumeName]);
} catch {
// Best effort cleanup for legacy named volumes.
}
}
// Also remove the app's root directory under appDataRoot (e.g. /DATA/AppData/<appId>).
// Individual bind mount entries point to sub-directories, so the parent folder
// remains unless we explicitly delete it.
const appDataDir = path.join(appDataRoot, stackAppId);
const normalizedAppDataDir = path.resolve(appDataDir);
const normalizedAppDataRoot = path.resolve(appDataRoot);
if (
isPathWithinRoot(normalizedAppDataDir, normalizedAppDataRoot) &&
normalizedAppDataDir !== normalizedAppDataRoot
) {
await rm(normalizedAppDataDir, { recursive: true, force: true });
}
}
const normalizedStackDir = path.resolve(stackDir);
const normalizedStacksRoot = path.resolve(stacksRoot);
const composeFileName = path.basename(input.composePath).toLowerCase();
const isComposeStackFile =
composeFileName === "docker-compose.yml" ||
composeFileName === "docker-compose.yaml";
const isKnownStackDirectory =
isPathWithinRoot(normalizedStackDir, normalizedStacksRoot) ||
path.basename(path.dirname(normalizedStackDir)) === "Apps";
const shouldRemoveStackDir =
isComposeStackFile &&
normalizedStackDir !== normalizedStacksRoot &&
isKnownStackDirectory;
if (shouldRemoveStackDir) {
await rm(normalizedStackDir, {
recursive: true,
force: true,
});
cleanupSummary.removedStackDir = normalizedStackDir;
}
},
);
}