From 981904b10ca188cf2de1e91551d4e50787cf1403 Mon Sep 17 00:00:00 2001 From: Aditya Choudhari <48932219+adityachoudhari26@users.noreply.github.com> Date: Mon, 9 Dec 2024 13:16:55 -0800 Subject: [PATCH] fix: Move deployments between systems (#255) --- .../EditDeploymentSection.tsx | 63 ++++++-- .../deployments/[deploymentSlug]/page.tsx | 8 +- packages/api/src/router/deployment.ts | 15 +- packages/db/src/schema/deployment.ts | 1 + .../job-dispatch/src/deployment-update.ts | 152 ++++++++++++++++++ .../src/events/handlers/resource-removed.ts | 3 + packages/job-dispatch/src/events/index.ts | 9 +- ...yment-deleted.ts => deployment-removed.ts} | 10 +- .../job-dispatch/src/events/triggers/index.ts | 2 +- packages/job-dispatch/src/index.ts | 1 + 10 files changed, 225 insertions(+), 39 deletions(-) create mode 100644 packages/job-dispatch/src/deployment-update.ts rename packages/job-dispatch/src/events/triggers/{deployment-deleted.ts => deployment-removed.ts} (84%) diff --git a/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/deployments/[deploymentSlug]/EditDeploymentSection.tsx b/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/deployments/[deploymentSlug]/EditDeploymentSection.tsx index c4a31a31..d6fc133e 100644 --- a/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/deployments/[deploymentSlug]/EditDeploymentSection.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/(app)/systems/[systemSlug]/deployments/[deploymentSlug]/EditDeploymentSection.tsx @@ -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, @@ -17,15 +16,28 @@ 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 = ({ + deployment, + systems, +}) => { const form = useForm({ schema: deploymentForm, defaultValues: { ...deployment }, @@ -33,19 +45,19 @@ export const EditDeploymentSection: React.FC<{ }); 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(); }) @@ -116,7 +128,30 @@ export const EditDeploymentSection: React.FC<{ )} /> - + ( + + System + + + + + + )} + /> a.id === deployment.jobAgentId); return ( @@ -162,7 +164,7 @@ export default async function DeploymentPage({
- + - +
); diff --git a/packages/api/src/router/deployment.ts b/packages/api/src/router/deployment.ts index 8132bb7f..5df94c05 100644 --- a/packages/api/src/router/deployment.ts +++ b/packages/api/src/router/deployment.ts @@ -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"; @@ -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 @@ -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) diff --git a/packages/db/src/schema/deployment.ts b/packages/db/src/schema/deployment.ts index 40172933..52dfefbc 100644 --- a/packages/db/src/schema/deployment.ts +++ b/packages/db/src/schema/deployment.ts @@ -70,6 +70,7 @@ const deploymentInsert = createInsertSchema(deployment, { export const createDeployment = deploymentInsert; export const updateDeployment = deploymentInsert.partial(); +export type UpdateDeployment = z.infer; export type Deployment = InferSelectModel; export const deploymentRelations = relations(deployment, ({ one }) => ({ diff --git a/packages/job-dispatch/src/deployment-update.ts b/packages/job-dispatch/src/deployment-update.ts new file mode 100644 index 00000000..d1df003c --- /dev/null +++ b/packages/job-dispatch/src/deployment-update.ts @@ -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 }; +}; diff --git a/packages/job-dispatch/src/events/handlers/resource-removed.ts b/packages/job-dispatch/src/events/handlers/resource-removed.ts index 64eebae0..8feacbc1 100644 --- a/packages/job-dispatch/src/events/handlers/resource-removed.ts +++ b/packages/job-dispatch/src/events/handlers/resource-removed.ts @@ -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 }) => diff --git a/packages/job-dispatch/src/events/index.ts b/packages/job-dispatch/src/events/index.ts index 380956cc..369fb6a0 100644 --- a/packages/job-dispatch/src/events/index.ts +++ b/packages/job-dispatch/src/events/index.ts @@ -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); diff --git a/packages/job-dispatch/src/events/triggers/deployment-deleted.ts b/packages/job-dispatch/src/events/triggers/deployment-removed.ts similarity index 84% rename from packages/job-dispatch/src/events/triggers/deployment-deleted.ts rename to packages/job-dispatch/src/events/triggers/deployment-removed.ts index 6d065f18..c9d5ac9e 100644 --- a/packages/job-dispatch/src/events/triggers/deployment-deleted.ts +++ b/packages/job-dispatch/src/events/triggers/deployment-removed.ts @@ -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 => { + 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 []; diff --git a/packages/job-dispatch/src/events/triggers/index.ts b/packages/job-dispatch/src/events/triggers/index.ts index 3fdb0840..837e2bd2 100644 --- a/packages/job-dispatch/src/events/triggers/index.ts +++ b/packages/job-dispatch/src/events/triggers/index.ts @@ -1,3 +1,3 @@ export * from "./environment-deleted.js"; -export * from "./deployment-deleted.js"; +export * from "./deployment-removed.js"; export * from "./resource-deleted.js"; diff --git a/packages/job-dispatch/src/index.ts b/packages/job-dispatch/src/index.ts index c396743b..cb7ed666 100644 --- a/packages/job-dispatch/src/index.ts +++ b/packages/job-dispatch/src/index.ts @@ -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";