Skip to content

Commit

Permalink
fix: Move deployments between systems (#255)
Browse files Browse the repository at this point in the history
  • Loading branch information
adityachoudhari26 authored Dec 9, 2024
1 parent de5cf4a commit 981904b
Show file tree
Hide file tree
Showing 10 changed files with 225 additions and 39 deletions.
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
"use client";

import type { Deployment } from "@ctrlplane/db/schema";
import { useParams, useRouter } from "next/navigation";
import { z } from "zod";

import { deploymentSchema } from "@ctrlplane/db/schema";
import * as schema from "@ctrlplane/db/schema";
import { Button } from "@ctrlplane/ui/button";
import {
Form,
Expand All @@ -17,35 +16,48 @@ import {
useForm,
} from "@ctrlplane/ui/form";
import { Input } from "@ctrlplane/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ctrlplane/ui/select";
import { Textarea } from "@ctrlplane/ui/textarea";

import { api } from "~/trpc/react";

const deploymentForm = z.object(deploymentSchema.shape);
const deploymentForm = z.object(schema.deploymentSchema.shape);

type EditDeploymentSectionProps = {
deployment: schema.Deployment;
systems: schema.System[];
};

export const EditDeploymentSection: React.FC<{
deployment: Deployment;
}> = ({ deployment }) => {
export const EditDeploymentSection: React.FC<EditDeploymentSectionProps> = ({
deployment,
systems,
}) => {
const form = useForm({
schema: deploymentForm,
defaultValues: { ...deployment },
mode: "onSubmit",
});
const { handleSubmit, setError } = form;

const { workspaceSlug, systemSlug } = useParams<{
workspaceSlug: string;
systemSlug: string;
}>();
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();
const router = useRouter();
const updateDeployment = api.deployment.update.useMutation();
const onSubmit = handleSubmit((data) => {
updateDeployment
.mutateAsync({ id: deployment.id, data })
.then(() => {
if (data.slug !== deployment.slug)
.then((updatedDeployment) => {
if (
data.slug !== deployment.slug ||
updatedDeployment.systemId !== deployment.systemId
)
router.replace(
`/${workspaceSlug}/systems/${systemSlug}/deployments/${data.slug}`,
`/${workspaceSlug}/systems/${updatedDeployment.system.slug}/deployments/${data.slug}`,
);
router.refresh();
})
Expand Down Expand Up @@ -116,7 +128,30 @@ export const EditDeploymentSection: React.FC<{
</FormItem>
)}
/>

<FormField
control={form.control}
name="systemId"
render={({ field: { value, onChange } }) => (
<FormItem>
<FormLabel>System</FormLabel>
<FormControl>
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="Select a system" />
</SelectTrigger>
<SelectContent>
{systems.map((system) => (
<SelectItem key={system.id} value={system.id}>
{system.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="retryCount"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,11 @@ export default async function DeploymentPage({
}) {
const workspace = await api.workspace.bySlug(params.workspaceSlug);
if (workspace == null) return notFound();
const workspaceId = workspace.id;
const { items: systems } = await api.system.list({ workspaceId });
const deployment = await api.deployment.bySlug(params);
if (deployment == null) return notFound();
const jobAgents = await api.job.agent.byWorkspaceId(workspace.id);
const jobAgents = await api.job.agent.byWorkspaceId(workspaceId);
const jobAgent = jobAgents.find((a) => a.id === deployment.jobAgentId);

return (
Expand All @@ -162,7 +164,7 @@ export default async function DeploymentPage({
</div>
</div>
<div className="mb-16 flex-grow space-y-10">
<EditDeploymentSection deployment={deployment} />
<EditDeploymentSection deployment={deployment} systems={systems} />

<JobAgentSection
jobAgents={jobAgents}
Expand All @@ -172,7 +174,7 @@ export default async function DeploymentPage({
deploymentId={deployment.id}
/>

<Variables workspaceId={workspace.id} deployment={deployment} />
<Variables workspaceId={workspaceId} deployment={deployment} />
</div>
</div>
);
Expand Down
15 changes: 6 additions & 9 deletions packages/api/src/router/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,15 @@ import {
runbookVariable,
runhook,
system,
updateDeployment,
updateDeployment as updateDeploymentSchema,
updateHook,
updateReleaseChannel,
workspace,
} from "@ctrlplane/db/schema";
import {
getEventsForDeploymentDeleted,
getEventsForDeploymentRemoved,
handleEvent,
updateDeployment,
} from "@ctrlplane/job-dispatch";
import { Permission } from "@ctrlplane/validators/auth";
import { JobStatus } from "@ctrlplane/validators/jobs";
Expand Down Expand Up @@ -451,13 +452,9 @@ export const deploymentRouter = createTRPCRouter({
.perform(Permission.DeploymentUpdate)
.on({ type: "deployment", id: input.id }),
})
.input(z.object({ id: z.string().uuid(), data: updateDeployment }))
.input(z.object({ id: z.string().uuid(), data: updateDeploymentSchema }))
.mutation(({ ctx, input }) =>
ctx.db
.update(deployment)
.set(input.data)
.where(eq(deployment.id, input.id))
.returning(),
updateDeployment(input.id, input.data, ctx.session.user.id),
),

delete: protectedProcedure
Expand All @@ -474,7 +471,7 @@ export const deploymentRouter = createTRPCRouter({
.from(deployment)
.where(eq(deployment.id, input))
.then(takeFirst);
const events = await getEventsForDeploymentDeleted(dep);
const events = await getEventsForDeploymentRemoved(dep, dep.systemId);
await Promise.allSettled(events.map(handleEvent));
return ctx.db
.delete(deployment)
Expand Down
1 change: 1 addition & 0 deletions packages/db/src/schema/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const deploymentInsert = createInsertSchema(deployment, {

export const createDeployment = deploymentInsert;
export const updateDeployment = deploymentInsert.partial();
export type UpdateDeployment = z.infer<typeof updateDeployment>;
export type Deployment = InferSelectModel<typeof deployment>;

export const deploymentRelations = relations(deployment, ({ one }) => ({
Expand Down
152 changes: 152 additions & 0 deletions packages/job-dispatch/src/deployment-update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import type { ResourceCondition } from "@ctrlplane/validators/resources";
import { isPresent } from "ts-is-present";

import { and, eq, inArray, isNotNull, isNull, takeFirst } from "@ctrlplane/db";
import { db } from "@ctrlplane/db/client";
import * as SCHEMA from "@ctrlplane/db/schema";
import {
ComparisonOperator,
FilterType,
} from "@ctrlplane/validators/conditions";

import { handleEvent } from "./events/index.js";
import { dispatchReleaseJobTriggers } from "./job-dispatch.js";
import { isPassingReleaseStringCheckPolicy } from "./policies/release-string-check.js";
import { isPassingAllPolicies } from "./policy-checker.js";
import { createJobApprovals } from "./policy-create.js";
import { createReleaseJobTriggers } from "./release-job-trigger.js";

const getResourcesOnlyInNewSystem = async (
newSystemId: string,
oldSystemId: string,
) => {
const hasFilter = isNotNull(SCHEMA.environment.resourceFilter);
const newSystem = await db.query.system.findFirst({
where: eq(SCHEMA.system.id, newSystemId),
with: { environments: { where: hasFilter } },
});

const oldSystem = await db.query.system.findFirst({
where: eq(SCHEMA.system.id, oldSystemId),
with: { environments: { where: hasFilter } },
});

if (newSystem == null || oldSystem == null) return [];

const newSystemFilter: ResourceCondition = {
type: FilterType.Comparison,
operator: ComparisonOperator.Or,
conditions: newSystem.environments
.flatMap((env) => env.resourceFilter)
.filter(isPresent),
};

const notInOldSystemFilter: ResourceCondition = {
type: FilterType.Comparison,
operator: ComparisonOperator.Or,
not: true,
conditions: oldSystem.environments
.flatMap((env) => env.resourceFilter)
.filter(isPresent),
};

const filter: ResourceCondition = {
type: FilterType.Comparison,
operator: ComparisonOperator.And,
conditions: [newSystemFilter, notInOldSystemFilter],
};

return db.query.resource.findMany({
where: and(
SCHEMA.resourceMatchesMetadata(db, filter),
isNull(SCHEMA.resource.deletedAt),
),
});
};

export const handleDeploymentSystemChanged = async (
deployment: SCHEMA.Deployment,
prevSystemId: string,
userId?: string,
) => {
const resourcesOnlyInNewSystem = await getResourcesOnlyInNewSystem(
deployment.systemId,
prevSystemId,
);

const events = resourcesOnlyInNewSystem.map((resource) => ({
action: "deployment.resource.removed" as const,
payload: { deployment, resource },
}));
await Promise.allSettled(events.map(handleEvent));

const isDeploymentHook = and(
eq(SCHEMA.hook.scopeType, "deployment"),
eq(SCHEMA.hook.scopeId, deployment.id),
);
await db.query.hook
.findMany({
where: isDeploymentHook,
with: { runhooks: { with: { runbook: true } } },
})
.then((hooks) => {
const runbookIds = hooks.flatMap((h) =>
h.runhooks.map((rh) => rh.runbook.id),
);
return db
.update(SCHEMA.runbook)
.set({ systemId: deployment.systemId })
.where(inArray(SCHEMA.runbook.id, runbookIds));
});

const createTriggers =
userId != null
? createReleaseJobTriggers(db, "new_release").causedById(userId)
: createReleaseJobTriggers(db, "new_release");
await createTriggers
.deployments([deployment.id])
.resources(resourcesOnlyInNewSystem.map((r) => r.id))
.filter(isPassingReleaseStringCheckPolicy)
.then(createJobApprovals)
.insert()
.then((triggers) =>
dispatchReleaseJobTriggers(db)
.releaseTriggers(triggers)
.filter(isPassingAllPolicies)
.dispatch(),
);
};

export const updateDeployment = async (
deploymentId: string,
data: SCHEMA.UpdateDeployment,
userId?: string,
) => {
const prevDeployment = await db
.select()
.from(SCHEMA.deployment)
.where(eq(SCHEMA.deployment.id, deploymentId))
.then(takeFirst);

const updatedDeployment = await db
.update(SCHEMA.deployment)
.set(data)
.where(eq(SCHEMA.deployment.id, deploymentId))
.returning()
.then(takeFirst);

if (prevDeployment.systemId !== updatedDeployment.systemId)
await handleDeploymentSystemChanged(
updatedDeployment,
prevDeployment.systemId,
userId,
);

const sys = await db
.select()
.from(SCHEMA.system)
.where(eq(SCHEMA.system.id, updatedDeployment.systemId))
.then(takeFirst);

return { ...updatedDeployment, system: sys };
};
3 changes: 3 additions & 0 deletions packages/job-dispatch/src/events/handlers/resource-removed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export const handleResourceRemoved = async (event: ResourceRemoved) => {
.innerJoin(SCHEMA.hook, eq(SCHEMA.runhook.hookId, SCHEMA.hook.id))
.where(isSubscribedToResourceRemoved);

if (runhooks.length === 0) return;
await db.insert(SCHEMA.event).values(event);

const resourceId = resource.id;
const deploymentId = deployment.id;
const handleRunhooksPromises = runhooks.map(({ runhook }) =>
Expand Down
9 changes: 2 additions & 7 deletions packages/job-dispatch/src/events/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
import type { HookEvent } from "@ctrlplane/validators/events";

import { db } from "@ctrlplane/db/client";
import * as SCHEMA from "@ctrlplane/db/schema";

import { handleResourceRemoved } from "./handlers/index.js";

export * from "./triggers/index.js";
export * from "./handlers/index.js";

export const handleEvent = async (event: HookEvent) => {
await db.insert(SCHEMA.event).values(event);
return handleResourceRemoved(event);
};
export const handleEvent = async (event: HookEvent) =>
handleResourceRemoved(event);
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ import * as SCHEMA from "@ctrlplane/db/schema";
import { ComparisonOperator } from "@ctrlplane/validators/conditions";
import { ResourceFilterType } from "@ctrlplane/validators/resources";

export const getEventsForDeploymentDeleted = async (
export const getEventsForDeploymentRemoved = async (
deployment: SCHEMA.Deployment,
systemId: string,
): Promise<HookEvent[]> => {
const hasFilter = isNotNull(SCHEMA.environment.resourceFilter);
const system = await db.query.system.findFirst({
where: eq(SCHEMA.system.id, deployment.systemId),
with: {
environments: { where: isNotNull(SCHEMA.environment.resourceFilter) },
},
where: eq(SCHEMA.system.id, systemId),
with: { environments: { where: hasFilter } },
});
if (system == null) return [];

Expand Down
2 changes: 1 addition & 1 deletion packages/job-dispatch/src/events/triggers/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export * from "./environment-deleted.js";
export * from "./deployment-deleted.js";
export * from "./deployment-removed.js";
export * from "./resource-deleted.js";
1 change: 1 addition & 0 deletions packages/job-dispatch/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./config.js";
export * from "./release-job-trigger.js";
export * from "./deployment-update.js";
export * from "./job-update.js";
export * from "./job-dispatch.js";
export * from "./policy-checker.js";
Expand Down

0 comments on commit 981904b

Please sign in to comment.