mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
fix(databases): skip empty volumeName mounts during database rebuild
This commit is contained in:
parent
1572008cdf
commit
6f1f009619
317
apps/dokploy/__test__/databases/rebuild-database.test.ts
Normal file
317
apps/dokploy/__test__/databases/rebuild-database.test.ts
Normal file
@ -0,0 +1,317 @@
|
||||
import { apiCreateMount, apiUpdateMount } from "@dokploy/server/db/schema";
|
||||
import { rebuildDatabase } from "@dokploy/server/utils/databases/rebuild";
|
||||
import { parse, quote } from "shell-quote";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type DatabaseType =
|
||||
| "libsql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "postgres"
|
||||
| "redis";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findFirst: vi.fn(),
|
||||
removeService: vi.fn(),
|
||||
execAsync: vi.fn(),
|
||||
execAsyncRemote: vi.fn(),
|
||||
deployLibsql: vi.fn(),
|
||||
deployMariadb: vi.fn(),
|
||||
deployMongo: vi.fn(),
|
||||
deployMySql: vi.fn(),
|
||||
deployPostgres: vi.fn(),
|
||||
deployRedis: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
libsql: { findFirst: mocks.findFirst },
|
||||
mariadb: { findFirst: mocks.findFirst },
|
||||
mongo: { findFirst: mocks.findFirst },
|
||||
mysql: { findFirst: mocks.findFirst },
|
||||
postgres: { findFirst: mocks.findFirst },
|
||||
redis: { findFirst: mocks.findFirst },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/docker/utils", () => ({
|
||||
removeService: mocks.removeService,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsync: mocks.execAsync,
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/libsql", () => ({
|
||||
deployLibsql: mocks.deployLibsql,
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/mariadb", () => ({
|
||||
deployMariadb: mocks.deployMariadb,
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/mongo", () => ({
|
||||
deployMongo: mocks.deployMongo,
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/mysql", () => ({
|
||||
deployMySql: mocks.deployMySql,
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/postgres", () => ({
|
||||
deployPostgres: mocks.deployPostgres,
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/redis", () => ({
|
||||
deployRedis: mocks.deployRedis,
|
||||
}));
|
||||
|
||||
const DEPLOY_BY_TYPE = {
|
||||
libsql: mocks.deployLibsql,
|
||||
mariadb: mocks.deployMariadb,
|
||||
mongo: mocks.deployMongo,
|
||||
mysql: mocks.deployMySql,
|
||||
postgres: mocks.deployPostgres,
|
||||
redis: mocks.deployRedis,
|
||||
} as const;
|
||||
|
||||
const volumeMount = (volumeName: string | null) => ({
|
||||
type: "volume" as const,
|
||||
volumeName,
|
||||
});
|
||||
|
||||
function setDatabase(
|
||||
input: {
|
||||
appName?: string;
|
||||
serverId?: string | null;
|
||||
mounts?: Array<Record<string, unknown>>;
|
||||
} = {},
|
||||
) {
|
||||
const database = {
|
||||
appName: input.appName ?? "pg-app",
|
||||
serverId: input.serverId ?? null,
|
||||
mounts: input.mounts ?? [],
|
||||
};
|
||||
mocks.findFirst.mockResolvedValue(database);
|
||||
return database;
|
||||
}
|
||||
|
||||
async function runRebuild(databaseId: string, type: DatabaseType) {
|
||||
const promise = rebuildDatabase(databaseId, type);
|
||||
await vi.advanceTimersByTimeAsync(6000);
|
||||
return promise;
|
||||
}
|
||||
|
||||
describe("rebuildDatabase", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
mocks.findFirst.mockResolvedValue(undefined);
|
||||
mocks.removeService.mockResolvedValue(undefined);
|
||||
mocks.execAsync.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
for (const fn of Object.values(DEPLOY_BY_TYPE))
|
||||
fn.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("skips volume mounts with a null/empty volumeName and still redeploys the database", async () => {
|
||||
setDatabase({
|
||||
mounts: [
|
||||
volumeMount("vol-1"),
|
||||
volumeMount(null),
|
||||
volumeMount(""),
|
||||
volumeMount("vol-2"),
|
||||
{ type: "bind", hostPath: "/host" },
|
||||
{ type: "file", filePath: "f.txt", content: "c" },
|
||||
],
|
||||
});
|
||||
|
||||
await runRebuild("db-1", "postgres");
|
||||
|
||||
expect(mocks.removeService).toHaveBeenCalledWith("pg-app", null);
|
||||
expect(mocks.execAsync).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.execAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"docker volume rm vol-1 --force",
|
||||
);
|
||||
expect(mocks.execAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"docker volume rm vol-2 --force",
|
||||
);
|
||||
expect(mocks.deployPostgres).toHaveBeenCalledWith("db-1");
|
||||
});
|
||||
|
||||
it("never constructs `docker volume rm '' --force` for an empty volumeName", async () => {
|
||||
setDatabase({
|
||||
mounts: [volumeMount(null), volumeMount(""), volumeMount("ok")],
|
||||
});
|
||||
|
||||
await runRebuild("db-2", "postgres");
|
||||
|
||||
const commands = mocks.execAsync.mock.calls.map((c) => c[0]);
|
||||
for (const cmd of commands) {
|
||||
expect(cmd).not.toMatch(/docker volume rm '' --force/);
|
||||
}
|
||||
expect(commands).toEqual(["docker volume rm ok --force"]);
|
||||
expect(mocks.deployPostgres).toHaveBeenCalledWith("db-2");
|
||||
});
|
||||
|
||||
it("removes valid volume mounts on a remote server and skips bad ones via execAsyncRemote", async () => {
|
||||
setDatabase({
|
||||
appName: "remote-app",
|
||||
serverId: "server-1",
|
||||
mounts: [volumeMount(null), volumeMount("remote-vol")],
|
||||
});
|
||||
|
||||
await runRebuild("db-3", "postgres");
|
||||
|
||||
expect(mocks.removeService).toHaveBeenCalledWith("remote-app", "server-1");
|
||||
expect(mocks.execAsync).not.toHaveBeenCalled();
|
||||
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
|
||||
"server-1",
|
||||
"docker volume rm remote-vol --force",
|
||||
);
|
||||
expect(mocks.deployPostgres).toHaveBeenCalledWith("db-3");
|
||||
});
|
||||
|
||||
it("shell-quotes the volume name so an injected command stays a single argument", async () => {
|
||||
const name = "v;touch /tmp/pwned";
|
||||
expect(parse(quote([name]))).toEqual([name]);
|
||||
setDatabase({ mounts: [volumeMount(name)] });
|
||||
|
||||
await runRebuild("db-4", "postgres");
|
||||
|
||||
const cmd = mocks.execAsync.mock.calls[0]?.[0];
|
||||
expect(cmd).toBe(`docker volume rm ${quote([name])} --force`);
|
||||
expect(parse(cmd ?? "")).not.toContain("touch");
|
||||
});
|
||||
|
||||
it("routes to the matching deploy function for each database type", async () => {
|
||||
const types: DatabaseType[] = [
|
||||
"postgres",
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"mongo",
|
||||
"redis",
|
||||
"libsql",
|
||||
];
|
||||
|
||||
for (const type of types) {
|
||||
vi.clearAllMocks();
|
||||
setDatabase({ mounts: [volumeMount("vol")] });
|
||||
|
||||
await runRebuild(`id-${type}`, type);
|
||||
|
||||
const expected = DEPLOY_BY_TYPE[type];
|
||||
for (const [t, fn] of Object.entries(DEPLOY_BY_TYPE)) {
|
||||
if (fn === expected) {
|
||||
expect(fn, `${t} should be called for ${type}`).toHaveBeenCalledWith(
|
||||
`id-${type}`,
|
||||
);
|
||||
} else {
|
||||
expect(
|
||||
fn,
|
||||
`${t} should not be called for ${type}`,
|
||||
).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("mount volumeName schema validation", () => {
|
||||
const baseCreate = {
|
||||
serviceId: "svc-1",
|
||||
serviceType: "postgres" as const,
|
||||
mountPath: "/data",
|
||||
};
|
||||
|
||||
describe("apiCreateMount", () => {
|
||||
it("accepts a volume mount with a valid volumeName", () => {
|
||||
expect(
|
||||
apiCreateMount.safeParse({
|
||||
...baseCreate,
|
||||
type: "volume",
|
||||
volumeName: "Vol_1.2-3",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing", {}],
|
||||
["empty", { volumeName: "" }],
|
||||
["invalid", { volumeName: "bad name!" }],
|
||||
["starting with a separator", { volumeName: ".starts-with-dot" }],
|
||||
])("rejects a volume mount with %s volumeName", (_label, extra) => {
|
||||
const result = apiCreateMount.safeParse({
|
||||
...baseCreate,
|
||||
type: "volume",
|
||||
...extra,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) => i.path.includes("volumeName")),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["bind", { type: "bind", hostPath: "/host" }],
|
||||
["file", { type: "file", filePath: "config.env", content: "KEY=value" }],
|
||||
])("does not require volumeName for a %s mount", (_label, extra) => {
|
||||
expect(
|
||||
apiCreateMount.safeParse({ ...baseCreate, ...extra }).success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiUpdateMount", () => {
|
||||
it("rejects flipping type to volume without a volumeName", () => {
|
||||
const result = apiUpdateMount.safeParse({
|
||||
mountId: "m-1",
|
||||
type: "volume",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) => i.path.includes("volumeName")),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a valid volume update and rejects an invalid one", () => {
|
||||
expect(
|
||||
apiUpdateMount.safeParse({
|
||||
mountId: "m-1",
|
||||
type: "volume",
|
||||
volumeName: "new-vol",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
apiUpdateMount.safeParse({
|
||||
mountId: "m-1",
|
||||
type: "volume",
|
||||
volumeName: "bad name",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not require volumeName for a bind update or for an update that leaves type untouched", () => {
|
||||
expect(
|
||||
apiUpdateMount.safeParse({
|
||||
mountId: "m-1",
|
||||
type: "bind",
|
||||
hostPath: "/host",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
apiUpdateMount.safeParse({ mountId: "m-1", mountPath: "/new-data" })
|
||||
.success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -11,6 +11,7 @@ import { mongo } from "./mongo";
|
||||
import { mysql } from "./mysql";
|
||||
import { postgres } from "./postgres";
|
||||
import { redis } from "./redis";
|
||||
import { VOLUME_NAME_MESSAGE, VOLUME_NAME_REGEX } from "./utils";
|
||||
|
||||
export const serviceType = pgEnum("serviceType", [
|
||||
"application",
|
||||
@ -122,6 +123,27 @@ const createSchema = createInsertSchema(mounts, {
|
||||
]),
|
||||
});
|
||||
|
||||
const validateVolumeMountName = (
|
||||
data: { type?: string; volumeName?: string | null },
|
||||
ctx: z.RefinementCtx,
|
||||
) => {
|
||||
if (data.type === "volume") {
|
||||
if (!data.volumeName) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Volume name is required for volume mounts",
|
||||
path: ["volumeName"],
|
||||
});
|
||||
} else if (!VOLUME_NAME_REGEX.test(data.volumeName)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: VOLUME_NAME_MESSAGE,
|
||||
path: ["volumeName"],
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const apiCreateMount = createSchema
|
||||
.pick({
|
||||
type: true,
|
||||
@ -134,7 +156,8 @@ export const apiCreateMount = createSchema
|
||||
})
|
||||
.extend({
|
||||
serviceId: z.string().min(1),
|
||||
});
|
||||
})
|
||||
.superRefine(validateVolumeMountName);
|
||||
|
||||
export const apiFindOneMount = z.object({
|
||||
mountId: z.string().min(1),
|
||||
@ -157,6 +180,9 @@ export const apiFindMountByApplicationId = z.object({
|
||||
serviceId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiUpdateMount = createSchema.partial().extend({
|
||||
mountId: z.string().min(1),
|
||||
});
|
||||
export const apiUpdateMount = createSchema
|
||||
.partial()
|
||||
.extend({
|
||||
mountId: z.string().min(1),
|
||||
})
|
||||
.superRefine(validateVolumeMountName);
|
||||
|
||||
@ -41,7 +41,8 @@ export const rebuildDatabase = async (
|
||||
|
||||
for (const mount of database.mounts) {
|
||||
if (mount.type === "volume") {
|
||||
const command = `docker volume rm ${quote([mount?.volumeName ?? ""])} --force`;
|
||||
if (!mount.volumeName) continue;
|
||||
const command = `docker volume rm ${quote([mount.volumeName])} --force`;
|
||||
if (database.serverId) {
|
||||
await execAsyncRemote(database.serverId, command);
|
||||
} else {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user