mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-23 00:45:36 +00:00
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:
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user