Merge pull request #1986 from Dokploy/319-ability-to-roll-back-service-depoyments

Ability to roll back service deployments
This commit is contained in:
Mauricio Siu
2025-06-22 07:35:36 +02:00
committed by GitHub
24 changed files with 18044 additions and 21 deletions

View File

@@ -121,6 +121,7 @@ const baseApp: ApplicationNested = {
updateConfigSwarm: null,
username: null,
dockerContextPath: null,
rollbackActive: false,
};
describe("unzipDrop using real zip files", () => {

View File

@@ -5,6 +5,7 @@ import { createRouterConfig } from "@dokploy/server";
import { expect, test } from "vitest";
const baseApp: ApplicationNested = {
rollbackActive: false,
applicationId: "",
herokuVersion: "",
giteaRepository: "",

View File

@@ -10,11 +10,14 @@ import {
CardTitle,
} from "@/components/ui/card";
import { type RouterOutputs, api } from "@/utils/api";
import { Clock, Loader2, RocketIcon } from "lucide-react";
import { Clock, Loader2, RocketIcon, Settings, RefreshCcw } from "lucide-react";
import React, { useEffect, useState } from "react";
import { CancelQueues } from "./cancel-queues";
import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment";
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
import { DialogAction } from "@/components/shared/dialog-action";
import { toast } from "sonner";
interface Props {
id: string;
@@ -57,6 +60,9 @@ export const ShowDeployments = ({
},
);
const { mutateAsync: rollback, isLoading: isRollingBack } =
api.rollback.rollback.useMutation();
const [url, setUrl] = React.useState("");
useEffect(() => {
setUrl(document.location.origin);
@@ -71,9 +77,18 @@ export const ShowDeployments = ({
See all the 10 last deployments for this {type}
</CardDescription>
</div>
{(type === "application" || type === "compose") && (
<CancelQueues id={id} type={type} />
)}
<div className="flex flex-row items-center gap-2">
{(type === "application" || type === "compose") && (
<CancelQueues id={id} type={type} />
)}
{type === "application" && (
<ShowRollbackSettings applicationId={id}>
<Button variant="outline">
Configure Rollbacks <Settings className="size-4" />
</Button>
</ShowRollbackSettings>
)}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{refreshToken && (
@@ -154,13 +169,47 @@ export const ShowDeployments = ({
)}
</div>
<Button
onClick={() => {
setActiveLog(deployment);
}}
>
View
</Button>
<div className="flex flex-row items-center gap-2">
<Button
onClick={() => {
setActiveLog(deployment);
}}
>
View
</Button>
{deployment?.rollback &&
deployment.status === "done" &&
type === "application" && (
<DialogAction
title="Rollback to this deployment"
description="Are you sure you want to rollback to this deployment?"
type="default"
onClick={async () => {
await rollback({
rollbackId: deployment.rollback.rollbackId,
})
.then(() => {
toast.success(
"Rollback initiated successfully",
);
})
.catch(() => {
toast.error("Error initiating rollback");
});
}}
>
<Button
variant="secondary"
size="sm"
isLoading={isRollingBack}
>
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
Rollback
</Button>
</DialogAction>
)}
</div>
</div>
</div>
))}

View File

@@ -0,0 +1,117 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const formSchema = z.object({
rollbackActive: z.boolean(),
});
type FormValues = z.infer<typeof formSchema>;
interface Props {
applicationId: string;
children?: React.ReactNode;
}
export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: application, refetch } = api.application.one.useQuery(
{
applicationId,
},
{
enabled: !!applicationId,
},
);
const { mutateAsync: updateApplication, isLoading } =
api.application.update.useMutation();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
rollbackActive: application?.rollbackActive ?? false,
},
});
const onSubmit = async (data: FormValues) => {
await updateApplication({
applicationId,
rollbackActive: data.rollbackActive,
})
.then(() => {
toast.success("Rollback settings updated");
setIsOpen(false);
refetch();
})
.catch(() => {
toast.error("Failed to update rollback settings");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Rollback Settings</DialogTitle>
<DialogDescription>
Configure how rollbacks work for this application
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="rollbackActive"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Enable Rollbacks
</FormLabel>
<FormDescription>
Allow rolling back to previous deployments
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Button type="submit" className="w-full" isLoading={isLoading}>
Save Settings
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,13 @@
CREATE TABLE "rollback" (
"rollbackId" text PRIMARY KEY NOT NULL,
"deploymentId" text NOT NULL,
"version" serial NOT NULL,
"image" text,
"createdAt" text NOT NULL,
"fullContext" jsonb
);
--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "rollbackActive" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "deployment" ADD COLUMN "rollbackId" text;--> statement-breakpoint
ALTER TABLE "rollback" ADD CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk" FOREIGN KEY ("deploymentId") REFERENCES "public"."deployment"("deploymentId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_rollbackId_rollback_rollbackId_fk" FOREIGN KEY ("rollbackId") REFERENCES "public"."rollback"("rollbackId") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "rollback" DROP CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk";
--> statement-breakpoint
ALTER TABLE "rollback" ADD CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk" FOREIGN KEY ("deploymentId") REFERENCES "public"."deployment"("deploymentId") ON DELETE set null ON UPDATE no action;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "rollback" DROP CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk";
--> statement-breakpoint
ALTER TABLE "rollback" ADD CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk" FOREIGN KEY ("deploymentId") REFERENCES "public"."deployment"("deploymentId") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -666,6 +666,27 @@
"when": 1750559214977,
"tag": "0094_numerous_carmella_unuscione",
"breakpoints": true
},
{
"idx": 95,
"version": "7",
"when": 1750562292392,
"tag": "0095_curly_justice",
"breakpoints": true
},
{
"idx": 96,
"version": "7",
"when": 1750566830268,
"tag": "0096_small_shaman",
"breakpoints": true
},
{
"idx": 97,
"version": "7",
"when": 1750567641441,
"tag": "0097_hard_lizard",
"breakpoints": true
}
]
}

View File

@@ -28,7 +28,6 @@ import { projectRouter } from "./routers/project";
import { redirectsRouter } from "./routers/redirects";
import { redisRouter } from "./routers/redis";
import { registryRouter } from "./routers/registry";
import { scheduleRouter } from "./routers/schedule";
import { securityRouter } from "./routers/security";
import { serverRouter } from "./routers/server";
import { settingsRouter } from "./routers/settings";
@@ -36,6 +35,8 @@ import { sshRouter } from "./routers/ssh-key";
import { stripeRouter } from "./routers/stripe";
import { swarmRouter } from "./routers/swarm";
import { userRouter } from "./routers/user";
import { scheduleRouter } from "./routers/schedule";
import { rollbackRouter } from "./routers/rollbacks";
/**
* This is the primary router for your server.
*
@@ -80,6 +81,7 @@ export const appRouter = createTRPCRouter({
ai: aiRouter,
organization: organizationRouter,
schedule: scheduleRouter,
rollback: rollbackRouter,
});
// export type definition of API

View File

@@ -65,7 +65,11 @@ export const deploymentRouter = createTRPCRouter({
const deploymentsList = await db.query.deployments.findMany({
where: eq(deployments[`${input.type}Id`], input.id),
orderBy: desc(deployments.createdAt),
with: {
rollback: true,
},
});
return deploymentsList;
}),
});

View File

@@ -0,0 +1,37 @@
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { apiFindOneRollback } from "@/server/db/schema";
import { removeRollbackById, rollback } from "@dokploy/server";
import { TRPCError } from "@trpc/server";
export const rollbackRouter = createTRPCRouter({
delete: protectedProcedure
.input(apiFindOneRollback)
.mutation(async ({ input }) => {
try {
return removeRollbackById(input.rollbackId);
} catch (error) {
const message =
error instanceof Error
? error.message
: "Error input: Deleting rollback";
throw new TRPCError({
code: "BAD_REQUEST",
message,
});
}
}),
rollback: protectedProcedure
.input(apiFindOneRollback)
.mutation(async ({ input }) => {
try {
return await rollback(input.rollbackId);
} catch (error) {
console.error(error);
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Rolling back",
cause: error,
});
}
}),
});

View File

@@ -15,7 +15,7 @@ const config = {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
"2xl": "87.5rem",
},
},
extend: {

View File

@@ -27,7 +27,6 @@ import { server } from "./server";
import { applicationStatus, certificateType, triggerType } from "./shared";
import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils";
export const sourceType = pgEnum("sourceType", [
"docker",
"git",
@@ -132,6 +131,7 @@ export const applications = pgTable("application", {
isPreviewDeploymentsActive: boolean("isPreviewDeploymentsActive").default(
false,
),
rollbackActive: boolean("rollbackActive").default(false),
buildArgs: text("buildArgs"),
memoryReservation: text("memoryReservation"),
memoryLimit: text("memoryLimit"),

View File

@@ -15,6 +15,7 @@ import { compose } from "./compose";
import { previewDeployments } from "./preview-deployments";
import { schedules } from "./schedule";
import { server } from "./server";
import { rollbacks } from "./rollbacks";
export const deploymentStatus = pgEnum("deploymentStatus", [
"running",
"done",
@@ -58,6 +59,10 @@ export const deployments = pgTable("deployment", {
backupId: text("backupId").references((): AnyPgColumn => backups.backupId, {
onDelete: "cascade",
}),
rollbackId: text("rollbackId").references(
(): AnyPgColumn => rollbacks.rollbackId,
{ onDelete: "cascade" },
),
});
export const deploymentsRelations = relations(deployments, ({ one }) => ({
@@ -85,6 +90,10 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
fields: [deployments.backupId],
references: [backups.backupId],
}),
rollback: one(rollbacks, {
fields: [deployments.deploymentId],
references: [rollbacks.deploymentId],
}),
}));
const schema = createInsertSchema(deployments, {

View File

@@ -32,3 +32,4 @@ export * from "./preview-deployments";
export * from "./ai";
export * from "./account";
export * from "./schedule";
export * from "./rollbacks";

View File

@@ -0,0 +1,45 @@
import { relations } from "drizzle-orm";
import { jsonb, pgTable, serial, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { deployments } from "./deployment";
export const rollbacks = pgTable("rollback", {
rollbackId: text("rollbackId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
deploymentId: text("deploymentId")
.notNull()
.references(() => deployments.deploymentId, {
onDelete: "cascade",
}),
version: serial(),
image: text("image"),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
fullContext: jsonb("fullContext"),
});
export type Rollback = typeof rollbacks.$inferSelect;
export const rollbacksRelations = relations(rollbacks, ({ one }) => ({
deployment: one(deployments, {
fields: [rollbacks.deploymentId],
references: [deployments.deploymentId],
}),
}));
export const createRollbackSchema = createInsertSchema(rollbacks).extend({
appName: z.string().min(1),
});
export const updateRollbackSchema = createRollbackSchema.extend({
rollbackId: z.string().min(1),
});
export const apiFindOneRollback = z.object({
rollbackId: z.string().min(1),
});

View File

@@ -32,6 +32,7 @@ export * from "./services/gitea";
export * from "./services/server";
export * from "./services/schedule";
export * from "./services/application";
export * from "./services/rollbacks";
export * from "./utils/databases/rebuild";
export * from "./setup/config-paths";
export * from "./setup/postgres-setup";

View File

@@ -60,6 +60,7 @@ import {
updatePreviewDeployment,
} from "./preview-deployment";
import { validUniqueServerAppName } from "./project";
import { createRollback } from "./rollbacks";
export type Application = typeof applications.$inferSelect;
export const createApplication = async (
@@ -214,6 +215,17 @@ 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.project.name,
applicationName: application.name,
@@ -338,6 +350,17 @@ export const deployRemoteApplication = 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.project.name,
applicationName: application.name,

View File

@@ -31,23 +31,38 @@ import {
updatePreviewDeployment,
} from "./preview-deployment";
import { findScheduleById } from "./schedule";
import { removeRollbackById } from "./rollbacks";
export type Deployment = typeof deployments.$inferSelect;
export const findDeploymentById = async (applicationId: string) => {
const application = await db.query.deployments.findFirst({
where: eq(deployments.applicationId, applicationId),
export const findDeploymentById = async (deploymentId: string) => {
const deployment = await db.query.deployments.findFirst({
where: eq(deployments.deploymentId, deploymentId),
with: {
application: true,
},
});
if (!application) {
if (!deployment) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Deployment not found",
});
}
return application;
return deployment;
};
export const findDeploymentByApplicationId = async (applicationId: string) => {
const deployment = await db.query.deployments.findFirst({
where: eq(deployments.applicationId, applicationId),
});
if (!deployment) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Deployment not found",
});
}
return deployment;
};
export const createDeployment = async (
@@ -481,6 +496,9 @@ const getDeploymentsByType = async (
const deploymentList = await db.query.deployments.findMany({
where: eq(deployments[`${type}Id`], id),
orderBy: desc(deployments.createdAt),
with: {
rollback: true,
},
});
return deploymentList;
};
@@ -515,6 +533,9 @@ const removeLastTenDeployments = async (
let command = "";
for (const oldDeployment of deploymentsToDelete) {
const logPath = path.join(oldDeployment.logPath);
if (oldDeployment.rollbackId) {
await removeRollbackById(oldDeployment.rollbackId);
}
command += `
rm -rf ${logPath};
@@ -525,8 +546,11 @@ const removeLastTenDeployments = async (
await execAsyncRemote(serverId, command);
} else {
for (const oldDeployment of deploymentsToDelete) {
if (oldDeployment.rollbackId) {
await removeRollbackById(oldDeployment.rollbackId);
}
const logPath = path.join(oldDeployment.logPath);
if (existsSync(logPath)) {
if (existsSync(logPath) && !oldDeployment.errorMessage) {
await fsPromises.unlink(logPath);
}
await removeDeployment(oldDeployment.deploymentId);

View File

@@ -0,0 +1,191 @@
import { eq } from "drizzle-orm";
import { db } from "../db";
import {
type createRollbackSchema,
rollbacks,
deployments as deploymentsSchema,
} from "../db/schema";
import type { z } from "zod";
import { findApplicationById } from "./application";
import { getRemoteDocker } from "../utils/servers/remote-docker";
import type { ApplicationNested } from "../utils/builders";
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
import type { CreateServiceOptions } from "dockerode";
import { findDeploymentById } from "./deployment";
export const createRollback = async (
input: z.infer<typeof createRollbackSchema>,
) => {
await db.transaction(async (tx) => {
const rollback = await tx
.insert(rollbacks)
.values(input)
.returning()
.then((res) => res[0]);
if (!rollback) {
throw new Error("Failed to create rollback");
}
const tagImage = `${input.appName}:v${rollback.version}`;
const deployment = await findDeploymentById(rollback.deploymentId);
if (!deployment?.applicationId) {
throw new Error("Deployment not found");
}
const {
deployments: _,
bitbucket,
github,
gitlab,
gitea,
...rest
} = await findApplicationById(deployment.applicationId);
await tx
.update(rollbacks)
.set({
image: tagImage,
fullContext: JSON.stringify(rest),
})
.where(eq(rollbacks.rollbackId, rollback.rollbackId));
// Update the deployment to reference this rollback
await tx
.update(deploymentsSchema)
.set({
rollbackId: rollback.rollbackId,
})
.where(eq(deploymentsSchema.deploymentId, rollback.deploymentId));
await createRollbackImage(rest, tagImage);
return rollback;
});
};
const findRollbackById = async (rollbackId: string) => {
const result = await db.query.rollbacks.findFirst({
where: eq(rollbacks.rollbackId, rollbackId),
});
if (!result) {
throw new Error("Rollback not found");
}
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`;
if (serverId) {
await execAsyncRemote(command, serverId);
} else {
await execAsync(command);
}
};
export const removeRollbackById = async (rollbackId: string) => {
const rollback = await findRollbackById(rollbackId);
if (!rollback) {
throw new Error("Rollback not found");
}
if (rollback?.image) {
try {
const deployment = await findDeploymentById(rollback.deploymentId);
if (!deployment?.applicationId) {
throw new Error("Deployment not found");
}
const application = await findApplicationById(deployment.applicationId);
await deleteRollbackImage(rollback.image, application.serverId);
await db
.delete(rollbacks)
.where(eq(rollbacks.rollbackId, rollbackId))
.returning()
.then((res) => res[0]);
} catch (error) {
console.error(error);
}
}
return rollback;
};
export const rollback = async (rollbackId: string) => {
const result = await findRollbackById(rollbackId);
const deployment = await findDeploymentById(result.deploymentId);
if (!deployment?.applicationId) {
throw new Error("Deployment not found");
}
const application = await findApplicationById(deployment.applicationId);
await rollbackApplication(
application.appName,
result.image || "",
application.serverId,
);
};
const rollbackApplication = async (
appName: string,
image: string,
serverId?: string | null,
) => {
const docker = await getRemoteDocker(serverId);
const settings: CreateServiceOptions = {
Name: appName,
TaskTemplate: {
ContainerSpec: {
Image: image,
},
},
};
try {
const service = docker.getService(appName);
const inspect = await service.inspect();
await service.update({
version: Number.parseInt(inspect.Version.Index),
...settings,
TaskTemplate: {
...settings.TaskTemplate,
ForceUpdate: inspect.Spec.TaskTemplate.ForceUpdate + 1,
},
});
} catch (_error: unknown) {
await docker.createService(settings);
}
};

View File

@@ -212,7 +212,7 @@ export const cleanUpDockerBuilder = async (serverId?: string) => {
};
export const cleanUpSystemPrune = async (serverId?: string) => {
const command = "docker system prune --all --force --volumes";
const command = "docker system prune --force --volumes";
if (serverId) {
await execAsyncRemote(serverId, command);
} else {