Skip to content

Feat: two phase deployment, version pinning #1739

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Feb 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/cold-coins-burn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@trigger.dev/react-hooks": patch
"@trigger.dev/sdk": patch
"trigger.dev": patch
---

Add support for two-phase deployments and task version pinning
4 changes: 1 addition & 3 deletions apps/webapp/app/components/primitives/Tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { Link, NavLink, useLocation } from "@remix-run/react";
import { NavLink } from "@remix-run/react";
import { motion } from "framer-motion";
import { ReactNode, useRef } from "react";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { projectPubSub } from "~/v3/services/projectPubSub.server";
import { ShortcutKey } from "./ShortcutKey";

export type TabsProps = {
Expand Down
46 changes: 44 additions & 2 deletions apps/webapp/app/components/runs/v3/RollbackDeploymentDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function RollbackDeploymentDialog({

return (
<DialogContent key="rollback">
<DialogHeader>Roll back to this deployment?</DialogHeader>
<DialogHeader>Rollback to this deployment?</DialogHeader>
<DialogDescription>
This deployment will become the default for all future runs. Tasks triggered but not
included in this deploy will remain queued until you roll back to or create a new deployment
Expand All @@ -50,7 +50,49 @@ export function RollbackDeploymentDialog({
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Rolling back..." : "Roll back deployment"}
{isLoading ? "Rolling back..." : "Rollback deployment"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
);
}

export function PromoteDeploymentDialog({
projectId,
deploymentShortCode,
redirectPath,
}: RollbackDeploymentDialogProps) {
const navigation = useNavigation();

const formAction = `/resources/${projectId}/deployments/${deploymentShortCode}/promote`;
const isLoading = navigation.formAction === formAction;

return (
<DialogContent key="promote">
<DialogHeader>Promote this deployment?</DialogHeader>
<DialogDescription>
This deployment will become the default for all future runs not explicitly tied to a
specific deployment.
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Form
action={`/resources/${projectId}/deployments/${deploymentShortCode}/promote`}
method="post"
>
<Button
type="submit"
name="redirectUrl"
value={redirectPath}
variant="primary/medium"
LeadingIcon={isLoading ? "spinner-white" : ArrowPathIcon}
disabled={isLoading}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isLoading ? "Promoting..." : "Promote deployment"}
</Button>
</Form>
</DialogFooter>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
ArrowPathIcon,
ArrowUturnLeftIcon,
ArrowUturnRightIcon,
BookOpenIcon,
ServerStackIcon,
} from "@heroicons/react/20/solid";
Expand Down Expand Up @@ -41,7 +42,10 @@ import {
deploymentStatuses,
} from "~/components/runs/v3/DeploymentStatus";
import { RetryDeploymentIndexingDialog } from "~/components/runs/v3/RetryDeploymentIndexingDialog";
import { RollbackDeploymentDialog } from "~/components/runs/v3/RollbackDeploymentDialog";
import {
PromoteDeploymentDialog,
RollbackDeploymentDialog,
} from "~/components/runs/v3/RollbackDeploymentDialog";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useUser } from "~/hooks/useUser";
Expand All @@ -58,6 +62,7 @@ import {
} from "~/utils/pathBuilder";
import { createSearchParams } from "~/utils/searchParams";
import { deploymentIndexingIsRetryable } from "~/v3/deploymentStatus";
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";

export const meta: MetaFunction = () => {
return [
Expand Down Expand Up @@ -106,6 +111,8 @@ export default function Page() {

const { deploymentParam } = useParams();

const currentDeployment = deployments.find((d) => d.isCurrent);

return (
<PageContainer>
<NavBar>
Expand Down Expand Up @@ -234,6 +241,7 @@ export default function Page() {
deployment={deployment}
path={path}
isSelected={isSelected}
currentDeployment={currentDeployment}
/>
</TableRow>
);
Expand Down Expand Up @@ -320,18 +328,25 @@ function DeploymentActionsCell({
deployment,
path,
isSelected,
currentDeployment,
}: {
deployment: DeploymentListItem;
path: string;
isSelected: boolean;
currentDeployment?: DeploymentListItem;
}) {
const location = useLocation();
const project = useProject();

const canRollback = !deployment.isCurrent && deployment.isDeployed;
const canBeMadeCurrent = !deployment.isCurrent && deployment.isDeployed;
const canRetryIndexing = deployment.isLatest && deploymentIndexingIsRetryable(deployment);
const canBeRolledBack =
canBeMadeCurrent &&
currentDeployment?.version &&
compareDeploymentVersions(deployment.version, currentDeployment.version) === -1;
const canBePromoted = canBeMadeCurrent && !canBeRolledBack;

if (!canRollback && !canRetryIndexing) {
if (!canBeMadeCurrent && !canRetryIndexing) {
return (
<TableCell to={path} isSelected={isSelected}>
{""}
Expand All @@ -345,7 +360,7 @@ function DeploymentActionsCell({
isSelected={isSelected}
popoverContent={
<>
{canRollback && (
{canBeRolledBack && (
<Dialog>
<DialogTrigger asChild>
<Button
Expand All @@ -365,6 +380,26 @@ function DeploymentActionsCell({
/>
</Dialog>
)}
{canBePromoted && (
<Dialog>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={ArrowUturnRightIcon}
leadingIconClassName="text-blue-500"
fullWidth
textAlignLeft
>
Promote…
</Button>
</DialogTrigger>
<PromoteDeploymentDialog
projectId={project.id}
deploymentShortCode={deployment.shortCode}
redirectPath={`${location.pathname}${location.search}`}
/>
</Dialog>
)}
{canRetryIndexing && (
<Dialog>
<DialogTrigger asChild>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server";

const ParamsSchema = z.object({
deploymentVersion: z.string(),
});

export async function action({ request, params }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}

const parsedParams = ParamsSchema.safeParse(params);

if (!parsedParams.success) {
return json({ error: "Invalid params" }, { status: 400 });
}

// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);

if (!authenticationResult) {
logger.info("Invalid or missing api key", { url: request.url });
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}

const authenticatedEnv = authenticationResult.environment;

const { deploymentVersion } = parsedParams.data;

const deployment = await prisma.workerDeployment.findFirst({
where: {
version: deploymentVersion,
environmentId: authenticatedEnv.id,
},
});

if (!deployment) {
return json({ error: "Deployment not found" }, { status: 404 });
}

try {
const service = new ChangeCurrentDeploymentService();
await service.call(deployment, "promote");

return json(
{
id: deployment.friendlyId,
version: deployment.version,
shortCode: deployment.shortCode,
},
{ status: 200 }
);
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 400 });
} else {
return json({ error: "Failed to promote deployment" }, { status: 500 });
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { parse } from "@conform-to/zod";
import { ActionFunction, json } from "@remix-run/node";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server";

export const promoteSchema = z.object({
redirectUrl: z.string(),
});

const ParamSchema = z.object({
projectId: z.string(),
deploymentShortCode: z.string(),
});

export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { projectId, deploymentShortCode } = ParamSchema.parse(params);

const formData = await request.formData();
const submission = parse(formData, { schema: promoteSchema });

if (!submission.value) {
return json(submission);
}

try {
const project = await prisma.project.findUnique({
where: {
id: projectId,
organization: {
members: {
some: {
userId,
},
},
},
},
});

if (!project) {
return redirectWithErrorMessage(submission.value.redirectUrl, request, "Project not found");
}

const deployment = await prisma.workerDeployment.findUnique({
where: {
projectId_shortCode: {
projectId: project.id,
shortCode: deploymentShortCode,
},
},
});

if (!deployment) {
return redirectWithErrorMessage(
submission.value.redirectUrl,
request,
"Deployment not found"
);
}

const promoteService = new ChangeCurrentDeploymentService();
await promoteService.call(deployment, "promote");

return redirectWithSuccessMessage(
submission.value.redirectUrl,
request,
`Promoted deployment version ${deployment.version} to current.`
);
} catch (error) {
if (error instanceof Error) {
logger.error("Failed to promote deployment", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
submission.error = { runParam: error.message };
return json(submission);
} else {
logger.error("Failed to promote deployment", { error });
submission.error = { runParam: JSON.stringify(error) };
return json(submission);
}
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { prisma } from "~/db.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { RollbackDeploymentService } from "~/v3/services/rollbackDeployment.server";
import { ChangeCurrentDeploymentService } from "~/v3/services/changeCurrentDeployment.server";

export const rollbackSchema = z.object({
redirectUrl: z.string(),
Expand Down Expand Up @@ -65,8 +65,8 @@ export const action: ActionFunction = async ({ request, params }) => {
);
}

const rollbackService = new RollbackDeploymentService();
await rollbackService.call(deployment);
const rollbackService = new ChangeCurrentDeploymentService();
await rollbackService.call(deployment, "rollback");

return redirectWithSuccessMessage(
submission.value.redirectUrl,
Expand Down
4 changes: 3 additions & 1 deletion apps/webapp/app/v3/authenticatedSocketConnection.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ export class AuthenticatedSocketConnection {
});
});
},
canSendMessage: () => ws.readyState === WebSocket.OPEN,
canSendMessage() {
return ws.readyState === WebSocket.OPEN;
},
});

this._consumer = new DevQueueConsumer(this.id, authenticatedEnv, this._sender, {
Expand Down
Loading