homeio
PublicForked from doctor-io/homeio
compose-runner.test.ts
import { parseComposeFile } from "@/lib/server/modules/docker/compose-parser";
import {
applyWebUiPortOverride,
cleanupComposeDataOnUninstall,
collectComposeBindMountOwnershipOverrides,
collectMaterializationDirectories,
normalizeComposeStorageBindings,
resolveComposeBindMountOwnershipOverrides,
sanitizeStackName,
} from "@/lib/server/modules/docker/compose-runner";
import {
resolveStoreAppDataRoot,
resolveStoreStacksRoot,
} from "@/lib/server/storage/data-root";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
describe("compose runner helpers", () => {
it("overrides first numeric host port mapping", () => {
const compose = `
services:
app:
image: test:latest
ports:
- "3000:3000"
`;
const updated = applyWebUiPortOverride(compose, 3100);
expect(updated).toContain(`- "3100:3000"`);
});
it("keeps compose unchanged when no published port mapping exists", () => {
const compose = `
services:
app:
image: test:latest
network_mode: host
`;
const updated = applyWebUiPortOverride(compose, 3100);
expect(updated).toBe(compose);
});
it("sanitizes stack names", () => {
expect(sanitizeStackName("AdGuard Home!")).toBe("adguard-home");
});
it("converts named volumes to bind mounts under DATA/Apps in legacy mode", () => {
const compose = `
services:
twofauth:
image: 2fauth/2fauth:latest
volumes:
- "big-bear-2fauth_data:/2fauth"
- "./config:/config"
volumes:
big-bear-2fauth_data:
`;
const stacksRoot = "/Users/ahmedtabib/Code/home-server/stacks";
const appDataRoot = "/Users/ahmedtabib/Code/home-server/DATA/AppData";
const normalized = normalizeComposeStorageBindings(
compose,
stacksRoot,
appDataRoot,
{
appId: "twofauth",
strategy: "legacy_named_source",
},
);
expect(normalized.composeContent).toContain(
'- "/Users/ahmedtabib/Code/home-server/DATA/AppData/big-bear-2fauth_data:/2fauth"',
);
expect(normalized.composeContent).toContain('- "./config:/config"');
expect(normalized.composeContent).not.toContain(
"\nvolumes:\n big-bear-2fauth_data:",
);
expect(Array.from(normalized.bindMountDirectories)).toContain(
"/Users/ahmedtabib/Code/home-server/DATA/AppData/big-bear-2fauth_data",
);
});
it("converts named volumes to app-scoped target paths in app_target_path mode", () => {
const compose = `
services:
app:
image: home-assistant:latest
volumes:
- "big-bear-home-assistant_config:/config"
- "big-bear-home-assistant_media:/var/lib/homeassistant/media"
volumes:
big-bear-home-assistant_config:
big-bear-home-assistant_media:
`;
const stacksRoot = "/DATA/AppData";
const appDataRoot = "/DATA/AppData";
const normalized = normalizeComposeStorageBindings(
compose,
stacksRoot,
appDataRoot,
{
appId: "home-assistant",
strategy: "app_target_path",
},
);
expect(normalized.composeContent).toContain(
'- "/DATA/AppData/home-assistant/config:/config"',
);
expect(normalized.composeContent).toContain(
'- "/DATA/AppData/home-assistant/var/lib/homeassistant/media:/var/lib/homeassistant/media"',
);
expect(normalized.composeContent).not.toContain(
"big-bear-home-assistant_config:",
);
expect(normalized.composeContent).not.toContain(
"big-bear-home-assistant_media:",
);
expect(Array.from(normalized.bindMountDirectories)).toContain(
"/DATA/AppData/home-assistant/config",
);
expect(Array.from(normalized.bindMountDirectories)).toContain(
"/DATA/AppData/home-assistant/var/lib/homeassistant/media",
);
});
it("converts long-form named volumes to app-scoped bind mounts in app_target_path mode", () => {
const compose = `
services:
cloudflared:
image: wisdomsky/cloudflared-web:latest
volumes:
- type: volume
source: cloudflared_config
target: /config
volumes:
cloudflared_config:
`;
const normalized = normalizeComposeStorageBindings(
compose,
"/DATA/AppData",
"/DATA/AppData",
{
appId: "cloudflared",
strategy: "app_target_path",
},
);
expect(normalized.composeContent).toContain("- type: bind");
expect(normalized.composeContent).toContain(
"source: /DATA/AppData/cloudflared/config",
);
expect(parseComposeFile(normalized.composeContent)).not.toBeNull();
expect(normalized.composeContent).not.toContain("cloudflared_config:");
expect(Array.from(normalized.bindMountDirectories)).toContain(
"/DATA/AppData/cloudflared/config",
);
});
it("rewrites CasaOS app-data bind mounts into app-scoped paths on fresh installs", () => {
const compose = `
services:
cloudflared:
image: wisdomsky/cloudflared-web:latest
volumes:
- type: bind
source: /DATA/AppData/casaos-cloudflared/config
target: /config
- "/DATA/AppData/data:/data"
`;
const normalized = normalizeComposeStorageBindings(
compose,
"/DATA/AppData",
"/DATA/AppData",
{
appId: "cloudflared",
strategy: "app_target_path",
},
);
expect(normalized.composeContent).toContain(
"source: /DATA/AppData/cloudflared/config",
);
expect(normalized.composeContent).toContain(
'- "/DATA/AppData/cloudflared/data:/data"',
);
expect(Array.from(normalized.bindMountDirectories)).toContain(
"/DATA/AppData/cloudflared/config",
);
expect(Array.from(normalized.bindMountDirectories)).toContain(
"/DATA/AppData/cloudflared/data",
);
});
it("collects app-data ownership overrides for Grafana bind mounts", () => {
const compose = `
services:
grafana:
image: grafana/grafana:12.1.4
volumes:
- type: bind
source: /DATA/AppData/grafana/data
target: /var/lib/grafana
`;
expect(collectComposeBindMountOwnershipOverrides(compose)).toEqual([
{
directoryPath: "/DATA/AppData/grafana/data",
uid: 472,
gid: 472,
},
]);
});
it("collects app-data ownership overrides for n8n bind mounts", () => {
const compose = `
services:
n8n:
image: n8nio/n8n:1.123.0
volumes:
- type: bind
source: /DATA/AppData/n8n
target: /home/node/.n8n
`;
expect(collectComposeBindMountOwnershipOverrides(compose)).toEqual([
{
directoryPath: "/DATA/AppData/n8n",
uid: 1000,
gid: 1000,
},
]);
});
it("resolves Grafana ownership overrides with AppID interpolation", () => {
const compose = `
services:
grafana:
image: grafana/grafana:12.1.4
volumes:
- type: bind
source: /DATA/AppData/$AppID/data
target: /var/lib/grafana
`;
expect(
resolveComposeBindMountOwnershipOverrides(compose, {
AppID: "grafana",
}),
).toEqual([
{
directoryPath: "/DATA/AppData/grafana/data",
uid: 472,
gid: 472,
},
]);
});
it("resolves n8n ownership overrides with AppID interpolation", () => {
const compose = `
services:
n8n:
image: n8nio/n8n:1.123.0
volumes:
- type: bind
source: /DATA/AppData/$AppID
target: /home/node/.n8n
`;
expect(
resolveComposeBindMountOwnershipOverrides(compose, {
AppID: "n8n",
}),
).toEqual([
{
directoryPath: "/DATA/AppData/n8n",
uid: 1000,
gid: 1000,
},
]);
});
it("uses explicit numeric service user ownership before image-specific rules", () => {
const compose = `
services:
app:
image: grafana/grafana:12.1.4
user: "1234:5678"
volumes:
- type: bind
source: /DATA/AppData/app/data
target: /var/lib/grafana
`;
expect(collectComposeBindMountOwnershipOverrides(compose)).toEqual([
{
directoryPath: "/DATA/AppData/app/data",
uid: 1234,
gid: 5678,
},
]);
});
it("uses PUID and PGID environment values when service user is not set", () => {
const compose = `
services:
app:
image: lscr.io/linuxserver/app:latest
environment:
PUID: "1001"
PGID: "1002"
volumes:
- type: bind
source: /DATA/AppData/app/config
target: /config
`;
expect(collectComposeBindMountOwnershipOverrides(compose)).toEqual([
{
directoryPath: "/DATA/AppData/app/config",
uid: 1001,
gid: 1002,
},
]);
});
it("uses list-style PUID and PGID environment values", () => {
const compose = `
services:
app:
image: lscr.io/linuxserver/app:latest
environment:
- PUID=1003
- PGID=1004
volumes:
- /DATA/AppData/app/config:/config
`;
expect(collectComposeBindMountOwnershipOverrides(compose)).toEqual([
{
directoryPath: "/DATA/AppData/app/config",
uid: 1003,
gid: 1004,
},
]);
});
it("includes ownership override directories in materialization targets", () => {
const directories = collectMaterializationDirectories(
["/DATA/AppData/grafana/var/lib/grafana"],
[
{
directoryPath: "/DATA/AppData/grafana/data",
uid: 472,
gid: 472,
},
],
);
expect(Array.from(directories)).toEqual([
"/DATA/AppData/grafana/var/lib/grafana",
"/DATA/AppData/grafana/data",
]);
});
it("does not collect ownership overrides for unrelated bind mounts", () => {
const compose = `
services:
app:
image: nginx:latest
volumes:
- /DATA/AppData/nginx/data:/usr/share/nginx/html
`;
expect(collectComposeBindMountOwnershipOverrides(compose)).toEqual([]);
});
it("removes the installed stack directory during uninstall cleanup without deleting app data by default", async () => {
const stacksRoot = resolveStoreStacksRoot();
const appId = `cleanup-test-${Date.now()}`;
const stackDir = path.join(stacksRoot, appId);
const composePath = path.join(stackDir, "docker-compose.yml");
// Use os.tmpdir() so bindDir is outside stackDir — avoids false ENOENT when
// the recursive stackDir removal also wipes a nested bindDir (happens in dev
// where stacksRoot and appDataRoot both resolve to DATA/AppData).
const bindDir = await mkdtemp(path.join(os.tmpdir(), "homeio-test-bind-"));
try {
await mkdir(stackDir, { recursive: true });
await writeFile(
composePath,
`services:
app:
image: nginx:latest
volumes:
- "${bindDir}:/data"
`,
"utf8",
);
await cleanupComposeDataOnUninstall({
composePath,
removeVolumes: false,
});
await expect(
rm(stackDir, {
recursive: true,
force: false,
}),
).rejects.toThrow();
await expect(
rm(bindDir, {
recursive: true,
force: false,
}),
).resolves.toBeUndefined();
} finally {
await rm(bindDir, { recursive: true, force: true });
}
});
it("removes long-form app-data bind mount directories during uninstall cleanup when requested", async () => {
const stacksRoot = resolveStoreStacksRoot();
const appDataRoot = resolveStoreAppDataRoot();
const appId = `cleanup-long-form-${Date.now()}`;
const stackDir = path.join(stacksRoot, appId);
const composePath = path.join(stackDir, "docker-compose.yml");
const bindDir = path.join(appDataRoot, appId, "config");
await mkdir(bindDir, { recursive: true });
await mkdir(stackDir, { recursive: true });
await writeFile(
composePath,
`services:
app:
image: nginx:latest
volumes:
- type: bind
source: ${bindDir}
target: /config
`,
"utf8",
);
await cleanupComposeDataOnUninstall({
composePath,
removeVolumes: true,
});
await expect(
rm(stackDir, {
recursive: true,
force: false,
}),
).rejects.toThrow();
await expect(
rm(bindDir, {
recursive: true,
force: false,
}),
).rejects.toThrow();
});
it("removes interpolated AppID app-data bind mount directories during uninstall cleanup when requested", async () => {
const stacksRoot = resolveStoreStacksRoot();
const appDataRoot = resolveStoreAppDataRoot();
const appId = `cleanup-appid-${Date.now()}`;
const stackDir = path.join(stacksRoot, appId);
const composePath = path.join(stackDir, "docker-compose.yml");
const bindDir = path.join(appDataRoot, appId, "data");
await mkdir(bindDir, { recursive: true });
await mkdir(stackDir, { recursive: true });
await writeFile(path.join(stackDir, ".env"), "", "utf8");
await writeFile(
composePath,
`services:
app:
image: grafana/grafana:12.1.4
volumes:
- type: bind
source: ${appDataRoot}/$AppID/data
target: /var/lib/grafana
`,
"utf8",
);
await cleanupComposeDataOnUninstall({
composePath,
removeVolumes: true,
});
await expect(
rm(bindDir, {
recursive: true,
force: false,
}),
).rejects.toThrow();
});
});