diff --git a/apps/event-worker/package.json b/apps/event-worker/package.json index 9be882694..2ca80c12c 100644 --- a/apps/event-worker/package.json +++ b/apps/event-worker/package.json @@ -20,6 +20,7 @@ "@ctrlplane/db": "workspace:*", "@ctrlplane/job-dispatch": "workspace:*", "@ctrlplane/logger": "workspace:*", + "@ctrlplane/release-manager": "workspace:*", "@ctrlplane/rule-engine": "workspace:*", "@ctrlplane/validators": "workspace:*", "@google-cloud/compute": "^4.9.0", diff --git a/apps/event-worker/src/releases/evaluate/index.ts b/apps/event-worker/src/releases/evaluate/index.ts index db42e5bb5..f62636c8b 100644 --- a/apps/event-worker/src/releases/evaluate/index.ts +++ b/apps/event-worker/src/releases/evaluate/index.ts @@ -1,9 +1,9 @@ +import type { Policy } from "@ctrlplane/rule-engine"; import type { ReleaseEvaluateEvent } from "@ctrlplane/validators/events"; import { Worker } from "bullmq"; -import _ from "lodash"; import { db } from "@ctrlplane/db/client"; -import { evaluate } from "@ctrlplane/rule-engine"; +import { evaluate, getReleases } from "@ctrlplane/rule-engine"; import { createCtx, getApplicablePolicies } from "@ctrlplane/rule-engine/db"; import { Channel } from "@ctrlplane/validators/events"; @@ -31,14 +31,15 @@ export const createReleaseEvaluateWorker = () => const { workspaceId } = ctx.resource; const policy = await getApplicablePolicies(db, workspaceId, job.data); + const getReleasesWithContext = (policy: Policy) => + getReleases(db, ctx, policy); - // TODO: Get the releases from the database. We will want to apply a - // prefix if one exists (a deployment version channel selector). For now - // just return releases from the latest deployed release to the current - // version. We need to account for upgrades and downgrades. - - const result = await evaluate(policy, [], ctx); + const result = await evaluate(policy, getReleasesWithContext, ctx); console.log(result); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error"; + job.log(`Error evaluating release: ${message}`); } finally { await mutex.unlock(); } diff --git a/packages/db/src/schema/policy-relations.ts b/packages/db/src/schema/policy-relations.ts index ad414cf5a..c910540ac 100644 --- a/packages/db/src/schema/policy-relations.ts +++ b/packages/db/src/schema/policy-relations.ts @@ -1,6 +1,11 @@ import { relations } from "drizzle-orm"; -import { policy, policyRuleDenyWindow, policyTarget } from "./policy.js"; +import { + policy, + policyDeploymentVersionSelector, + policyRuleDenyWindow, + policyTarget, +} from "./policy.js"; import { workspace } from "./workspace.js"; export const policyRelations = relations(policy, ({ many, one }) => ({ @@ -10,6 +15,10 @@ export const policyRelations = relations(policy, ({ many, one }) => ({ }), targets: many(policyTarget), denyWindows: many(policyRuleDenyWindow), + deploymentVersionSelector: one(policyDeploymentVersionSelector, { + fields: [policy.id], + references: [policyDeploymentVersionSelector.policyId], + }), })); export const policyTargetRelations = relations(policyTarget, ({ one }) => ({ diff --git a/packages/db/src/schema/policy.ts b/packages/db/src/schema/policy.ts index 782396e4f..546343a20 100644 --- a/packages/db/src/schema/policy.ts +++ b/packages/db/src/schema/policy.ts @@ -1,5 +1,6 @@ import type { DeploymentCondition } from "@ctrlplane/validators/deployments"; import type { EnvironmentCondition } from "@ctrlplane/validators/environments"; +import type { DeploymentVersionCondition } from "@ctrlplane/validators/releases"; import type { InferSelectModel } from "drizzle-orm"; import type { Options } from "rrule"; import { sql } from "drizzle-orm"; @@ -72,6 +73,27 @@ export const policyRuleDenyWindow = pgTable("policy_rule_deny_window", { .defaultNow(), }); +export const policyDeploymentVersionSelector = pgTable( + "policy_deployment_version_selector", + { + id: uuid("id").primaryKey().defaultRandom(), + + // can only have one deployment version selector per policy, you can do and + // ors in the deployment version selector. + policyId: uuid("policy_id") + .notNull() + .unique() + .references(() => policy.id, { onDelete: "cascade" }), + + name: text("name").notNull(), + description: text("description"), + + deploymentVersionSelector: jsonb("deployment_version_selector") + .notNull() + .$type(), + }, +); + // Create zod schemas from drizzle schemas const policyInsertSchema = createInsertSchema(policy, { name: z.string().min(1, "Policy name is required"), @@ -229,3 +251,6 @@ export type PolicyTarget = InferSelectModel; export type PolicyRuleDenyWindow = InferSelectModel< typeof policyRuleDenyWindow >; +export type PolicyDeploymentVersionSelector = InferSelectModel< + typeof policyDeploymentVersionSelector +>; diff --git a/packages/db/src/schema/resource.ts b/packages/db/src/schema/resource.ts index fadd242f3..138e9e4e5 100644 --- a/packages/db/src/schema/resource.ts +++ b/packages/db/src/schema/resource.ts @@ -119,6 +119,28 @@ export const resourceRelease = pgTable( }), ); +export const resourceReleaseRelations = relations( + resourceRelease, + ({ one }) => ({ + desiredRelease: one(release, { + fields: [resourceRelease.desiredReleaseId], + references: [release.id], + }), + deployment: one(deployment, { + fields: [resourceRelease.deploymentId], + references: [deployment.id], + }), + environment: one(environment, { + fields: [resourceRelease.environmentId], + references: [environment.id], + }), + resource: one(resource, { + fields: [resourceRelease.resourceId], + references: [resource.id], + }), + }), +); + export const resourceRelations = relations(resource, ({ one, many }) => ({ metadata: many(resourceMetadata), variables: many(resourceVariable), diff --git a/packages/release-manager/package.json b/packages/release-manager/package.json index d22b0e6d2..8aaa83058 100644 --- a/packages/release-manager/package.json +++ b/packages/release-manager/package.json @@ -29,6 +29,7 @@ "@ctrlplane/eslint-config": "workspace:*", "@ctrlplane/prettier-config": "workspace:*", "@ctrlplane/tsconfig": "workspace:*", + "@types/lodash": "catalog:", "@types/node": "catalog:node22", "eslint": "catalog:", "prettier": "catalog:", diff --git a/packages/release-manager/src/variables/types.ts b/packages/release-manager/src/variables/types.ts index 71487618a..a401bd81b 100644 --- a/packages/release-manager/src/variables/types.ts +++ b/packages/release-manager/src/variables/types.ts @@ -1,4 +1,4 @@ -import type { ReleaseIdentifier } from "src/types"; +import type { ReleaseIdentifier } from "../types.js"; export type Variable = { id: string; diff --git a/packages/rule-engine/package.json b/packages/rule-engine/package.json index aad5aac77..f76546992 100644 --- a/packages/rule-engine/package.json +++ b/packages/rule-engine/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@ctrlplane/db": "workspace:*", + "@ctrlplane/logger": "workspace:*", "@ctrlplane/validators": "workspace:*", "@date-fns/tz": "^1.2.0", "date-fns": "^4.1.0", @@ -37,6 +38,7 @@ "@ctrlplane/eslint-config": "workspace:*", "@ctrlplane/prettier-config": "workspace:*", "@ctrlplane/tsconfig": "workspace:*", + "@types/lodash": "catalog:", "@types/node": "catalog:node22", "eslint": "catalog:", "prettier": "catalog:", diff --git a/packages/rule-engine/src/db/create-ctx.ts b/packages/rule-engine/src/db/create-ctx.ts index 0c12aa9c6..8383a0f27 100644 --- a/packages/rule-engine/src/db/create-ctx.ts +++ b/packages/rule-engine/src/db/create-ctx.ts @@ -1,6 +1,6 @@ import type { Tx } from "@ctrlplane/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq } from "@ctrlplane/db"; import { resourceRelease } from "@ctrlplane/db/schema"; import type { ReleaseRepository } from "../types.js"; diff --git a/packages/rule-engine/src/db/get-applicable-policies.ts b/packages/rule-engine/src/db/get-applicable-policies.ts index 1c27da039..da44799db 100644 --- a/packages/rule-engine/src/db/get-applicable-policies.ts +++ b/packages/rule-engine/src/db/get-applicable-policies.ts @@ -112,7 +112,7 @@ export const getApplicablePolicies = async ( ) => { const policy = await tx.query.policy.findMany({ where: eq(schema.policy.workspaceId, workspaceId), - with: { targets: true, denyWindows: true }, + with: { targets: true, denyWindows: true, deploymentVersionSelector: true }, orderBy: [desc(schema.policy.priority)], }); diff --git a/packages/rule-engine/src/evaluate.ts b/packages/rule-engine/src/evaluate.ts index cb4b97d50..04962767e 100644 --- a/packages/rule-engine/src/evaluate.ts +++ b/packages/rule-engine/src/evaluate.ts @@ -22,22 +22,31 @@ const denyWindows = (policy: Policy | null) => * deployment is allowed. * * @param policy - The policy containing deployment rules and deny windows - * @param releases - One or more releases to evaluate + * @param getReleases - A function that returns a list of releases for a given + * policy * @param context - The deployment context containing information needed for * rule evaluation * @returns A promise resolving to the evaluation result, including allowed * status and chosen release */ -export const evaluate = ( +export const evaluate = async ( policy: Policy | Policy[] | null, - releases: Release[] | Release, + getReleases: (policy: Policy) => Promise | Release[], context: DeploymentResourceContext, ) => { const policies = policy == null ? [] : Array.isArray(policy) ? policy : [policy]; + const mergedPolicy = mergePolicies(policies); + if (mergedPolicy == null) + return { + allowed: false, + release: undefined, + }; + const rules = [...denyWindows(mergedPolicy)]; const engine = new RuleEngine(rules); + const releases = await getReleases(mergedPolicy); const releaseCollection = Releases.from(releases); return engine.evaluate(releaseCollection, context); }; diff --git a/packages/rule-engine/src/index.ts b/packages/rule-engine/src/index.ts index f52aa64f5..f3b5c5e98 100644 --- a/packages/rule-engine/src/index.ts +++ b/packages/rule-engine/src/index.ts @@ -4,3 +4,4 @@ export * from "./rule-engine.js"; export * from "./rules/index.js"; export * from "./evaluate.js"; export * from "./utils/merge-policies.js"; +export * from "./utils/get-releases.js"; diff --git a/packages/rule-engine/src/types.ts b/packages/rule-engine/src/types.ts index 48bc7267c..0fe8a5625 100644 --- a/packages/rule-engine/src/types.ts +++ b/packages/rule-engine/src/types.ts @@ -65,6 +65,7 @@ export interface DeploymentResourceRule { export type Policy = schema.Policy & { denyWindows: schema.PolicyRuleDenyWindow[]; + deploymentVersionSelector: schema.PolicyDeploymentVersionSelector | null; }; export type ReleaseRepository = { diff --git a/packages/rule-engine/src/utils/get-releases.ts b/packages/rule-engine/src/utils/get-releases.ts new file mode 100644 index 000000000..047f471b3 --- /dev/null +++ b/packages/rule-engine/src/utils/get-releases.ts @@ -0,0 +1,118 @@ +import type { Tx } from "@ctrlplane/db"; +import { isAfter } from "date-fns"; + +import { and, desc, eq, exists, gte, lte } from "@ctrlplane/db"; +import * as SCHEMA from "@ctrlplane/db/schema"; +import { logger } from "@ctrlplane/logger"; +import { JobStatus } from "@ctrlplane/validators/jobs"; + +import type { DeploymentResourceContext } from ".."; + +type Policy = SCHEMA.Policy & { + denyWindows: SCHEMA.PolicyRuleDenyWindow[]; + deploymentVersionSelector: SCHEMA.PolicyDeploymentVersionSelector | null; +}; + +const log = logger.child({ + module: "rule-engine", + function: "getReleases", +}); + +const getIsDateBoundsValid = ( + latestDeployedReleaseDate?: Date, + desiredReleaseCreatedAt?: Date, +) => { + if (latestDeployedReleaseDate == null) return true; + if (desiredReleaseCreatedAt == null) return true; + return !isAfter(latestDeployedReleaseDate, desiredReleaseCreatedAt); +}; + +export const getReleases = async ( + db: Tx, + ctx: DeploymentResourceContext, + policy: Policy, +) => { + const latestDeployedRelease = await db.query.release.findFirst({ + where: and( + eq(SCHEMA.release.deploymentId, ctx.deployment.id), + eq(SCHEMA.release.resourceId, ctx.resource.id), + eq(SCHEMA.release.environmentId, ctx.environment.id), + exists( + db + .select() + .from(SCHEMA.releaseJob) + .innerJoin(SCHEMA.job, eq(SCHEMA.releaseJob.jobId, SCHEMA.job.id)) + .where( + and( + eq(SCHEMA.releaseJob.releaseId, SCHEMA.release.id), + eq(SCHEMA.job.status, JobStatus.Successful), + ), + ) + .limit(1), + ), + ), + orderBy: desc(SCHEMA.release.createdAt), + }); + + const resourceRelease = await db.query.resourceRelease.findFirst({ + where: and( + eq(SCHEMA.resourceRelease.resourceId, ctx.resource.id), + eq(SCHEMA.resourceRelease.environmentId, ctx.environment.id), + eq(SCHEMA.resourceRelease.deploymentId, ctx.deployment.id), + ), + with: { desiredRelease: true }, + }); + + const isDateBoundsValid = getIsDateBoundsValid( + latestDeployedRelease?.createdAt, + resourceRelease?.desiredRelease?.createdAt, + ); + + if (!isDateBoundsValid) + log.warn( + `Date bounds are invalid, latestDeployedRelease is after desiredRelease: + latestDeployedRelease: ${latestDeployedRelease?.createdAt != null ? latestDeployedRelease.createdAt.toISOString() : "null"}, + resourceRelease: ${resourceRelease?.desiredRelease?.createdAt != null ? resourceRelease.desiredRelease.createdAt.toISOString() : "null"}`, + ); + + return db.query.release + .findMany({ + where: and( + eq(SCHEMA.release.deploymentId, ctx.deployment.id), + eq(SCHEMA.release.resourceId, ctx.resource.id), + eq(SCHEMA.release.environmentId, ctx.environment.id), + SCHEMA.deploymentVersionMatchesCondition( + db, + policy.deploymentVersionSelector?.deploymentVersionSelector, + ), + latestDeployedRelease != null + ? gte(SCHEMA.release.createdAt, latestDeployedRelease.createdAt) + : undefined, + resourceRelease?.desiredRelease != null + ? lte( + SCHEMA.release.createdAt, + resourceRelease.desiredRelease.createdAt, + ) + : undefined, + ), + with: { + version: { with: { metadata: true } }, + variables: true, + }, + orderBy: desc(SCHEMA.release.createdAt), + }) + .then((releases) => + releases.map((release) => ({ + ...release, + variables: Object.fromEntries( + release.variables.map((v) => [v.key, v.value]), + ), + version: { + ...release.version, + metadata: Object.fromEntries( + release.version.metadata.map((m) => [m.key, m.value]), + ), + }, + })), + ); +}; diff --git a/packages/rule-engine/src/utils/merge-policies.ts b/packages/rule-engine/src/utils/merge-policies.ts index aa1225a1b..b4ca5cf0d 100644 --- a/packages/rule-engine/src/utils/merge-policies.ts +++ b/packages/rule-engine/src/utils/merge-policies.ts @@ -1,11 +1,37 @@ +import type { DeploymentVersionCondition } from "@ctrlplane/validators/releases"; import _ from "lodash"; +import { isPresent } from "ts-is-present"; + +import { + ComparisonOperator, + ConditionType, +} from "@ctrlplane/validators/conditions"; import type { Policy } from "../types.js"; +const mergeVersionSelectors = ( + policies: Policy[], +): DeploymentVersionCondition | null => { + const versionSelectors = policies + .map((p) => p.deploymentVersionSelector?.deploymentVersionSelector) + .filter(isPresent); + + if (versionSelectors.length === 0) return null; + if (versionSelectors.length === 1) return versionSelectors[0]!; + + return { + type: ConditionType.Comparison, + operator: ComparisonOperator.And, + conditions: versionSelectors, + }; +}; + export const mergePolicies = (policies: Policy[]): Policy | null => { if (policies.length === 0) return null; if (policies.length === 1) return policies[0]!; - return _.mergeWith( + + const mergedVersionSelector = mergeVersionSelectors(policies); + const merged = _.mergeWith( policies[0], ...policies.slice(1), (objValue: any, sourceValue: any) => { @@ -13,4 +39,9 @@ export const mergePolicies = (policies: Policy[]): Policy | null => { return objValue.concat(sourceValue); }, ); + + return { + ...merged, + deploymentVersionSelector: mergedVersionSelector, + }; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9241a0e4f..8f89727ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,6 +148,9 @@ importers: '@ctrlplane/logger': specifier: workspace:* version: link:../../packages/logger + '@ctrlplane/release-manager': + specifier: workspace:* + version: link:../../packages/release-manager '@ctrlplane/rule-engine': specifier: workspace:* version: link:../../packages/rule-engine @@ -1257,6 +1260,9 @@ importers: '@ctrlplane/tsconfig': specifier: workspace:* version: link:../../tooling/typescript + '@types/lodash': + specifier: 'catalog:' + version: 4.17.12 '@types/node': specifier: catalog:node22 version: 22.13.10 @@ -1278,6 +1284,9 @@ importers: '@ctrlplane/db': specifier: workspace:* version: link:../db + '@ctrlplane/logger': + specifier: workspace:* + version: link:../logger '@ctrlplane/validators': specifier: workspace:* version: link:../validators @@ -1309,6 +1318,9 @@ importers: '@ctrlplane/tsconfig': specifier: workspace:* version: link:../../tooling/typescript + '@types/lodash': + specifier: 'catalog:' + version: 4.17.12 '@types/node': specifier: catalog:node22 version: 22.13.10