diff --git a/packages/api/src/router/environment.ts b/packages/api/src/router/environment.ts index 65056d13..9099bb25 100644 --- a/packages/api/src/router/environment.ts +++ b/packages/api/src/router/environment.ts @@ -1,4 +1,5 @@ import type { Tx } from "@ctrlplane/db"; +import type { ResourceCondition } from "@ctrlplane/validators/resources"; import _ from "lodash"; import { isPresent } from "ts-is-present"; import { z } from "zod"; @@ -8,6 +9,8 @@ import { buildConflictUpdateColumns, eq, inArray, + isNotNull, + ne, not, takeFirst, } from "@ctrlplane/db"; @@ -26,6 +29,10 @@ import { import { getEventsForEnvironmentDeleted, handleEvent } from "@ctrlplane/events"; import { dispatchJobsForNewResources } from "@ctrlplane/job-dispatch"; import { Permission } from "@ctrlplane/validators/auth"; +import { + ComparisonOperator, + FilterType, +} from "@ctrlplane/validators/conditions"; import { createTRPCRouter, protectedProcedure } from "../trpc"; import { policyRouter } from "./environment-policy"; @@ -234,21 +241,74 @@ export const environmentRouter = createTRPCRouter({ ); if (hasResourceFiltersChanged) { + const isOtherEnv = and( + isNotNull(environment.resourceFilter), + ne(environment.id, input.id), + ); + const sys = await ctx.db.query.system.findFirst({ + where: eq(system.id, oldEnv.system.id), + with: { environments: { where: isOtherEnv }, deployments: true }, + }); + + const otherEnvFilters = + sys?.environments.map((e) => e.resourceFilter).filter(isPresent) ?? + []; + const oldQuery = resourceMatchesMetadata( ctx.db, oldEnv.environment.resourceFilter, ); + const newQuery = resourceMatchesMetadata(ctx.db, resourceFilter); + const newResources = await ctx.db .select({ id: resource.id }) .from(resource) .where( and( eq(resource.workspaceId, oldEnv.system.workspaceId), - resourceMatchesMetadata(ctx.db, resourceFilter), + newQuery, oldQuery && not(oldQuery), ), ); + const removedResources = await ctx.db.query.resource.findMany({ + where: and( + eq(resource.workspaceId, oldEnv.system.workspaceId), + oldQuery, + newQuery && not(newQuery), + ), + }); + + if (removedResources.length > 0) { + const sysFilter: ResourceCondition = { + type: FilterType.Comparison, + operator: ComparisonOperator.Or, + not: true, + conditions: otherEnvFilters, + }; + + const removedFromSystemResources = + await ctx.db.query.resource.findMany({ + where: and( + inArray( + resource.id, + removedResources.map((r) => r.id), + ), + resourceMatchesMetadata(ctx.db, sysFilter), + ), + }); + + const events = removedFromSystemResources.flatMap((resource) => + (sys?.deployments ?? []).map((deployment) => ({ + action: "deployment.resource.removed" as const, + payload: { deployment, resource }, + })), + ); + + const handleEventPromises = events.map(handleEvent); + await Promise.allSettled(handleEventPromises); + } + if (newResources.length > 0) { await dispatchJobsForNewResources( ctx.db, diff --git a/packages/api/src/router/resources.ts b/packages/api/src/router/resources.ts index 5e4bf925..c63dfb89 100644 --- a/packages/api/src/router/resources.ts +++ b/packages/api/src/router/resources.ts @@ -17,7 +17,7 @@ import { takeFirstOrNull, } from "@ctrlplane/db"; import * as schema from "@ctrlplane/db/schema"; -import { getEventsForTargetDeleted, handleEvent } from "@ctrlplane/events"; +import { getEventsForResourceDeleted, handleEvent } from "@ctrlplane/events"; import { cancelOldReleaseJobTriggersOnJobDispatch, createJobApprovals, @@ -582,7 +582,7 @@ export const resourceRouter = createTRPCRouter({ where: inArray(schema.resource.id, input), }); const events = ( - await Promise.allSettled(resources.map(getEventsForTargetDeleted)) + await Promise.allSettled(resources.map(getEventsForResourceDeleted)) ).flatMap((r) => (r.status === "fulfilled" ? r.value : [])); await Promise.allSettled(events.map(handleEvent)); diff --git a/packages/events/src/handlers/target-removed.ts b/packages/events/src/handlers/target-removed.ts index 62dbd69f..7902af3e 100644 --- a/packages/events/src/handlers/target-removed.ts +++ b/packages/events/src/handlers/target-removed.ts @@ -1,28 +1,28 @@ -import type { TargetRemoved } from "@ctrlplane/validators/events"; +import type { ResourceRemoved } from "@ctrlplane/validators/events"; import { and, eq } from "@ctrlplane/db"; import { db } from "@ctrlplane/db/client"; import * as SCHEMA from "@ctrlplane/db/schema"; import { dispatchRunbook } from "@ctrlplane/job-dispatch"; -export const handleTargetRemoved = async (event: TargetRemoved) => { - const { target, deployment } = event.payload; +export const handleResourceRemoved = async (event: ResourceRemoved) => { + const { resource, deployment } = event.payload; - const isSubscribedToTargetRemoved = and( + const isSubscribedToResourceRemoved = and( eq(SCHEMA.hook.scopeId, deployment.id), eq(SCHEMA.hook.scopeType, "deployment"), - eq(SCHEMA.hook.action, "deployment.target.removed"), + eq(SCHEMA.hook.action, "deployment.resource.removed"), ); const runhooks = await db .select() .from(SCHEMA.runhook) .innerJoin(SCHEMA.hook, eq(SCHEMA.runhook.hookId, SCHEMA.hook.id)) - .where(isSubscribedToTargetRemoved); + .where(isSubscribedToResourceRemoved); - const targetId = target.id; + const resourceId = resource.id; const deploymentId = deployment.id; const handleRunhooksPromises = runhooks.map(({ runhook }) => - dispatchRunbook(db, runhook.runbookId, { targetId, deploymentId }), + dispatchRunbook(db, runhook.runbookId, { resourceId, deploymentId }), ); await Promise.all(handleRunhooksPromises); diff --git a/packages/events/src/index.ts b/packages/events/src/index.ts index 0908e6b1..dc4137df 100644 --- a/packages/events/src/index.ts +++ b/packages/events/src/index.ts @@ -2,15 +2,15 @@ import type { HookEvent } from "@ctrlplane/validators/events"; import { db } from "@ctrlplane/db/client"; import * as SCHEMA from "@ctrlplane/db/schema"; -import { isTargetRemoved } from "@ctrlplane/validators/events"; +import { isResourceRemoved } from "@ctrlplane/validators/events"; -import { handleTargetRemoved } from "./handlers/target-removed.js"; +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); - if (isTargetRemoved(event)) return handleTargetRemoved(event); - throw new Error(`Unhandled event: ${event.action}`); + if (isResourceRemoved(event)) return handleResourceRemoved(event); + throw new Error(`Unhandled event`); }; diff --git a/packages/events/src/triggers/deployment-deleted.ts b/packages/events/src/triggers/deployment-deleted.ts index 57916403..89053e05 100644 --- a/packages/events/src/triggers/deployment-deleted.ts +++ b/packages/events/src/triggers/deployment-deleted.ts @@ -30,12 +30,12 @@ export const getEventsForDeploymentDeleted = async ( conditions: envFilters, }; - const targets = await db.query.resource.findMany({ + const resources = await db.query.resource.findMany({ where: SCHEMA.resourceMatchesMetadata(db, systemFilter), }); - return targets.map((target) => ({ - action: "deployment.target.removed", - payload: { deployment, target }, + return resources.map((resource) => ({ + action: "deployment.resource.removed", + payload: { deployment, resource }, })); }; diff --git a/packages/events/src/triggers/environment-deleted.ts b/packages/events/src/triggers/environment-deleted.ts index 778d9a52..c43e67ec 100644 --- a/packages/events/src/triggers/environment-deleted.ts +++ b/packages/events/src/triggers/environment-deleted.ts @@ -12,11 +12,11 @@ export const getEventsForEnvironmentDeleted = async ( environment: SCHEMA.Environment, ): Promise => { if (environment.resourceFilter == null) return []; - const targets = await db + const resources = await db .select() .from(SCHEMA.resource) .where(SCHEMA.resourceMatchesMetadata(db, environment.resourceFilter)); - if (targets.length === 0) return []; + if (resources.length === 0) return []; const checks = and( isNotNull(SCHEMA.environment.resourceFilter), @@ -39,7 +39,7 @@ export const getEventsForEnvironmentDeleted = async ( conditions: envFilters, }; - const removedFromSystemTargets = + const removedFromSystemResources = envFilters.length > 0 ? await db .select() @@ -49,17 +49,17 @@ export const getEventsForEnvironmentDeleted = async ( SCHEMA.resourceMatchesMetadata(db, removedFromSystemFilter), inArray( SCHEMA.resource.id, - targets.map((t) => t.id), + resources.map((r) => r.id), ), ), ) - : targets; - if (removedFromSystemTargets.length === 0) return []; + : resources; + if (removedFromSystemResources.length === 0) return []; return system.deployments.flatMap((deployment) => - removedFromSystemTargets.map((target) => ({ - action: "deployment.target.removed", - payload: { deployment, target }, + removedFromSystemResources.map((resource) => ({ + action: "deployment.resource.removed", + payload: { deployment, resource }, })), ); }; diff --git a/packages/events/src/triggers/target-deleted.ts b/packages/events/src/triggers/target-deleted.ts index 542a3d83..cf54ba7f 100644 --- a/packages/events/src/triggers/target-deleted.ts +++ b/packages/events/src/triggers/target-deleted.ts @@ -9,16 +9,16 @@ import { ComparisonOperator } from "@ctrlplane/validators/conditions"; import { ResourceFilterType } from "@ctrlplane/validators/resources"; /** - * Get events for a target that has been deleted. - * NOTE: Because we may need to do inner joins on target metadata for the filter, - * this actually needs to be called before the target is actually deleted. - * @param target + * Get events for a resource that has been deleted. + * NOTE: Because we may need to do inner joins on resource metadata for the filter, + * this actually needs to be called before the resource is actually deleted. + * @param resource */ -export const getEventsForTargetDeleted = async ( - target: SCHEMA.Resource, +export const getEventsForResourceDeleted = async ( + resource: SCHEMA.Resource, ): Promise => { const systems = await db.query.system.findMany({ - where: eq(SCHEMA.system.workspaceId, target.workspaceId), + where: eq(SCHEMA.system.workspaceId, resource.workspaceId), with: { environments: { where: isNotNull(SCHEMA.environment.resourceFilter) }, deployments: true, @@ -36,17 +36,17 @@ export const getEventsForTargetDeleted = async ( conditions: filters, }; - const matchedTarget = await db.query.resource.findFirst({ + const matchedResource = await db.query.resource.findFirst({ where: SCHEMA.resourceMatchesMetadata(db, systemFilter), }); - if (matchedTarget == null) return []; + if (matchedResource == null) return []; return s.deployments; }); const deployments = (await Promise.all(deploymentPromises)).flat(); return deployments.map((deployment) => ({ - action: "deployment.target.removed", - payload: { target, deployment }, + action: "deployment.resource.removed", + payload: { resource, deployment }, })); }; diff --git a/packages/validators/src/events/hooks/index.ts b/packages/validators/src/events/hooks/index.ts index 0b815b07..9c0b0bbe 100644 --- a/packages/validators/src/events/hooks/index.ts +++ b/packages/validators/src/events/hooks/index.ts @@ -1,21 +1,17 @@ import { z } from "zod"; -import type { TargetDeleted, TargetRemoved } from "./target.js"; -import { targetDeleted, targetRemoved } from "./target.js"; +import type { ResourceRemoved } from "./target.js"; +import { resourceRemoved } from "./target.js"; export * from "./target.js"; -export const hookEvent = z.union([targetRemoved, targetDeleted]); +export const hookEvent = resourceRemoved; export type HookEvent = z.infer; // typeguards -export const isTargetRemoved = (event: HookEvent): event is TargetRemoved => - event.action === "deployment.target.removed"; -export const isTargetDeleted = (event: HookEvent): event is TargetDeleted => - event.action === "deployment.target.deleted"; +export const isResourceRemoved = (event: HookEvent): event is ResourceRemoved => + true; // action -export const hookActionsList = hookEvent.options.map( - (schema) => schema.shape.action.value, -); +export const hookActionsList = ["deployment.resource.removed"]; export const hookActions = z.enum(hookActionsList as [string, ...string[]]); diff --git a/packages/validators/src/events/hooks/target.ts b/packages/validators/src/events/hooks/target.ts index 1a2b3fa8..f49b234c 100644 --- a/packages/validators/src/events/hooks/target.ts +++ b/packages/validators/src/events/hooks/target.ts @@ -1,20 +1,14 @@ import { z } from "zod"; const deployment = z.object({ id: z.string().uuid(), name: z.string() }); -const target = z.object({ +const resource = z.object({ id: z.string().uuid(), name: z.string(), config: z.record(z.any()), }); -export const targetRemoved = z.object({ - action: z.literal("deployment.target.removed"), - payload: z.object({ deployment, target }), +export const resourceRemoved = z.object({ + action: z.literal("deployment.resource.removed"), + payload: z.object({ deployment, resource }), }); -export type TargetRemoved = z.infer; - -export const targetDeleted = z.object({ - action: z.literal("deployment.target.deleted"), - payload: z.object({ target }), -}); -export type TargetDeleted = z.infer; +export type ResourceRemoved = z.infer;