Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 53 additions & 9 deletions apps/cli/src/lib/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,18 +603,62 @@ function composeProjectName(prev: Record<string, string>): string {
*
* A fresh install uses a subdirectory (`…/data/pgdata`) rather than the bare
* mount root: initdb against a mount root fails on quirky host filesystems with
* "Operation not permitted" (WAL preallocation / lost+found — see #350). But a
* pre-existing install already has its DB at the mount ROOT, and moving PGDATA
* would make Postgres init a fresh empty DB and orphan the old one. So the
* decision is made ONCE and then pinned in `.env` (same sticky rule as
* COMPOSE_PROJECT_NAME): re-runs reuse it; a volume that predates this pin keeps
* the root. The check uses the resolved project name so it inspects the right
* `<project>_postgres_data` volume.
* "Operation not permitted" (WAL preallocation / lost+found — see #350). A
* pre-existing install may have its DB at the mount ROOT or in the `pgdata/`
* subdirectory, depending on how it was created; moving PGDATA would make
* Postgres init a fresh empty DB and orphan the old one. So the decision is
* made ONCE and then pinned in `.env` (same sticky rule as
* COMPOSE_PROJECT_NAME): re-runs reuse it; a volume that predates this pin is
* probed for PG_VERSION and the existing path is preserved. The check uses the
* resolved project name so it inspects the right `<project>_postgres_data`
* volume.
*/
const PGDATA_ROOT = "/var/lib/postgresql/data";

/**
* Probe the existing `<project>_postgres_data` volume to find where the
* Postgres cluster lives. Returns:
* "subdir" – `pgdata/PG_VERSION` exists (fresh-install default),
* "root" – `PG_VERSION` exists at the volume root (legacy pre-subdir data),
* "empty" – the volume has no entries (safe to initialize in the subdir),
* "unknown" – non-empty but no recognizable PG_VERSION; caller should fail.
*/
function pgDataLocation(project: string): "subdir" | "root" | "empty" | "unknown" {
const r = spawnSync(
"docker",
[
"run",
"--rm",
"-v",
`${project}_postgres_data:/data:ro`,
"alpine:3",
"sh",
"-c",
"if [ -f /data/pgdata/PG_VERSION ]; then echo subdir; exit 0; fi; " +
"if [ -f /data/PG_VERSION ]; then echo root; exit 0; fi; " +
'if [ -z "$(ls -A /data 2>/dev/null)" ]; then echo empty; exit 0; fi; ' +
"echo unknown",
],
{ encoding: "utf8" },
);
if (r.status !== 0 || !r.stdout) return "unknown";
const out = r.stdout.trim();
if (out === "subdir" || out === "root" || out === "empty") return out;
return "unknown";
}

function resolvePgData(prev: Record<string, string>): string {
if (prev.OPENSHIP_PGDATA) return prev.OPENSHIP_PGDATA; // decided already — never move it
return dbVolumeExists(composeProjectName(prev)) ? PGDATA_ROOT : `${PGDATA_ROOT}/pgdata`;
const project = composeProjectName(prev);
if (!dbVolumeExists(project)) return `${PGDATA_ROOT}/pgdata`;
const loc = pgDataLocation(project);
if (loc === "subdir" || loc === "empty") return `${PGDATA_ROOT}/pgdata`;
if (loc === "root") return PGDATA_ROOT;
throw new Error(
`Postgres data volume ${project}_postgres_data already exists but its ` +
`PGDATA cannot be determined: no PG_VERSION in / or /pgdata and the volume ` +
`is not empty. Set OPENSHIP_PGDATA in ${ENV_FILE} to the correct path and re-run.`,
);
}

/**
Expand Down Expand Up @@ -1101,7 +1145,7 @@ function renderEnv(
`OPENSHIP_IMAGE_REGISTRY=${cfg.registry}`,
`OPENSHIP_VERSION=${opts.version || (typeof __CLI_VERSION__ === "string" ? __CLI_VERSION__ : "latest")}`,
`POSTGRES_PASSWORD=${keepSecret(prev, "POSTGRES_PASSWORD")}`,
// Pinned once (see resolvePgData): fresh install → subdir, existing volume → root.
// Pinned once (see resolvePgData): fresh/empty → subdir, existing cluster in pgdata/ → subdir, existing cluster at root → root.
`OPENSHIP_PGDATA=${resolvePgData(prev)}`,
`BETTER_AUTH_SECRET=${keepSecret(prev, "BETTER_AUTH_SECRET")}`,
`INTERNAL_TOKEN=${keepSecret(prev, "INTERNAL_TOKEN")}`,
Expand Down
117 changes: 117 additions & 0 deletions apps/cli/test/unit/compose-pgdata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, it, expect, beforeEach, vi } from "vitest";

/**
* OPENSHIP_PGDATA detection: an existing Postgres data volume may hold the
* cluster at the volume root (legacy installs) or in the `pgdata/` subdirectory
* (fresh installs since the EPERM fix). resolvePgData must pick the path that
* actually contains the cluster, or fail loudly when it cannot tell.
*/

const h = vi.hoisted(() => ({
existing: new Set<string>(),
written: new Map<string, string>(),
composeCalls: [] as string[][],
/** Mock state for the postgres data volume. */
volumeState: "missing" as string,
}));

vi.mock("node:child_process", () => ({
execFile: (_c: unknown, _a: unknown, cb: (e: null, o: { stdout: string }) => void) =>
cb(null, { stdout: "" }),
spawnSync: (cmd: string, args: string[] = []) => {
if (cmd === "docker" && args[0] === "compose") h.composeCalls.push(args);
// Volume existence probe used by dbVolumeExists.
if (cmd === "docker" && args[0] === "volume") {
if (h.volumeState === "missing") return { status: 1, stdout: "", stderr: "" };
return { status: 0, stdout: "", stderr: "" };
}
// Volume contents probe used by pgDataLocation (PG_VERSION in / or /pgdata).
if (cmd === "docker" && args[0] === "run") {
const loc = h.volumeState === "missing" ? "empty" : h.volumeState;
if (loc === "empty") return { status: 0, stdout: "empty\n", stderr: "" };
if (loc === "root") return { status: 0, stdout: "root\n", stderr: "" };
if (loc === "subdir") return { status: 0, stdout: "subdir\n", stderr: "" };
return { status: 0, stdout: "unknown\n", stderr: "" };
}
return { status: 0, stdout: "", stderr: "" };
},
}));

vi.mock("node:fs", () => ({
existsSync: (p: string) => h.existing.has(String(p)),
mkdirSync: () => undefined,
readFileSync: (p: string) => {
const v = h.written.get(String(p));
if (v === undefined) throw new Error(`ENOENT: ${p}`);
return v;
},
writeFileSync: (p: string, data: string) => {
h.written.set(String(p), String(data));
h.existing.add(String(p));
},
}));

vi.mock("../../src/lib/source-install", () => ({
readSourceInstall: () => null,
}));

vi.mock("@repo/adapters/proxy", () => ({
sanitizeEdgeVhosts: async () => {},
}));

vi.mock("@repo/adapters", async () => {
const lua = await import("../../../../packages/adapters/src/infra/openresty-lua");
return {
systemCatalog: { installs: { docker: () => ({ supported: false }) } },
EDGE_HOST_STATE_DIR: lua.EDGE_HOST_STATE_DIR,
EDGE_CONTAINER_MOUNTS: lua.EDGE_CONTAINER_MOUNTS,
invalidateEdgeContainer: () => {},
LocalExecutor: class {},
};
});

import { composeUp, composePaths } from "../../src/lib/compose";

/** The `.env` this run wrote, parsed back into key → value. */
function writtenEnv(): Record<string, string> {
const out: Record<string, string> = {};
for (const line of (h.written.get(composePaths.env) ?? "").split("\n")) {
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
if (m) out[m[1]] = m[2];
}
return out;
}

beforeEach(() => {
h.existing = new Set();
h.written = new Map();
h.composeCalls = [];
h.volumeState = "missing";
});

describe("resolvePgData — OPENSHIP_PGDATA path detection", () => {
it("uses the pgdata/ subdir on a fresh install", async () => {
const res = await composeUp({});
expect(res.ok).toBe(true);
expect(writtenEnv().OPENSHIP_PGDATA).toBe("/var/lib/postgresql/data/pgdata");
});

it("keeps the pgdata/ subdir when an existing volume already holds the cluster there", async () => {
h.volumeState = "subdir";
const res = await composeUp({});
expect(res.ok).toBe(true);
expect(writtenEnv().OPENSHIP_PGDATA).toBe("/var/lib/postgresql/data/pgdata");
});

it("keeps the volume root when an existing legacy cluster lives there", async () => {
h.volumeState = "root";
const res = await composeUp({});
expect(res.ok).toBe(true);
expect(writtenEnv().OPENSHIP_PGDATA).toBe("/var/lib/postgresql/data");
});

it("fails loudly when the existing volume is non-empty but has no recognizable PG_VERSION", async () => {
h.volumeState = "unknown";
await expect(composeUp({})).rejects.toThrow(/cannot be determined/);
});
});