From 3f90bb9159897ffeed12186776676a851dd4ce18 Mon Sep 17 00:00:00 2001 From: Narciso Date: Thu, 15 Oct 2026 17:56:51 -0400 Subject: [PATCH] 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 --- .../__test__/export-import/apply.test.ts | 24 ++ .../__test__/export-import/plan.test.ts | 24 ++ .../export-import/requirements.test.ts | 44 ++- .../__test__/export-import/rotate.test.ts | 35 +++ .../__test__/export-import/roundtrip.test.ts | 24 ++ .../project/export-import/import-project.tsx | 5 + .../project/export-import/resolution-step.tsx | 17 +- .../api/routers/project-export-import.ts | 124 +++++++- .../services/project-export-import/apply.ts | 59 +++- .../project-export-import/apply/children.ts | 53 ++-- .../project-export-import/apply/services.ts | 30 +- .../project-export-import/apply/shared.ts | 1 + .../project-export-import/export-job.ts | 13 +- .../project-export-import/import-job.ts | 54 ++-- .../services/project-export-import/plan.ts | 267 ++++++++++++++++-- .../project-export-import/post-apply.ts | 75 ++++- .../project-export-import/requirements.ts | 72 +++-- .../services/project-export-import/rotate.ts | 30 +- 18 files changed, 798 insertions(+), 153 deletions(-) diff --git a/apps/dokploy/__test__/export-import/apply.test.ts b/apps/dokploy/__test__/export-import/apply.test.ts index 4b4dacb26..98c13f9b4 100644 --- a/apps/dokploy/__test__/export-import/apply.test.ts +++ b/apps/dokploy/__test__/export-import/apply.test.ts @@ -185,6 +185,30 @@ const readDb = (): TransferDb => 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; diff --git a/apps/dokploy/__test__/export-import/plan.test.ts b/apps/dokploy/__test__/export-import/plan.test.ts index ab17670f1..9a45d04f2 100644 --- a/apps/dokploy/__test__/export-import/plan.test.ts +++ b/apps/dokploy/__test__/export-import/plan.test.ts @@ -138,6 +138,30 @@ const fakeDb = (): TransferDb => { 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 () => [ { diff --git a/apps/dokploy/__test__/export-import/requirements.test.ts b/apps/dokploy/__test__/export-import/requirements.test.ts index 2166352e7..0c71087cf 100644 --- a/apps/dokploy/__test__/export-import/requirements.test.ts +++ b/apps/dokploy/__test__/export-import/requirements.test.ts @@ -244,26 +244,53 @@ describe("detectRequirements", () => { }); }); - it("detects a certificate requirement via customCertResolver, not a direct FK", async () => { + 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", - certificateType: "custom", - customCertResolver: "cert-path-abc", + 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, { - certificateId: "cert-1", - certificatePath: "cert-path-abc", - certificateData: - "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----", - serverId: "server-1", + ...certificate, + certificateId: "cert-other-server", + serverId: null, }, ], }, @@ -278,6 +305,7 @@ describe("detectRequirements", () => { 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"); }); diff --git a/apps/dokploy/__test__/export-import/rotate.test.ts b/apps/dokploy/__test__/export-import/rotate.test.ts index 3daf04bd1..b364e6c80 100644 --- a/apps/dokploy/__test__/export-import/rotate.test.ts +++ b/apps/dokploy/__test__/export-import/rotate.test.ts @@ -181,6 +181,41 @@ describe("buildRotationPlan", () => { ]); }); + 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: { diff --git a/apps/dokploy/__test__/export-import/roundtrip.test.ts b/apps/dokploy/__test__/export-import/roundtrip.test.ts index a5537f6d0..0e6c8c9f7 100644 --- a/apps/dokploy/__test__/export-import/roundtrip.test.ts +++ b/apps/dokploy/__test__/export-import/roundtrip.test.ts @@ -209,6 +209,30 @@ const readDb = (): TransferDb => 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; diff --git a/apps/dokploy/components/dashboard/project/export-import/import-project.tsx b/apps/dokploy/components/dashboard/project/export-import/import-project.tsx index fd421788f..a2d5438da 100644 --- a/apps/dokploy/components/dashboard/project/export-import/import-project.tsx +++ b/apps/dokploy/components/dashboard/project/export-import/import-project.tsx @@ -187,6 +187,11 @@ export const ImportProject = ({ 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); diff --git a/apps/dokploy/components/dashboard/project/export-import/resolution-step.tsx b/apps/dokploy/components/dashboard/project/export-import/resolution-step.tsx index e0eb94fbd..2da068231 100644 --- a/apps/dokploy/components/dashboard/project/export-import/resolution-step.tsx +++ b/apps/dokploy/components/dashboard/project/export-import/resolution-step.tsx @@ -130,11 +130,18 @@ const RequirementRow = ({ {candidates.length > 0 && (