Skip to content

Commit a54ec4d

Browse files
committed
add deployment rollbacks
1 parent c11a77f commit a54ec4d

File tree

5 files changed

+242
-9
lines changed

5 files changed

+242
-9
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { ArrowPathIcon } from "@heroicons/react/20/solid";
2+
import { Form, useNavigation } from "@remix-run/react";
3+
import { Button } from "~/components/primitives/Buttons";
4+
import {
5+
DialogContent,
6+
DialogDescription,
7+
DialogFooter,
8+
DialogHeader,
9+
} from "~/components/primitives/Dialog";
10+
11+
type RollbackDeploymentDialogProps = {
12+
projectId: string;
13+
deploymentShortCode: string;
14+
redirectPath: string;
15+
};
16+
17+
export function RollbackDeploymentDialog({
18+
projectId,
19+
deploymentShortCode,
20+
redirectPath,
21+
}: RollbackDeploymentDialogProps) {
22+
const navigation = useNavigation();
23+
24+
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/rollback`;
25+
const isLoading = navigation.formAction === formAction;
26+
27+
return (
28+
<DialogContent key="rollback">
29+
<DialogHeader>Roll back to this deployment?</DialogHeader>
30+
<DialogDescription>
31+
This deployment will become the default for all future runs. Tasks triggered but not
32+
included in this deploy will remain queued until you roll back to or create a new deployment
33+
with these tasks included.
34+
</DialogDescription>
35+
<DialogFooter>
36+
<Form
37+
action={`/resources/${projectId}/deployments/${deploymentShortCode}/rollback`}
38+
method="post"
39+
>
40+
<Button
41+
type="submit"
42+
name="redirectUrl"
43+
value={redirectPath}
44+
variant="primary/small"
45+
LeadingIcon={isLoading ? "spinner-white" : ArrowPathIcon}
46+
disabled={isLoading}
47+
shortcut={{ modifiers: ["meta"], key: "enter" }}
48+
>
49+
{isLoading ? "Rolling back..." : "Roll back deployment"}
50+
</Button>
51+
</Form>
52+
</DialogFooter>
53+
</DialogContent>
54+
);
55+
}

apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { getUsername } from "~/utils/username";
77

88
const pageSize = 20;
99

10+
export type DeploymentList = Awaited<ReturnType<DeploymentListPresenter["call"]>>;
11+
export type DeploymentListItem = DeploymentList["deployments"][0];
12+
1013
export class DeploymentListPresenter {
1114
#prismaClient: PrismaClient;
1215

@@ -136,6 +139,8 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
136139
deployedAt: deployment.deployedAt,
137140
tasksCount: deployment.tasksCount ? Number(deployment.tasksCount) : null,
138141
label: label?.label,
142+
isCurrent: label?.label === "current",
143+
isDeployed: deployment.status === "DEPLOYED",
139144
environment: {
140145
id: environment.id,
141146
type: environment.type,

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.deployments/route.tsx

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
import { CommandLineIcon, ServerIcon } from "@heroicons/react/20/solid";
2-
import { Outlet, useParams } from "@remix-run/react";
1+
import { ArrowPathIcon, CommandLineIcon, ServerIcon } from "@heroicons/react/20/solid";
2+
import { Outlet, useLocation, useParams } from "@remix-run/react";
33
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
4-
import { TerminalIcon, TerminalSquareIcon } from "lucide-react";
54
import { typedjson, useTypedLoaderData } from "remix-typedjson";
65
import { z } from "zod";
76
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
87
import { UserAvatar } from "~/components/UserProfilePhoto";
98
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
109
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
1110
import { Badge } from "~/components/primitives/Badge";
12-
import { LinkButton } from "~/components/primitives/Buttons";
11+
import { Button, LinkButton } from "~/components/primitives/Buttons";
1312
import { DateTime } from "~/components/primitives/DateTime";
13+
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
1414
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
1515
import { PaginationControls } from "~/components/primitives/Pagination";
1616
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -24,24 +24,26 @@ import {
2424
TableBlankRow,
2525
TableBody,
2626
TableCell,
27-
TableCellChevron,
27+
TableCellMenu,
2828
TableHeader,
2929
TableHeaderCell,
3030
TableRow,
3131
} from "~/components/primitives/Table";
3232
import { TextLink } from "~/components/primitives/TextLink";
3333
import { DeploymentStatus } from "~/components/runs/v3/DeploymentStatus";
34+
import { RollbackDeploymentDialog } from "~/components/runs/v3/RollbackDeploymentDialog";
3435
import { useOrganization } from "~/hooks/useOrganizations";
3536
import { useProject } from "~/hooks/useProject";
3637
import { useUser } from "~/hooks/useUser";
37-
import { DeploymentListPresenter } from "~/presenters/v3/DeploymentListPresenter.server";
38+
import {
39+
DeploymentListItem,
40+
DeploymentListPresenter,
41+
} from "~/presenters/v3/DeploymentListPresenter.server";
3842
import { requireUserId } from "~/services/session.server";
39-
import { cn } from "~/utils/cn";
4043
import {
4144
ProjectParamSchema,
4245
docsPath,
4346
v3DeploymentPath,
44-
v3DeploymentsPath,
4547
v3EnvironmentVariablesPath,
4648
} from "~/utils/pathBuilder";
4749
import { createSearchParams } from "~/utils/searchParams";
@@ -166,7 +168,7 @@ export default function Page() {
166168
"–"
167169
)}
168170
</TableCell>
169-
<TableCellChevron to={path} />
171+
<DeploymentActionsCell deployment={deployment} path={path} />
170172
</TableRow>
171173
);
172174
})
@@ -240,3 +242,35 @@ function CreateDeploymentInstructions() {
240242
</MainCenteredContainer>
241243
);
242244
}
245+
246+
function DeploymentActionsCell({
247+
deployment,
248+
path,
249+
}: {
250+
deployment: DeploymentListItem;
251+
path: string;
252+
}) {
253+
const location = useLocation();
254+
const project = useProject();
255+
256+
if (deployment.isCurrent || !deployment.isDeployed) return <TableCell to={path}>{""}</TableCell>;
257+
258+
return (
259+
<TableCellMenu isSticky>
260+
{!deployment.isCurrent && deployment.isDeployed && (
261+
<Dialog>
262+
<DialogTrigger asChild>
263+
<Button variant="small-menu-item" LeadingIcon={ArrowPathIcon}>
264+
Rollback
265+
</Button>
266+
</DialogTrigger>
267+
<RollbackDeploymentDialog
268+
projectId={project.id}
269+
deploymentShortCode={deployment.shortCode}
270+
redirectPath={`${location.pathname}${location.search}`}
271+
/>
272+
</Dialog>
273+
)}
274+
</TableCellMenu>
275+
);
276+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { parse } from "@conform-to/zod";
2+
import { ActionFunction, json } from "@remix-run/node";
3+
import { z } from "zod";
4+
import { prisma } from "~/db.server";
5+
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
6+
import { logger } from "~/services/logger.server";
7+
import { requireUserId } from "~/services/session.server";
8+
import { RollbackDeploymentService } from "~/v3/services/rollbackDeployment.server";
9+
10+
export const rollbackSchema = z.object({
11+
redirectUrl: z.string(),
12+
});
13+
14+
const ParamSchema = z.object({
15+
projectId: z.string(),
16+
deploymentShortCode: z.string(),
17+
});
18+
19+
export const action: ActionFunction = async ({ request, params }) => {
20+
const userId = await requireUserId(request);
21+
const { projectId, deploymentShortCode } = ParamSchema.parse(params);
22+
23+
console.log("projectId", projectId);
24+
console.log("deploymentShortCode", deploymentShortCode);
25+
26+
const formData = await request.formData();
27+
const submission = parse(formData, { schema: rollbackSchema });
28+
29+
if (!submission.value) {
30+
return json(submission);
31+
}
32+
33+
try {
34+
const project = await prisma.project.findUnique({
35+
where: {
36+
id: projectId,
37+
organization: {
38+
members: {
39+
some: {
40+
userId,
41+
},
42+
},
43+
},
44+
},
45+
});
46+
47+
if (!project) {
48+
return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found");
49+
}
50+
51+
const deployment = await prisma.workerDeployment.findUnique({
52+
where: {
53+
projectId_shortCode: {
54+
projectId: project.id,
55+
shortCode: deploymentShortCode,
56+
},
57+
},
58+
});
59+
60+
if (!deployment) {
61+
return redirectWithErrorMessage(
62+
submission.value.redirectUrl,
63+
request,
64+
"Deployment not found"
65+
);
66+
}
67+
68+
const rollbackService = new RollbackDeploymentService();
69+
await rollbackService.call(deployment);
70+
71+
return redirectWithSuccessMessage(
72+
submission.value.redirectUrl,
73+
request,
74+
"Rolled back deployment"
75+
);
76+
} catch (error) {
77+
if (error instanceof Error) {
78+
logger.error("Failed to roll back deployment", {
79+
error: {
80+
name: error.name,
81+
message: error.message,
82+
stack: error.stack,
83+
},
84+
});
85+
submission.error = { runParam: error.message };
86+
return json(submission);
87+
} else {
88+
logger.error("Failed to roll back deployment", { error });
89+
submission.error = { runParam: JSON.stringify(error) };
90+
return json(submission);
91+
}
92+
}
93+
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { logger } from "~/services/logger.server";
2+
import { BaseService } from "./baseService.server";
3+
import { WorkerDeployment } from "@trigger.dev/database";
4+
import { CURRENT_DEPLOYMENT_LABEL } from "~/consts";
5+
6+
export class RollbackDeploymentService extends BaseService {
7+
public async call(deployment: WorkerDeployment) {
8+
if (deployment.status !== "DEPLOYED") {
9+
logger.error("Can't roll back to unsuccessful deployment", { id: deployment.id });
10+
return;
11+
}
12+
13+
const promotion = await this._prisma.workerDeploymentPromotion.findFirst({
14+
where: {
15+
deploymentId: deployment.id,
16+
label: CURRENT_DEPLOYMENT_LABEL,
17+
},
18+
});
19+
20+
if (promotion) {
21+
logger.error(`Deployment is already the current deployment`, { id: deployment.id });
22+
return;
23+
}
24+
25+
await this._prisma.workerDeploymentPromotion.upsert({
26+
where: {
27+
environmentId_label: {
28+
environmentId: deployment.environmentId,
29+
label: CURRENT_DEPLOYMENT_LABEL,
30+
},
31+
},
32+
create: {
33+
deploymentId: deployment.id,
34+
environmentId: deployment.environmentId,
35+
label: CURRENT_DEPLOYMENT_LABEL,
36+
},
37+
update: {
38+
deploymentId: deployment.id,
39+
},
40+
});
41+
42+
return {
43+
id: deployment.id,
44+
};
45+
}
46+
}

0 commit comments

Comments
 (0)