IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

compose-runner.test.ts

Open raw
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();
  });
});
bytes
13,419
lines
510
Type
ts
Latest commit
16 October 2026