mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-18 21:55:24 +02:00
feat: implement rollback registry functionality in application settings
- Added a new optional field `rollbackRegistryId` to the application schema to support rollback registry selection. - Enhanced the form in the ShowRollbackSettings component to include a dropdown for selecting a rollback registry when rollbacks are enabled. - Updated the application service to handle rollback registry logic during deployment and rollback processes. - Improved error handling and validation for rollback settings, ensuring a registry is selected when rollbacks are active. - Adjusted database schema and migration files to accommodate the new rollback registry feature.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -20,13 +20,37 @@ import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const formSchema = z.object({
|
||||
rollbackActive: z.boolean(),
|
||||
});
|
||||
const formSchema = z
|
||||
.object({
|
||||
rollbackActive: z.boolean(),
|
||||
rollbackRegistryId: z.string().optional(),
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
if (
|
||||
values.rollbackActive &&
|
||||
(!values.rollbackRegistryId || values.rollbackRegistryId === "none")
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["rollbackRegistryId"],
|
||||
message: "Registry is required when rollbacks are enabled",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -49,17 +73,33 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
||||
const { mutateAsync: updateApplication, isLoading } =
|
||||
api.application.update.useMutation();
|
||||
|
||||
const { data: registries } = api.registry.all.useQuery();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
rollbackActive: application?.rollbackActive ?? false,
|
||||
rollbackRegistryId: application?.rollbackRegistryId || "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (application) {
|
||||
form.reset({
|
||||
rollbackActive: application.rollbackActive ?? false,
|
||||
rollbackRegistryId: application.rollbackRegistryId || "",
|
||||
});
|
||||
}
|
||||
}, [application, form]);
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
await updateApplication({
|
||||
applicationId,
|
||||
rollbackActive: data.rollbackActive,
|
||||
rollbackRegistryId:
|
||||
data.rollbackRegistryId === "none" || !data.rollbackRegistryId
|
||||
? null
|
||||
: data.rollbackRegistryId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Rollback settings updated");
|
||||
@@ -112,6 +152,52 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("rollbackActive") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rollbackRegistryId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Rollback Registry</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || "none"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a registry" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="none">
|
||||
<span className="flex items-center gap-2">
|
||||
<span>None</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
{registries?.map((registry) => (
|
||||
<SelectItem
|
||||
key={registry.registryId}
|
||||
value={registry.registryId}
|
||||
>
|
||||
{registry.registryName}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Registries ({registries?.length || 0})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select a registry where rollback images will be stored.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isLoading}>
|
||||
Save Settings
|
||||
</Button>
|
||||
|
||||
2
apps/dokploy/drizzle/0125_neat_the_phantom.sql
Normal file
2
apps/dokploy/drizzle/0125_neat_the_phantom.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "application" ADD COLUMN "rollbackRegistryId" text;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD CONSTRAINT "application_rollbackRegistryId_registry_registryId_fk" FOREIGN KEY ("rollbackRegistryId") REFERENCES "public"."registry"("registryId") ON DELETE set null ON UPDATE no action;
|
||||
6850
apps/dokploy/drizzle/meta/0125_snapshot.json
Normal file
6850
apps/dokploy/drizzle/meta/0125_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -876,6 +876,13 @@
|
||||
"when": 1764571454170,
|
||||
"tag": "0124_certain_cloak",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 125,
|
||||
"version": "7",
|
||||
"when": 1764573207555,
|
||||
"tag": "0125_neat_the_phantom",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -187,6 +187,12 @@ export const applications = pgTable("application", {
|
||||
registryId: text("registryId").references(() => registry.registryId, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
rollbackRegistryId: text("rollbackRegistryId").references(
|
||||
() => registry.registryId,
|
||||
{
|
||||
onDelete: "set null",
|
||||
},
|
||||
),
|
||||
environmentId: text("environmentId")
|
||||
.notNull()
|
||||
.references(() => environments.environmentId, { onDelete: "cascade" }),
|
||||
@@ -270,6 +276,11 @@ export const applicationsRelations = relations(
|
||||
relationName: "applicationBuildRegistry",
|
||||
}),
|
||||
previewDeployments: many(previewDeployments),
|
||||
rollbackRegistry: one(registry, {
|
||||
fields: [applications.rollbackRegistryId],
|
||||
references: [registry.registryId],
|
||||
relationName: "applicationRollbackRegistry",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ export const registryRelations = relations(registry, ({ many }) => ({
|
||||
buildApplications: many(applications, {
|
||||
relationName: "applicationBuildRegistry",
|
||||
}),
|
||||
rollbackApplications: many(applications, {
|
||||
relationName: "applicationRollbackRegistry",
|
||||
}),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(registry, {
|
||||
|
||||
@@ -49,7 +49,6 @@ import {
|
||||
updatePreviewDeployment,
|
||||
} from "./preview-deployment";
|
||||
import { validUniqueServerAppName } from "./project";
|
||||
import { createRollback } from "./rollbacks";
|
||||
export type Application = typeof applications.$inferSelect;
|
||||
|
||||
export const createApplication = async (
|
||||
@@ -113,6 +112,7 @@ export const findApplicationById = async (applicationId: string) => {
|
||||
server: true,
|
||||
previewDeployments: true,
|
||||
buildRegistry: true,
|
||||
rollbackRegistry: true,
|
||||
},
|
||||
});
|
||||
if (!application) {
|
||||
@@ -198,7 +198,7 @@ export const deployApplication = async ({
|
||||
command += await buildRemoteDocker(application);
|
||||
}
|
||||
|
||||
command += getBuildCommand(application);
|
||||
command += await getBuildCommand(application);
|
||||
|
||||
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||
if (serverId) {
|
||||
@@ -211,17 +211,6 @@ export const deployApplication = async ({
|
||||
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||
await updateApplicationStatus(applicationId, "done");
|
||||
|
||||
if (application.rollbackActive) {
|
||||
const tagImage =
|
||||
application.sourceType === "docker"
|
||||
? application.dockerImage
|
||||
: application.appName;
|
||||
await createRollback({
|
||||
appName: tagImage || "",
|
||||
deploymentId: deployment.deploymentId,
|
||||
});
|
||||
}
|
||||
|
||||
await sendBuildSuccessNotifications({
|
||||
projectName: application.environment.project.name,
|
||||
applicationName: application.name,
|
||||
@@ -299,7 +288,7 @@ export const rebuildApplication = async ({
|
||||
try {
|
||||
let command = "set -e;";
|
||||
// Check case for docker only
|
||||
command += getBuildCommand(application);
|
||||
command += await getBuildCommand(application);
|
||||
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, commandWithLog);
|
||||
@@ -310,17 +299,6 @@ export const rebuildApplication = async ({
|
||||
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||
await updateApplicationStatus(applicationId, "done");
|
||||
|
||||
if (application.rollbackActive) {
|
||||
const tagImage =
|
||||
application.sourceType === "docker"
|
||||
? application.dockerImage
|
||||
: application.appName;
|
||||
await createRollback({
|
||||
appName: tagImage || "",
|
||||
deploymentId: deployment.deploymentId,
|
||||
});
|
||||
}
|
||||
|
||||
await sendBuildSuccessNotifications({
|
||||
projectName: application.environment.project.name,
|
||||
applicationName: application.name,
|
||||
@@ -423,6 +401,10 @@ export const deployPreviewApplication = async ({
|
||||
application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
|
||||
application.buildArgs = `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
|
||||
application.buildSecrets = `${application.previewBuildSecrets}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
|
||||
application.rollbackActive = false;
|
||||
application.buildRegistry = null;
|
||||
application.rollbackRegistry = null;
|
||||
application.registry = null;
|
||||
|
||||
let command = "set -e;";
|
||||
if (application.sourceType === "github") {
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
deployments as deploymentsSchema,
|
||||
rollbacks,
|
||||
} from "../db/schema";
|
||||
import { type ApplicationNested, getAuthConfig } from "../utils/builders";
|
||||
import type { ApplicationNested } from "../utils/builders";
|
||||
import { getRegistryTag } from "../utils/cluster/upload";
|
||||
import {
|
||||
calculateResources,
|
||||
generateBindMounts,
|
||||
@@ -22,11 +23,12 @@ import { findDeploymentById } from "./deployment";
|
||||
import type { Mount } from "./mount";
|
||||
import type { Port } from "./port";
|
||||
import type { Project } from "./project";
|
||||
import type { Registry } from "./registry";
|
||||
|
||||
export const createRollback = async (
|
||||
input: z.infer<typeof createRollbackSchema>,
|
||||
) => {
|
||||
await db.transaction(async (tx) => {
|
||||
return await db.transaction(async (tx) => {
|
||||
const { fullContext, ...other } = input;
|
||||
const rollback = await tx
|
||||
.insert(rollbacks)
|
||||
@@ -70,9 +72,11 @@ export const createRollback = async (
|
||||
})
|
||||
.where(eq(deploymentsSchema.deploymentId, rollback.deploymentId));
|
||||
|
||||
await createRollbackImage(rest, tagImage);
|
||||
const updatedRollback = await tx.query.rollbacks.findFirst({
|
||||
where: eq(rollbacks.rollbackId, rollback.rollbackId),
|
||||
});
|
||||
|
||||
return rollback;
|
||||
return updatedRollback;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -103,27 +107,6 @@ export const findRollbackById = async (rollbackId: string) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const createRollbackImage = async (
|
||||
application: ApplicationNested,
|
||||
tagImage: string,
|
||||
) => {
|
||||
const docker = await getRemoteDocker(application.serverId);
|
||||
|
||||
const appTagName =
|
||||
application.sourceType === "docker"
|
||||
? application.dockerImage
|
||||
: `${application.appName}:latest`;
|
||||
|
||||
const result = docker.getImage(appTagName || "");
|
||||
|
||||
const [repo, version] = tagImage.split(":");
|
||||
|
||||
await result.tag({
|
||||
repo,
|
||||
tag: version,
|
||||
});
|
||||
};
|
||||
|
||||
const deleteRollbackImage = async (image: string, serverId?: string | null) => {
|
||||
const command = `docker image rm ${image} --force`;
|
||||
|
||||
@@ -179,14 +162,18 @@ export const rollback = async (rollbackId: string) => {
|
||||
if (!result.fullContext) {
|
||||
throw new Error("Rollback context not found");
|
||||
}
|
||||
|
||||
// Use the full context for rollback
|
||||
await rollbackApplication(
|
||||
application.appName,
|
||||
result.image || "",
|
||||
application.serverId,
|
||||
result.fullContext,
|
||||
);
|
||||
try {
|
||||
// Use the full context for rollback
|
||||
await rollbackApplication(
|
||||
application.appName,
|
||||
result.image || "",
|
||||
application.serverId,
|
||||
result.fullContext,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error("Failed to rollback application");
|
||||
}
|
||||
};
|
||||
|
||||
const rollbackApplication = async (
|
||||
@@ -199,6 +186,7 @@ const rollbackApplication = async (
|
||||
};
|
||||
mounts: Mount[];
|
||||
ports: Port[];
|
||||
rollbackRegistry?: Registry;
|
||||
},
|
||||
) => {
|
||||
if (!fullContext) {
|
||||
@@ -245,16 +233,24 @@ const rollbackApplication = async (
|
||||
fullContext.environment.project.env,
|
||||
);
|
||||
|
||||
// For rollback, we use the provided image instead of calculating it
|
||||
const authConfig = getAuthConfig(fullContext as ApplicationNested);
|
||||
// Build the full registry image path if rollbackRegistry is available
|
||||
// e.g., "appName:v5" -> "siumauricio/appName:v5" or "registry.com/prefix/appName:v5"
|
||||
let rollbackImage = image;
|
||||
if (fullContext.rollbackRegistry) {
|
||||
rollbackImage = getRegistryTag(fullContext.rollbackRegistry, image);
|
||||
}
|
||||
|
||||
const settings: CreateServiceOptions = {
|
||||
authconfig: authConfig,
|
||||
authconfig: {
|
||||
password: fullContext.rollbackRegistry?.password || "",
|
||||
username: fullContext.rollbackRegistry?.username || "",
|
||||
serveraddress: fullContext.rollbackRegistry?.registryUrl || "",
|
||||
},
|
||||
Name: appName,
|
||||
TaskTemplate: {
|
||||
ContainerSpec: {
|
||||
HealthCheck,
|
||||
Image: image,
|
||||
Image: rollbackImage,
|
||||
Env: envVariables,
|
||||
Mounts: [...volumesMount, ...bindsMount],
|
||||
...(command
|
||||
@@ -297,7 +293,8 @@ const rollbackApplication = async (
|
||||
ForceUpdate: inspect.Spec.TaskTemplate.ForceUpdate + 1,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await docker.createService(settings);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import type { CreateServiceOptions } from "dockerode";
|
||||
import { uploadImageRemoteCommand } from "../cluster/upload";
|
||||
import { getRegistryTag, uploadImageRemoteCommand } from "../cluster/upload";
|
||||
import {
|
||||
calculateResources,
|
||||
generateBindMounts,
|
||||
@@ -30,39 +30,45 @@ export type ApplicationNested = InferResultType<
|
||||
ports: true;
|
||||
registry: true;
|
||||
buildRegistry: true;
|
||||
rollbackRegistry: true;
|
||||
deployments: true;
|
||||
environment: { with: { project: true } };
|
||||
}
|
||||
>;
|
||||
|
||||
export const getBuildCommand = (application: ApplicationNested) => {
|
||||
export const getBuildCommand = async (application: ApplicationNested) => {
|
||||
let command = "";
|
||||
const { buildType } = application;
|
||||
|
||||
if (application.sourceType === "docker") {
|
||||
return "";
|
||||
if (application.sourceType !== "docker") {
|
||||
const { buildType } = application;
|
||||
switch (buildType) {
|
||||
case "nixpacks":
|
||||
command = getNixpacksCommand(application);
|
||||
break;
|
||||
case "heroku_buildpacks":
|
||||
command = getHerokuCommand(application);
|
||||
break;
|
||||
case "paketo_buildpacks":
|
||||
command = getPaketoCommand(application);
|
||||
break;
|
||||
case "static":
|
||||
command = getStaticCommand(application);
|
||||
break;
|
||||
case "dockerfile":
|
||||
command = getDockerCommand(application);
|
||||
break;
|
||||
case "railpack":
|
||||
command = getRailpackCommand(application);
|
||||
break;
|
||||
}
|
||||
}
|
||||
switch (buildType) {
|
||||
case "nixpacks":
|
||||
command = getNixpacksCommand(application);
|
||||
break;
|
||||
case "heroku_buildpacks":
|
||||
command = getHerokuCommand(application);
|
||||
break;
|
||||
case "paketo_buildpacks":
|
||||
command = getPaketoCommand(application);
|
||||
break;
|
||||
case "static":
|
||||
command = getStaticCommand(application);
|
||||
break;
|
||||
case "dockerfile":
|
||||
command = getDockerCommand(application);
|
||||
break;
|
||||
case "railpack":
|
||||
command = getRailpackCommand(application);
|
||||
break;
|
||||
}
|
||||
if (application.registry || application.buildRegistry) {
|
||||
command += uploadImageRemoteCommand(application);
|
||||
|
||||
if (
|
||||
application.registry ||
|
||||
application.buildRegistry ||
|
||||
application.rollbackRegistry
|
||||
) {
|
||||
command += await uploadImageRemoteCommand(application);
|
||||
}
|
||||
|
||||
return command;
|
||||
@@ -188,17 +194,11 @@ const getImageName = (application: ApplicationNested) => {
|
||||
}
|
||||
|
||||
if (registry) {
|
||||
const { registryUrl, imagePrefix, username } = registry;
|
||||
const registryTag = imagePrefix
|
||||
? `${registryUrl ? `${registryUrl}/` : ""}${imagePrefix}/${imageName}`
|
||||
: `${registryUrl ? `${registryUrl}/` : ""}${username}/${imageName}`;
|
||||
const registryTag = getRegistryTag(registry, imageName);
|
||||
return registryTag;
|
||||
}
|
||||
if (buildRegistry) {
|
||||
const { registryUrl, imagePrefix, username } = buildRegistry;
|
||||
const registryTag = imagePrefix
|
||||
? `${registryUrl ? `${registryUrl}/` : ""}${imagePrefix}/${imageName}`
|
||||
: `${registryUrl ? `${registryUrl}/` : ""}${username}/${imageName}`;
|
||||
const registryTag = getRegistryTag(buildRegistry, imageName);
|
||||
return registryTag;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { findAllDeploymentsByApplicationId } from "@dokploy/server/services/deployment";
|
||||
import type { Registry } from "@dokploy/server/services/registry";
|
||||
import { createRollback } from "@dokploy/server/services/rollbacks";
|
||||
import type { ApplicationNested } from "../builders";
|
||||
|
||||
export const uploadImageRemoteCommand = (application: ApplicationNested) => {
|
||||
export const uploadImageRemoteCommand = async (
|
||||
application: ApplicationNested,
|
||||
) => {
|
||||
const registry = application.registry;
|
||||
const buildRegistry = application.buildRegistry;
|
||||
const rollbackRegistry = application.rollbackRegistry;
|
||||
|
||||
if (!registry && !buildRegistry) {
|
||||
if (!registry && !buildRegistry && !rollbackRegistry) {
|
||||
throw new Error("No registry found");
|
||||
}
|
||||
|
||||
const { appName } = application;
|
||||
const imageName = `${appName}:latest`;
|
||||
const imageName =
|
||||
application.sourceType === "docker"
|
||||
? application.dockerImage || ""
|
||||
: `${appName}:latest`;
|
||||
|
||||
const commands: string[] = [];
|
||||
if (registry) {
|
||||
@@ -35,16 +43,38 @@ export const uploadImageRemoteCommand = (application: ApplicationNested) => {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (rollbackRegistry && application.rollbackActive) {
|
||||
const deployment = await findAllDeploymentsByApplicationId(
|
||||
application.applicationId,
|
||||
);
|
||||
if (!deployment || !deployment[0]) {
|
||||
throw new Error("Deployment not found");
|
||||
}
|
||||
const deploymentId = deployment[0].deploymentId;
|
||||
const rollback = await createRollback({
|
||||
appName: appName,
|
||||
deploymentId: deploymentId,
|
||||
});
|
||||
|
||||
const rollbackRegistryTag = getRegistryTag(
|
||||
rollbackRegistry,
|
||||
rollback?.image || "",
|
||||
);
|
||||
if (rollbackRegistryTag) {
|
||||
commands.push(`echo "🔄 [Enabled Rollback Registry]"`);
|
||||
commands.push(
|
||||
getRegistryCommands(rollbackRegistry, imageName, rollbackRegistryTag),
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
return commands.join("\n");
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const getRegistryTag = (registry: Registry | null, imageName: string) => {
|
||||
if (!registry) {
|
||||
return null;
|
||||
}
|
||||
export const getRegistryTag = (registry: Registry, imageName: string) => {
|
||||
const { registryUrl, imagePrefix, username } = registry;
|
||||
return imagePrefix
|
||||
? `${registryUrl ? `${registryUrl}/` : ""}${imagePrefix}/${imageName}`
|
||||
|
||||
Reference in New Issue
Block a user