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
This commit is contained in:
Narciso
2026-10-15 17:56:51 -04:00
parent 63a1a0c89f
commit 3f90bb9159
18 changed files with 798 additions and 153 deletions
@@ -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;
@@ -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 () => [
{
@@ -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");
});
@@ -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: {
@@ -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;
@@ -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);
@@ -130,11 +130,18 @@ const RequirementRow = ({
{candidates.length > 0 && (
<Select
value={
choice.action === "skip" ? "__skip__" : (choice.targetId ?? "")
choice.action === "skip"
? "__skip__"
: choice.action === "create"
? "__create__"
: (choice.targetId ?? "")
}
onValueChange={(value) => {
if (value === "__skip__") {
onChange({ requirementKey: key, action: "skip" });
if (value === "__skip__" || value === "__create__") {
onChange({
requirementKey: key,
action: value === "__skip__" ? "skip" : "create",
});
return;
}
onChange({
@@ -148,6 +155,10 @@ const RequirementRow = ({
<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}
@@ -1,9 +1,18 @@
import { randomUUID } from "node:crypto";
import { getDokployImageTag, IS_CLOUD, TRAEFIK_VERSION } from "@dokploy/server";
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,
@@ -60,6 +69,7 @@ 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";
@@ -79,6 +89,94 @@ const localInstanceContext = () => {
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> => {
@@ -801,20 +899,26 @@ export const projectExportImportRouter = createTRPCRouter({
});
}
assertPlanChecksPass(
await validatePlan(
db,
job.snapshot,
job.plan,
ctx.session.activeOrganizationId,
),
);
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, {