Skip to content

Commit 26f9a1e

Browse files
authored
Feat: two phase deployment, version pinning (#1739)
* WIP two-phase deployments * Fix the help text * Rename TRIGGER_WORKER_VERSION to TRIGGER_VERSION * Add changeset * A few naming fixes
1 parent 0e5ec8b commit 26f9a1e

23 files changed

+598
-86
lines changed

.changeset/cold-coins-burn.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@trigger.dev/react-hooks": patch
3+
"@trigger.dev/sdk": patch
4+
"trigger.dev": patch
5+
---
6+
7+
Add support for two-phase deployments and task version pinning

apps/webapp/app/components/primitives/Tabs.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
import { Link, NavLink, useLocation } from "@remix-run/react";
1+
import { NavLink } from "@remix-run/react";
22
import { motion } from "framer-motion";
33
import { ReactNode, useRef } from "react";
4-
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
54
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
65
import { cn } from "~/utils/cn";
7-
import { projectPubSub } from "~/v3/services/projectPubSub.server";
86
import { ShortcutKey } from "./ShortcutKey";
97

108
export type TabsProps = {

apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function RollbackDeploymentDialog({
2727

2828
return (
2929
<DialogContent key="rollback">
30-
<DialogHeader>Roll back to this deployment?</DialogHeader>
30+
<DialogHeader>Rollback to this deployment?</DialogHeader>
3131
<DialogDescription>
3232
This deployment will become the default for all future runs. Tasks triggered but not
3333
included in this deploy will remain queued until you roll back to or create a new deployment
@@ -50,7 +50,49 @@ export function RollbackDeploymentDialog({
5050
disabled={isLoading}
5151
shortcut={{ modifiers: ["mod"], key: "enter" }}
5252
>
53-
{isLoading ? "Rolling back..." : "Roll back deployment"}
53+
{isLoading ? "Rolling back..." : "Rollback deployment"}
54+
</Button>
55+
</Form>
56+
</DialogFooter>
57+
</DialogContent>
58+
);
59+
}
60+
61+
export function PromoteDeploymentDialog({
62+
projectId,
63+
deploymentShortCode,
64+
redirectPath,
65+
}: RollbackDeploymentDialogProps) {
66+
const navigation = useNavigation();
67+
68+
const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`;
69+
const isLoading = navigation.formAction === formAction;
70+
71+
return (
72+
<DialogContent key="promote">
73+
<DialogHeader>Promote this deployment?</DialogHeader>
74+
<DialogDescription>
75+
This deployment will become the default for all future runs not explicitly tied to a
76+
specific deployment.
77+
</DialogDescription>
78+
<DialogFooter>
79+
<DialogClose asChild>
80+
<Button variant="tertiary/medium">Cancel</Button>
81+
</DialogClose>
82+
<Form
83+
action={`/resources/${projectId}/deployments/${deploymentShortCode}/promote`}
84+
method="post"
85+
>
86+
<Button
87+
type="submit"
88+
name="redirectUrl"
89+
value={redirectPath}
90+
variant="primary/medium"
91+
LeadingIcon={isLoading ? "spinner-white" : ArrowPathIcon}
92+
disabled={isLoading}
93+
shortcut={{ modifiers: ["mod"], key: "enter" }}
94+
>
95+
{isLoading ? "Promoting..." : "Promote deployment"}
5496
</Button>
5597
</Form>
5698
</DialogFooter>

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

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
ArrowPathIcon,
33
ArrowUturnLeftIcon,
4+
ArrowUturnRightIcon,
45
BookOpenIcon,
56
ServerStackIcon,
67
} from "@heroicons/react/20/solid";
@@ -41,7 +42,10 @@ import {
4142
deploymentStatuses,
4243
} from "~/components/runs/v3/DeploymentStatus";
4344
import { RetryDeploymentIndexingDialog } from "~/components/runs/v3/RetryDeploymentIndexingDialog";
44-
import { RollbackDeploymentDialog } from "~/components/runs/v3/RollbackDeploymentDialog";
45+
import {
46+
PromoteDeploymentDialog,
47+
RollbackDeploymentDialog,
48+
} from "~/components/runs/v3/RollbackDeploymentDialog";
4549
import { useOrganization } from "~/hooks/useOrganizations";
4650
import { useProject } from "~/hooks/useProject";
4751
import { useUser } from "~/hooks/useUser";
@@ -58,6 +62,7 @@ import {
5862
} from "~/utils/pathBuilder";
5963
import { createSearchParams } from "~/utils/searchParams";
6064
import { deploymentIndexingIsRetryable } from "~/v3/deploymentStatus";
65+
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
6166

6267
export const meta: MetaFunction = () => {
6368
return [
@@ -106,6 +111,8 @@ export default function Page() {
106111

107112
const { deploymentParam } = useParams();
108113

114+
const currentDeployment = deployments.find((d) => d.isCurrent);
115+
109116
return (
110117
<PageContainer>
111118
<NavBar>
@@ -234,6 +241,7 @@ export default function Page() {
234241
deployment={deployment}
235242
path={path}
236243
isSelected={isSelected}
244+
currentDeployment={currentDeployment}
237245
/>
238246
</TableRow>
239247
);
@@ -320,18 +328,25 @@ function DeploymentActionsCell({
320328
deployment,
321329
path,
322330
isSelected,
331+
currentDeployment,
323332
}: {
324333
deployment: DeploymentListItem;
325334
path: string;
326335
isSelected: boolean;
336+
currentDeployment?: DeploymentListItem;
327337
}) {
328338
const location = useLocation();
329339
const project = useProject();
330340

331-
const canRollback = !deployment.isCurrent && deployment.isDeployed;
341+
const canBeMadeCurrent = !deployment.isCurrent && deployment.isDeployed;
332342
const canRetryIndexing = deployment.isLatest && deploymentIndexingIsRetryable(deployment);
343+
const canBeRolledBack =
344+
canBeMadeCurrent &&
345+
currentDeployment?.version &&
346+
compareDeploymentVersions(deployment.version, currentDeployment.version) === -1;
347+
const canBePromoted = canBeMadeCurrent && !canBeRolledBack;
333348

334-
if (!canRollback && !canRetryIndexing) {
349+
if (!canBeMadeCurrent && !canRetryIndexing) {
335350
return (
336351
<TableCell to={path} isSelected={isSelected}>
337352
{""}
@@ -345,7 +360,7 @@ function DeploymentActionsCell({
345360
isSelected={isSelected}
346361
popoverContent={
347362
<>
348-
{canRollback && (
363+
{canBeRolledBack && (
349364
<Dialog>
350365
<DialogTrigger asChild>
351366
<Button
@@ -365,6 +380,26 @@ function DeploymentActionsCell({
365380
/>
366381
</Dialog>
367382
)}
383+
{canBePromoted && (
384+
<Dialog>
385+
<DialogTrigger asChild>
386+
<Button
387+
variant="small-menu-item"
388+
LeadingIcon={ArrowUturnRightIcon}
389+
leadingIconClassName="text-blue-500"
390+
fullWidth
391+
textAlignLeft
392+
>
393+
Promote…
394+
</Button>
395+
</DialogTrigger>
396+
<PromoteDeploymentDialog
397+
projectId={project.id}
398+
deploymentShortCode={deployment.shortCode}
399+
redirectPath={`${location.pathname}${location.search}`}
400+
/>
401+
</Dialog>
402+
)}
368403
{canRetryIndexing && (
369404
<Dialog>
370405
<DialogTrigger asChild>
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
2+
import { z } from "zod";
3+
import { prisma } from "~/db.server";
4+
import { authenticateApiRequest } from "~/services/apiAuth.server";
5+
import { logger } from "~/services/logger.server";
6+
import { ServiceValidationError } from "~/v3/services/baseService.server";
7+
import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server";
8+
9+
const ParamsSchema = z.object({
10+
deploymentVersion: z.string(),
11+
});
12+
13+
export async function action({ request, params }: ActionFunctionArgs) {
14+
// Ensure this is a POST request
15+
if (request.method.toUpperCase() !== "POST") {
16+
return { status: 405, body: "Method Not Allowed" };
17+
}
18+
19+
const parsedParams = ParamsSchema.safeParse(params);
20+
21+
if (!parsedParams.success) {
22+
return json({ error: "Invalid params" }, { status: 400 });
23+
}
24+
25+
// Next authenticate the request
26+
const authenticationResult = await authenticateApiRequest(request);
27+
28+
if (!authenticationResult) {
29+
logger.info("Invalid or missing api key", { url: request.url });
30+
return json({ error: "Invalid or Missing API key" }, { status: 401 });
31+
}
32+
33+
const authenticatedEnv = authenticationResult.environment;
34+
35+
const { deploymentVersion } = parsedParams.data;
36+
37+
const deployment = await prisma.workerDeployment.findFirst({
38+
where: {
39+
version: deploymentVersion,
40+
environmentId: authenticatedEnv.id,
41+
},
42+
});
43+
44+
if (!deployment) {
45+
return json({ error: "Deployment not found" }, { status: 404 });
46+
}
47+
48+
try {
49+
const service = new ChangeCurrentDeploymentService();
50+
await service.call(deployment, "promote");
51+
52+
return json(
53+
{
54+
id: deployment.friendlyId,
55+
version: deployment.version,
56+
shortCode: deployment.shortCode,
57+
},
58+
{ status: 200 }
59+
);
60+
} catch (error) {
61+
if (error instanceof ServiceValidationError) {
62+
return json({ error: error.message }, { status: 400 });
63+
} else {
64+
return json({ error: "Failed to promote deployment" }, { status: 500 });
65+
}
66+
}
67+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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 { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server";
9+
10+
export const promoteSchema = 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+
const formData = await request.formData();
24+
const submission = parse(formData, { schema: promoteSchema });
25+
26+
if (!submission.value) {
27+
return json(submission);
28+
}
29+
30+
try {
31+
const project = await prisma.project.findUnique({
32+
where: {
33+
id: projectId,
34+
organization: {
35+
members: {
36+
some: {
37+
userId,
38+
},
39+
},
40+
},
41+
},
42+
});
43+
44+
if (!project) {
45+
return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found");
46+
}
47+
48+
const deployment = await prisma.workerDeployment.findUnique({
49+
where: {
50+
projectId_shortCode: {
51+
projectId: project.id,
52+
shortCode: deploymentShortCode,
53+
},
54+
},
55+
});
56+
57+
if (!deployment) {
58+
return redirectWithErrorMessage(
59+
submission.value.redirectUrl,
60+
request,
61+
"Deployment not found"
62+
);
63+
}
64+
65+
const promoteService = new ChangeCurrentDeploymentService();
66+
await promoteService.call(deployment, "promote");
67+
68+
return redirectWithSuccessMessage(
69+
submission.value.redirectUrl,
70+
request,
71+
`Promoted deployment version ${deployment.version} to current.`
72+
);
73+
} catch (error) {
74+
if (error instanceof Error) {
75+
logger.error("Failed to promote deployment", {
76+
error: {
77+
name: error.name,
78+
message: error.message,
79+
stack: error.stack,
80+
},
81+
});
82+
submission.error = { runParam: error.message };
83+
return json(submission);
84+
} else {
85+
logger.error("Failed to promote deployment", { error });
86+
submission.error = { runParam: JSON.stringify(error) };
87+
return json(submission);
88+
}
89+
}
90+
};

apps/webapp/app/routes/resources.$projectId.deployments.$deploymentShortCode.rollback.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { prisma } from "~/db.server";
55
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
66
import { logger } from "~/services/logger.server";
77
import { requireUserId } from "~/services/session.server";
8-
import { RollbackDeploymentService } from "~/v3/services/rollbackDeployment.server";
8+
import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server";
99

1010
export const rollbackSchema = z.object({
1111
redirectUrl: z.string(),
@@ -65,8 +65,8 @@ export const action: ActionFunction = async ({ request, params }) => {
6565
);
6666
}
6767

68-
const rollbackService = new RollbackDeploymentService();
69-
await rollbackService.call(deployment);
68+
const rollbackService = new ChangeCurrentDeploymentService();
69+
await rollbackService.call(deployment, "rollback");
7070

7171
return redirectWithSuccessMessage(
7272
submission.value.redirectUrl,

apps/webapp/app/v3/authenticatedSocketConnection.server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ export class AuthenticatedSocketConnection {
4343
});
4444
});
4545
},
46-
canSendMessage: () => ws.readyState === WebSocket.OPEN,
46+
canSendMessage() {
47+
return ws.readyState === WebSocket.OPEN;
48+
},
4749
});
4850

4951
this._consumer = new DevQueueConsumer(this.id, authenticatedEnv, this._sender, {

0 commit comments

Comments
 (0)