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:
Mauricio Siu
2025-12-02 00:48:11 -06:00
parent e052850b87
commit 5a7f55ea63
10 changed files with 7077 additions and 109 deletions

View File

@@ -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>

View 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;

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View File

@@ -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",
}),
}),
);

View File

@@ -39,6 +39,9 @@ export const registryRelations = relations(registry, ({ many }) => ({
buildApplications: many(applications, {
relationName: "applicationBuildRegistry",
}),
rollbackApplications: many(applications, {
relationName: "applicationRollbackRegistry",
}),
}));
const createSchema = createInsertSchema(registry, {

View File

@@ -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") {

View File

@@ -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);
}
};

View File

@@ -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;
}

View File

@@ -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}`