Skip to content
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
1 change: 1 addition & 0 deletions apps/event-worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 9 additions & 8 deletions apps/event-worker/src/releases/evaluate/index.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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();
}
Expand Down
11 changes: 10 additions & 1 deletion packages/db/src/schema/policy-relations.ts
Original file line number Diff line number Diff line change
@@ -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 }) => ({
Expand All @@ -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 }) => ({
Expand Down
25 changes: 25 additions & 0 deletions packages/db/src/schema/policy.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<DeploymentVersionCondition>(),
},
);

// Create zod schemas from drizzle schemas
const policyInsertSchema = createInsertSchema(policy, {
name: z.string().min(1, "Policy name is required"),
Expand Down Expand Up @@ -229,3 +251,6 @@ export type PolicyTarget = InferSelectModel<typeof policyTarget>;
export type PolicyRuleDenyWindow = InferSelectModel<
typeof policyRuleDenyWindow
>;
export type PolicyDeploymentVersionSelector = InferSelectModel<
typeof policyDeploymentVersionSelector
>;
22 changes: 22 additions & 0 deletions packages/db/src/schema/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions packages/release-manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
2 changes: 1 addition & 1 deletion packages/release-manager/src/variables/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ReleaseIdentifier } from "src/types";
import type { ReleaseIdentifier } from "../types.js";

export type Variable<T = any> = {
id: string;
Expand Down
2 changes: 2 additions & 0 deletions packages/rule-engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:",
Expand Down
2 changes: 1 addition & 1 deletion packages/rule-engine/src/db/create-ctx.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
2 changes: 1 addition & 1 deletion packages/rule-engine/src/db/get-applicable-policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)],
});

Expand Down
15 changes: 12 additions & 3 deletions packages/rule-engine/src/evaluate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]> | 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);
};
1 change: 1 addition & 0 deletions packages/rule-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
1 change: 1 addition & 0 deletions packages/rule-engine/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export interface DeploymentResourceRule {

export type Policy = schema.Policy & {
denyWindows: schema.PolicyRuleDenyWindow[];
deploymentVersionSelector: schema.PolicyDeploymentVersionSelector | null;
};

export type ReleaseRepository = {
Expand Down
118 changes: 118 additions & 0 deletions packages/rule-engine/src/utils/get-releases.ts
Original file line number Diff line number Diff line change
@@ -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]),
),
},
})),
);
};
33 changes: 32 additions & 1 deletion packages/rule-engine/src/utils/merge-policies.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,47 @@
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) => {
if (Array.isArray(objValue) && Array.isArray(sourceValue))
return objValue.concat(sourceValue);
},
);

return {
...merged,
deploymentVersionSelector: mergedVersionSelector,
};
};
Loading
Loading