diff --git a/apps/event-worker/package.json b/apps/event-worker/package.json index 641291d9d..9be882694 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/rule-engine": "workspace:*", "@ctrlplane/validators": "workspace:*", "@google-cloud/compute": "^4.9.0", "@google-cloud/container": "^5.16.0", @@ -36,6 +37,7 @@ "js-yaml": "^4.1.0", "lodash": "catalog:", "ms": "^2.1.3", + "redis-semaphore": "^5.6.2", "semver": "catalog:", "ts-is-present": "^1.2.2", "uuid": "^10.0.0", diff --git a/apps/event-worker/src/job-sync/index.ts b/apps/event-worker/src/job-sync/index.ts deleted file mode 100644 index 4f3b94b11..000000000 --- a/apps/event-worker/src/job-sync/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -// import type { Job } from "@ctrlplane/db/schema"; -// import type { DispatchJobEvent } from "@ctrlplane/validators/events"; -// import type { Job as JobMq } from "bullmq"; -// import { Queue, Worker } from "bullmq"; - -// import { eq, takeFirstOrNull } from "@ctrlplane/db"; -// import { db } from "@ctrlplane/db/client"; -// import * as schema from "@ctrlplane/db/schema"; -// import { onJobCompletion } from "@ctrlplane/job-dispatch"; -// import { Channel } from "@ctrlplane/validators/events"; -// import { JobAgentType, JobStatus } from "@ctrlplane/validators/jobs"; - -// import { redis } from "../redis.js"; -// import { syncGithubJob } from "./github.js"; - -// const jobSyncQueue = new Queue(Channel.JobSync, { -// connection: redis, -// }); -// const removeJobSyncJob = (job: JobMq) => -// job.repeatJobKey != null -// ? jobSyncQueue.removeRepeatableByKey(job.repeatJobKey) -// : null; - -// type SyncFunction = (je: Job) => Promise; - -// const getSyncFunction = (agentType: string): SyncFunction | null => { -// if (agentType === String(JobAgentType.GithubApp)) return syncGithubJob; -// return null; -// }; - -// export const createjobSyncWorker = () => -// new Worker( -// Channel.JobSync, -// (job) => -// db -// .select() -// .from(schema.job) -// .innerJoin( -// schema.jobAgent, -// eq(schema.job.jobAgentId, schema.jobAgent.id), -// ) -// .where(eq(schema.job.id, job.data.jobId)) -// .then(takeFirstOrNull) -// .then((je) => { -// if (je == null) return; - -// const syncFunction = getSyncFunction(je.job_agent.type); -// if (syncFunction == null) return; - -// try { -// syncFunction(je.job).then(async (isCompleted) => { -// if (!isCompleted) return; -// removeJobSyncJob(job); -// await onJobCompletion(je.job); -// }); -// } catch (error) { -// db.update(schema.job).set({ -// status: JobStatus.Failure, -// message: (error as Error).message, -// }); -// } -// }), -// { -// connection: redis, -// removeOnComplete: { age: 0, count: 0 }, -// removeOnFail: { age: 0, count: 0 }, -// concurrency: 10, -// }, -// ); diff --git a/apps/event-worker/src/rule-engine-evaluation/index.ts b/apps/event-worker/src/rule-engine-evaluation/index.ts new file mode 100644 index 000000000..2692bc19e --- /dev/null +++ b/apps/event-worker/src/rule-engine-evaluation/index.ts @@ -0,0 +1,150 @@ +import type { + DeploymentResourceContext, + Release, +} from "@ctrlplane/rule-engine"; +import type { RuleEngineEvaluationEvent } from "@ctrlplane/validators/events"; +import { Worker } from "bullmq"; +import { Mutex } from "redis-semaphore"; + +import { and, desc, eq, sql, takeFirstOrNull } from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import * as schema from "@ctrlplane/db/schema"; +import { + Releases, + RuleEngine, + VersionCooldownRule, +} from "@ctrlplane/rule-engine"; +import { Channel } from "@ctrlplane/validators/events"; + +import { redis } from "../redis.js"; + +const createDeploymentResourceContext = ({ + resourceId, + deploymentId, + environmentId, +}: RuleEngineEvaluationEvent) => { + return db + .select({ + desiredReleaseId: schema.resourceDesiredRelease.desiredReleaseId, + deployment: schema.deployment, + environment: schema.environment, + resource: schema.resource, + }) + .from(schema.resourceDesiredRelease) + .innerJoin( + schema.deployment, + eq(schema.resourceDesiredRelease.deploymentId, schema.deployment.id), + ) + .innerJoin( + schema.environment, + eq(schema.resourceDesiredRelease.environmentId, schema.environment.id), + ) + .innerJoin( + schema.resource, + eq(schema.resourceDesiredRelease.resourceId, schema.resource.id), + ) + .where( + and( + eq(schema.resourceDesiredRelease.resourceId, resourceId), + eq(schema.resourceDesiredRelease.environmentId, environmentId), + eq(schema.resourceDesiredRelease.deploymentId, deploymentId), + ), + ) + .then(takeFirstOrNull); +}; + +const getReleaseCandidates = async ( + ctx: DeploymentResourceContext, +): Promise => { + return db + .select({ + id: schema.release.id, + createdAt: schema.release.createdAt, + version: schema.deploymentVersion, + variables: sql>`COALESCE(jsonb_object_agg( + ${schema.releaseVariable.key}, + ${schema.releaseVariable.value} + ) FILTER (WHERE ${schema.releaseVariable.key} IS NOT NULL), '{}'::jsonb)`.as( + "variables", + ), + }) + .from(schema.release) + .where( + and( + eq(schema.release.id, ctx.resource.id), + eq(schema.release.environmentId, ctx.environment.id), + eq(schema.release.deploymentId, ctx.deployment.id), + ), + ) + .innerJoin( + schema.deploymentVersion, + eq(schema.release.versionId, schema.deploymentVersion.id), + ) + .leftJoin( + schema.releaseVariable, + and( + eq(schema.release.id, schema.releaseVariable.releaseId), + eq(schema.releaseVariable.sensitive, false), + ), + ) + .groupBy( + schema.release.id, + schema.release.createdAt, + schema.deploymentVersion.id, + ) + .orderBy(desc(schema.release.createdAt)) + .limit(100) + .then((releases) => + releases.map((r) => ({ + ...r, + version: { + ...r.version, + metadata: {} as Record, + }, + })), + ); +}; + +const versionCooldownRule = () => + new VersionCooldownRule({ + cooldownMinutes: 1440, + getLastSuccessfulDeploymentTime: () => new Date(), + }); + +export const createRuleEngineEvaluationWorker = () => + new Worker( + Channel.RuleEngineEvaluation, + async (job) => { + const { resourceId, deploymentId, environmentId } = job.data; + + const key = `rule-engine-evaluation:${resourceId}-${deploymentId}-${environmentId}`; + const mutex = new Mutex(redis, key); + + await mutex.acquire(); + try { + const ctx = await createDeploymentResourceContext(job.data); + if (ctx == null) + throw new Error( + "Resource desired release not found. Could not build context.", + ); + + const allReleaseCandidates = await getReleaseCandidates(ctx); + + const releases = Releases.from(allReleaseCandidates); + if (releases.isEmpty()) return; + + const ruleEngine = new RuleEngine([versionCooldownRule()]); + const result = await ruleEngine.evaluate(releases, ctx); + + console.log(result); + } finally { + await mutex.release(); + } + }, + { + connection: redis, + removeOnComplete: { age: 1 * 60 * 60, count: 5000 }, + removeOnFail: { age: 12 * 60 * 60, count: 5000 }, + concurrency: 10, + }, + ); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 95942b05a..5db4651f3 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -22,6 +22,8 @@ export * from "./job-agent.js"; export * from "./event.js"; export * from "./release-channel.js"; export * from "./release-job-trigger.js"; +export * from "./release.js"; +export * from "./rule.js"; // relations export * from "./environment-relations.js"; diff --git a/packages/db/src/schema/release.ts b/packages/db/src/schema/release.ts new file mode 100644 index 000000000..bbac57ce1 --- /dev/null +++ b/packages/db/src/schema/release.ts @@ -0,0 +1,63 @@ +import { + boolean, + json, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; + +import { deploymentVersion } from "./deployment-version.js"; +import { deployment } from "./deployment.js"; +import { environment } from "./environment.js"; +import { job } from "./job.js"; +import { resource } from "./resource.js"; + +export const release = pgTable("release", { + id: uuid("id").primaryKey().defaultRandom(), + + versionId: uuid("version_id") + .notNull() + .references(() => deploymentVersion.id, { onDelete: "cascade" }), + resourceId: uuid("resource_id") + .notNull() + .references(() => resource.id, { onDelete: "cascade" }), + deploymentId: uuid("deployment_id") + .notNull() + .references(() => deployment.id, { onDelete: "cascade" }), + environmentId: uuid("environment_id") + .references(() => environment.id, { onDelete: "cascade" }) + .notNull(), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); + +export const releaseVariable = pgTable( + "release_variable", + { + id: uuid("id").primaryKey().defaultRandom(), + releaseId: uuid("release_id") + .notNull() + .references(() => release.id, { onDelete: "cascade" }), + key: text("key").notNull(), + value: json("value").notNull(), + sensitive: boolean("sensitive").notNull().default(false), + }, + (t) => ({ uniq: uniqueIndex().on(t.releaseId, t.key) }), +); + +export const releaseJob = pgTable("release_job", { + id: uuid("id").primaryKey().defaultRandom(), + releaseId: uuid("release_id") + .notNull() + .references(() => release.id, { onDelete: "cascade" }), + jobId: uuid("job_id") + .notNull() + .references(() => job.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), +}); diff --git a/packages/db/src/schema/resource.ts b/packages/db/src/schema/resource.ts index c404cc87b..1ca3fa23c 100644 --- a/packages/db/src/schema/resource.ts +++ b/packages/db/src/schema/resource.ts @@ -50,8 +50,11 @@ import { } from "@ctrlplane/validators/resources"; import type { Tx } from "../common.js"; +import { deployment } from "./deployment.js"; +import { environment } from "./environment.js"; import { job } from "./job.js"; import { releaseJobTrigger } from "./release-job-trigger.js"; +import { release } from "./release.js"; import { resourceProvider } from "./resource-provider.js"; import { workspace } from "./workspace.js"; @@ -405,3 +408,27 @@ export const createResourceVariable = createInsertSchema(resourceVariable, { export const updateResourceVariable = createResourceVariable.partial(); export type ResourceVariable = InferSelectModel; + +export const resourceDesiredRelease = pgTable( + "resource_desired_release", + { + id: uuid("id").primaryKey().defaultRandom(), + + resourceId: uuid("resource_id") + .references(() => resource.id, { onDelete: "cascade" }) + .notNull(), + environmentId: uuid("environment_id") + .references(() => environment.id, { onDelete: "cascade" }) + .notNull(), + deploymentId: uuid("deployment_id") + .references(() => deployment.id, { onDelete: "cascade" }) + .notNull(), + + desiredReleaseId: uuid("desired_release_id") + .references(() => release.id, { onDelete: "set null" }) + .default(sql`NULL`), + }, + (t) => ({ + uniq: uniqueIndex().on(t.resourceId, t.environmentId, t.deploymentId), + }), +); diff --git a/packages/db/src/schema/rule.ts b/packages/db/src/schema/rule.ts new file mode 100644 index 000000000..3e6295fd8 --- /dev/null +++ b/packages/db/src/schema/rule.ts @@ -0,0 +1,161 @@ +import { relations, sql } from "drizzle-orm"; +import { + boolean, + integer, + json, + pgEnum, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +import { workspace } from "./workspace.js"; + +export const rule = pgTable("rule", { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull(), + description: text("description"), + + priority: integer("priority").notNull().default(0), + + workspaceId: uuid("workspace_id") + .notNull() + .references(() => workspace.id, { onDelete: "cascade" }), + + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).$onUpdate( + () => new Date(), + ), +}); + +export const ruleTarget = pgTable("rule_target", { + id: uuid("id").primaryKey().defaultRandom(), + ruleId: uuid("rule_id") + .notNull() + .references(() => rule.id, { onDelete: "cascade" }), + deploymentSelector: json("deployment_selector"), + environmentSelector: json("environment_selector"), +}); + +export const ruleRollout = pgTable("rule_rollout", { + id: uuid("id").primaryKey().defaultRandom(), + ruleId: uuid("rule_id") + .notNull() + .references(() => rule.id, { onDelete: "cascade" }), + timeWindowMinutes: integer("time_window_minutes").notNull().default(0), + maxDeploymentsPerTimeWindow: integer("max_deployments_per_time_window") + .notNull() + .default(0), +}); + +export const ruleApproval = pgTable("rule_approval", { + id: uuid("id").primaryKey().defaultRandom(), + ruleId: uuid("rule_id") + .notNull() + .references(() => rule.id, { onDelete: "cascade" }), + approvalType: text("approval_type").notNull().default("anyone"), // 'anyone' or 'team' + teamId: uuid("team_id").default(sql`NULL`), // For future team-specific approvals + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).$onUpdate( + () => new Date(), + ), +}); + +export const ruleMaintenanceWindow = pgTable("rule_maintenance_window", { + id: uuid("id").primaryKey().defaultRandom(), + ruleId: uuid("rule_id") + .notNull() + .references(() => rule.id, { onDelete: "cascade" }), + name: text("name").notNull(), + start: timestamp("start", { withTimezone: true }).notNull(), + end: timestamp("end", { withTimezone: true }).notNull(), +}); + +export const ruleResourceConcurrency = pgTable("rule_resource_concurrency", { + id: uuid("id").primaryKey().defaultRandom(), + ruleId: uuid("rule_id") + .notNull() + .references(() => rule.id, { onDelete: "cascade" }), + // resourceSelector: json("resource_selector").default(sql`NULL`), + concurrencyLimit: integer("concurrency_limit").notNull().default(0), +}); + +export const rulePreviousDeployStatus = pgTable("rule_previous_deploy_status", { + id: uuid("id").primaryKey().defaultRandom(), + ruleId: uuid("rule_id") + .notNull() + .references(() => rule.id, { onDelete: "cascade" }), + minSuccessfulDeployments: integer("min_successful_deployments") + .notNull() + .default(0), + requireAllResources: boolean("require_all_resources") + .notNull() + .default(false), + environmentSelector: json("environment_selector").notNull(), +}); + +export const ruleVersionMetadataValidation = pgTable( + "rule_version_metadata_validation", + { + id: uuid("id").primaryKey().defaultRandom(), + ruleId: uuid("rule_id") + .notNull() + .references(() => rule.id, { onDelete: "cascade" }), + metadataKey: text("metadata_key").notNull(), + requiredValue: text("required_value").notNull(), + allowMissingMetadata: boolean("allow_missing_metadata") + .notNull() + .default(false), + customErrorMessage: text("custom_error_message"), + }, +); + +export const ruleTimeWindowDays = pgEnum("rule_time_window_days", [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", +]); + +export const ruleTimeWindow = pgTable("rule_time_window", { + id: uuid("id").primaryKey().defaultRandom(), + ruleId: uuid("rule_id") + .notNull() + .references(() => rule.id, { onDelete: "cascade" }), + startHour: integer("start_hour").notNull(), + endHour: integer("end_hour").notNull(), + days: ruleTimeWindowDays("days").array().notNull(), + timezone: text("timezone").notNull(), +}); + +export const ruleRelationships = relations(rule, ({ many, one }) => ({ + targets: many(ruleTarget), + rollouts: many(ruleRollout), + approvals: many(ruleApproval), + maintenanceWindows: many(ruleMaintenanceWindow), + resourceConcurrency: one(ruleResourceConcurrency), + previousDeployStatus: many(rulePreviousDeployStatus), + timeWindows: many(ruleTimeWindow), + versionMetadataValidation: many(ruleVersionMetadataValidation), +})); + +export type Rule = typeof rule.$inferSelect; +export type RuleTarget = typeof ruleTarget.$inferSelect; +export type RuleRollout = typeof ruleRollout.$inferSelect; +export type RuleApproval = typeof ruleApproval.$inferSelect; +export type RuleMaintenanceWindow = typeof ruleMaintenanceWindow.$inferSelect; +export type RuleResourceConcurrency = + typeof ruleResourceConcurrency.$inferSelect; +export type RulePreviousDeployStatus = + typeof rulePreviousDeployStatus.$inferSelect; +export type RuleVersionMetadataValidation = + typeof ruleVersionMetadataValidation.$inferSelect; +export type RuleTimeWindow = typeof ruleTimeWindow.$inferSelect; diff --git a/packages/rule-engine/README.md b/packages/rule-engine/README.md new file mode 100644 index 000000000..32e1e7612 --- /dev/null +++ b/packages/rule-engine/README.md @@ -0,0 +1,281 @@ +# Rule Engine + +The Ctrlplane Rule Engine is a flexible system for evaluating and selecting +appropriate releases based on configurable criteria. + +## Core Concepts + +### Rule Engine Architecture + +The Rule Engine operates on a simple yet powerful principle: a series of rules +are applied in sequence to filter available releases, with each rule narrowing +down the options until a final release is selected based on priority rules. + +``` + ┌─────────────────┐ + │ All Available │ + │ Releases │ + └────────┬────────┘ + │ + ▼ +┌─────────────────────────────┐ +│ Rules Pipeline │ +│ ┌─────────────────────┐ │ +│ │ Rule 1 │ │ +│ └─────────┬───────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────┐ │ +│ │ Rule 2 │ │ +│ └─────────┬───────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────┐ │ +│ │ Rule N │ │ +│ └─────────┬───────────┘ │ +└────────────┼────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ Release Selection│ + │ Logic │ + └────────┬────────┘ + │ + ▼ + ┌─────────────────┐ + │ Final Selected │ + │ Release │ + └─────────────────┘ +``` + +### Key Components + +1. **DeploymentResourceContext**: Contains all information for rule evaluation + + - Desired release ID + - Deployment details + - Environment information + - Resource being deployed + - All available releases + +2. **Release**: Represents a deployable version + + - Unique ID + - Creation timestamp + - Version information (tag, config, metadata) + - Variables for deployment + +3. **DeploymentResourceRule**: Interface for all rules + + - `name`: Identifying the rule for logging/debugging + - `filter()`: Method that filters candidate releases + +4. **Releases**: Utility class for working with collections of releases + + - Immutable operations (each returns a new instance) + - Release selection helpers (getOldest, getNewest, etc.) + - Filtering and sorting operations + +5. **RuleEngine**: Coordinates the evaluation process + - Applies rules sequentially to filter releases + - Selects the final release based on priority rules + - Reports success or failure with detailed reasons + +## Design Principles + +### Rule Implementation Guidelines + +Rules should follow these principles: + +1. **Return All Valid Candidates**: Rules should return ALL releases that + satisfy their criteria, not just one. This ensures downstream rules have + complete information for their filtering logic. + +2. **Immutability**: Rules should not modify the input releases collection. + Instead, they should use the Releases utility class to create new filtered + collections. + +3. **Early Returns**: Rules should check edge cases first (empty collections, + non-applicable scenarios) to simplify the core logic. + +4. **Explicit Reasoning**: When filtering out releases, rules should provide + clear reasons why, enhancing debugging and user communication. + +### Release Selection Priority + +The rule engine uses the following priority order when selecting the final +release: + +1. If sequential upgrade releases are present, select the oldest one +2. If a desired release ID is specified and that release is in the candidate + list, select it +3. Otherwise, select the newest release (by creation timestamp) + +This ensures that critical sequential upgrades are applied in the correct order, +while respecting user preferences when possible. + +## Key Assumptions + +The rule engine makes several important assumptions: + +1. **Sequential vs. Explicit Order**: Some releases may be flagged as requiring + sequential application, which overrides desired release selection. + +2. **Metadata-Driven Behavior**: Release behavior is often controlled through + metadata (e.g., "requiresSequentialUpgrade": "true"). + +3. **Rule Independence**: Each rule should operate independently without relying + on the outcome of other rules. + +4. **Creation Time Order**: Sequential releases are ordered by their creation + timestamps, with the assumption that older sequential releases must be + applied before newer ones. + +5. **Complete Input Information**: The deployment context contains all necessary + information for rules to make correct decisions. + +6. **Rules Don't Jump Ahead**: Each rule evaluates the candidates filtered by + previous rules; they can't access the original complete set. + +7. **Rules Return All Valid Options**: Unlike traditional filters that might + return just one "best" option, rules should return all valid candidates for + downstream rules. + +## Common Rules + +The engine includes several common rule implementations: + +- **SequentialUpgradeRule**: Enforces that critical releases with specific + migrations or changes are applied in sequence. +- **TimeWindowRule**: Restricts deployments to specified time windows (e.g., + business hours only). +- **ApprovalRequiredRule**: Requires approvals for deployments to certain + environments. +- **VersionCooldownRule**: Enforces a waiting period after release creation + before deployment. +- **MaintenanceWindowRule**: Blocks deployments during defined maintenance + periods. +- **ResourceConcurrencyRule**: Limits concurrent deployments of a resource. +- **PreviousDeployStatusRule**: Considers previous deployment status before + allowing a new one. +- **GradualVersionRolloutRule**: Implements progressive deployment of new + versions. + +## Limitations and Constraints + +The Rule Engine, while powerful, has several important limitations to be aware of: + +1. **Rule Ordering Sensitivity**: The order in which rules are defined matters + significantly. Rules are applied sequentially, and each rule only sees the + candidates that passed previous rules. Plan rule ordering carefully to avoid + unintended filtering behavior. + +2. **No Rule Communication**: Rules cannot directly communicate with each other + or share state. If rules need to make decisions based on the same + information, that information must be provided in the context or encoded in + release metadata. + +3. **Immediate Rejection on Empty Results**: If any rule filters out all + candidates, the evaluation stops immediately. This means later rules won't + have a chance to run, which could impact observability and debugging. + +4. **Creation Timestamp Dependency**: Sequential upgrade ordering relies heavily + on creation timestamps. If timestamps are incorrect or manipulated, + sequential upgrade logic may not work correctly. + +5. **Limited Rule Flexibility**: Once the rule pipeline begins execution, the + set of rules and their configuration cannot be changed dynamically based on + evaluation results. + +6. **All-or-Nothing Filtering**: Rules either accept or reject a release; + there's no concept of partial acceptance or scoring/ranking releases. + +7. **No Built-in Backpressure**: The engine has no built-in mechanism to slow + down or rate-limit deployments based on system health or metrics. + +8. **Context Completeness**: The rule engine assumes all necessary information + is available in the context object. External information cannot be queried + during rule execution without custom implementation. + +9. **Limited Explanations**: While rules can provide reasons for rejection, the + reason field is a simple string that may not capture the full complexity of + the decision. + +10. **Assumption of Stable Data**: The engine assumes that release information + remains stable during the evaluation process. If release data changes while + the engine is evaluating (e.g., metadata is updated, approvals are added), + unexpected behavior may result. For this reason, evaluation is typically + wrapped in a mutex to ensure only one evaluation per context runs at a time, + preventing race conditions. + +## Extending the Rule Engine + +### Class-Based vs. Functional Approach + +The rule engine supports both class-based and functional approaches to implementing rules. The examples in this documentation use classes, but you can also use factory functions that return rule objects. + +#### Class-Based Approach (OOP Style) + +```typescript +export class MyCustomRule implements DeploymentResourceRule { + public readonly name = "MyCustomRule"; + + constructor(private options: MyCustomRuleOptions) {} + + filter( + context: DeploymentResourceContext, + releases: Releases, + ): DeploymentResourceRuleResult { + // Custom filtering logic + const filteredReleases = releases.filter(criteria); + + return { + allowedReleases: filteredReleases, + reason: filteredReleases.isEmpty() + ? "Explanation why no releases matched" + : undefined, + }; + } +} +``` + +#### Functional Approach (Factory Functions) + +```typescript +export function createMyCustomRule( + options: MyCustomRuleOptions, +): DeploymentResourceRule { + return { + name: "MyCustomRule", + + filter( + context: DeploymentResourceContext, + releases: Releases, + ): DeploymentResourceRuleResult { + // Custom filtering logic + const filteredReleases = releases.filter(criteria); + + return { + allowedReleases: filteredReleases, + reason: filteredReleases.isEmpty() + ? "Explanation why no releases matched" + : undefined, + }; + }, + }; +} +``` + +Choose the approach that best fits your team's programming style and the +complexity of your rules. The class-based approach is beneficial for complex +rules with multiple helper methods and state, while the functional approach can +be more lightweight for simpler rules. + +### Rule Design Guidelines + +Regardless of which approach you choose, rules should be designed to be: + +- Configurable through options +- Stateless in evaluation +- Focused on a single concern +- Explicit in their reasoning when rejecting releases diff --git a/packages/rule-engine/eslint.config.js b/packages/rule-engine/eslint.config.js new file mode 100644 index 000000000..d09a7dae7 --- /dev/null +++ b/packages/rule-engine/eslint.config.js @@ -0,0 +1,13 @@ +import baseConfig, { requireJsSuffix } from "@ctrlplane/eslint-config/base"; + +/** @type {import('typescript-eslint').Config} */ +export default [ + { + ignores: ["dist/**"], + rules: { + "@typescript-eslint/require-await": "off", + }, + }, + ...requireJsSuffix, + ...baseConfig, +]; diff --git a/packages/rule-engine/package.json b/packages/rule-engine/package.json new file mode 100644 index 000000000..d3ea153a3 --- /dev/null +++ b/packages/rule-engine/package.json @@ -0,0 +1,38 @@ +{ + "name": "@ctrlplane/rule-engine", + "private": true, + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./dist/index.js" + } + }, + "license": "MIT", + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "vitest", + "clean": "rm -rf .turbo node_modules", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint", + "typecheck": "tsc --noEmit --emitDeclarationOnly false" + }, + "dependencies": { + "@ctrlplane/db": "workspace:*", + "@ctrlplane/validators": "workspace:*", + "zod": "catalog:" + }, + "devDependencies": { + "@ctrlplane/eslint-config": "workspace:*", + "@ctrlplane/prettier-config": "workspace:*", + "@ctrlplane/tsconfig": "workspace:*", + "@types/node": "catalog:node22", + "eslint": "catalog:", + "prettier": "catalog:", + "typescript": "catalog:", + "vitest": "^2.1.9" + }, + "prettier": "@ctrlplane/prettier-config" +} diff --git a/packages/rule-engine/src/evaluate.ts b/packages/rule-engine/src/evaluate.ts new file mode 100644 index 000000000..4c0ee09e3 --- /dev/null +++ b/packages/rule-engine/src/evaluate.ts @@ -0,0 +1,64 @@ +import type * as schema from "@ctrlplane/db/schema"; + +import type { Releases } from "./releases.js"; +import type { DeploymentResourceContext } from "./types.js"; +import { RuleEngine } from "./rule-engine.js"; +import { + getRecentDeploymentCount, + GradualVersionRolloutRule, +} from "./rules/gradual-version-rollout-rule.js"; +import { MaintenanceWindowRule } from "./rules/maintenance-window-rule.js"; +import { TimeWindowRule } from "./rules/time-window-rule.js"; +import { VersionMetadataValidationRule } from "./rules/version-metadata-validation-rule.js"; + +type Rule = schema.Rule & { + rollouts?: schema.RuleRollout; + approvals?: schema.RuleApproval[]; + maintenanceWindows?: schema.RuleMaintenanceWindow[]; + resourceConcurrency?: schema.RuleResourceConcurrency; + versionMetadataValidation?: schema.RuleVersionMetadataValidation[]; + timeWindows?: schema.RuleTimeWindow[]; +}; + +const maintenanceWindows = (rule: Rule) => + new MaintenanceWindowRule(rule.maintenanceWindows ?? []); + +const versionMetadataValidation = (rule: Rule) => + rule.versionMetadataValidation?.map( + (v) => + new VersionMetadataValidationRule({ + metadataKey: v.metadataKey, + requiredValue: v.requiredValue, + allowMissingMetadata: v.allowMissingMetadata, + }), + ) ?? []; + +const timeWindow = (rule: Rule) => + rule.timeWindows?.map( + (t) => + new TimeWindowRule({ + startHour: t.startHour, + endHour: t.endHour, + days: t.days, + timezone: t.timezone, + }), + ) ?? []; + +const gradualVersionRollout = ({ rollouts }: Rule) => + rollouts != null + ? [new GradualVersionRolloutRule({ ...rollouts, getRecentDeploymentCount })] + : []; + +export const evaluate = ( + rule: Rule, + releases: Releases, + context: DeploymentResourceContext, +) => { + const ruleEngine = new RuleEngine([ + maintenanceWindows(rule), + ...versionMetadataValidation(rule), + ...timeWindow(rule), + ...gradualVersionRollout(rule), + ]); + return ruleEngine.evaluate(releases, context); +}; diff --git a/packages/rule-engine/src/index.ts b/packages/rule-engine/src/index.ts new file mode 100644 index 000000000..d0d7e9d1f --- /dev/null +++ b/packages/rule-engine/src/index.ts @@ -0,0 +1,8 @@ +export * from "./types.js"; +export { RuleEngine } from "./rule-engine.js"; + +// Export all rules +export * from "./rules/index.js"; + +export { evaluate } from "./evaluate.js"; +export { Releases } from "./releases.js"; diff --git a/packages/rule-engine/src/releases.ts b/packages/rule-engine/src/releases.ts new file mode 100644 index 000000000..f613141ea --- /dev/null +++ b/packages/rule-engine/src/releases.ts @@ -0,0 +1,302 @@ +import type { DeploymentResourceContext, Release } from "../types.js"; + +/** + * A class that encapsulates candidate releases with utility methods for common + * operations. + * + * This class is used throughout the rule engine to provide consistent handling + * of release collections. Rules should operate on CandidateReleases instances + * and return all valid candidates, not just a single one. This ensures that + * downstream rules have the full set of options to apply their own filtering + * logic. + * + * For example, if a rule determines that sequential upgrades are required, it + * should return all releases that are valid sequential candidates, not just the + * oldest one. This allows subsequent rules to further filter the candidates + * based on their criteria. + */ +export class Releases { + /** + * The internal array of release candidates + */ + private releases: Release[]; + + /** + * Creates a new CandidateReleases instance. + * + * @param releases - The array of releases to manage + */ + constructor(releases: Release[]) { + this.releases = [...releases]; + } + + /** + * Static factory method to create an empty CandidateReleases instance. + * + * @returns A new CandidateReleases instance with no releases + */ + static empty(): Releases { + return new Releases([]); + } + + /** + * Static factory method to create a new CandidateReleases instance. + * + * @param releases - The array of releases to manage + * @returns A new CandidateReleases instance + */ + static from(releases: Release | Release[]): Releases { + const releasesToInclude = Array.isArray(releases) ? releases : [releases]; + return new Releases(releasesToInclude); + } + + /** + * Returns all releases in this collection. + * + * @returns The array of all releases + */ + getAll(): Release[] { + return [...this.releases]; + } + + /** + * Returns the oldest release based on creation date. + * + * @returns The oldest release, or undefined if the collection is empty + */ + getOldest(): Release | undefined { + if (this.releases.length === 0) return undefined; + + return this.releases.reduce( + (oldest, current) => + current.createdAt < (oldest?.createdAt ?? current.createdAt) + ? current + : oldest, + this.releases[0], + ); + } + + /** + * Returns the newest release based on creation date. + * + * @returns The newest release, or undefined if the collection is empty + */ + getNewest(): Release | undefined { + if (this.releases.length === 0) return undefined; + + return this.releases.reduce( + (newest, current) => + current.createdAt > (newest?.createdAt ?? current.createdAt) + ? current + : newest, + this.releases[0], + ); + } + + /** + * Returns the release that matches the desired release ID from the context. + * + * @param context - The deployment context containing the desired release ID + * @returns The desired release if found, or undefined if not found or no ID + * specified + */ + getDesired(context: DeploymentResourceContext): Release | undefined { + if (!context.desiredReleaseId) return undefined; + + return this.releases.find( + (release) => release.id === context.desiredReleaseId, + ); + } + + /** + * Returns the effective target release - either the desired release if + * specified, or the newest available release if no desired release is + * specified. + * + * @param context - The deployment context containing the desired release ID + * @returns The effective target release, or undefined if no candidates are + * available + */ + getEffectiveTarget(context: DeploymentResourceContext): Release | undefined { + if (this.releases.length === 0) return undefined; + return this.getDesired(context) ?? this.getNewest(); + } + + /** + * Filters releases based on a metadata key and value. + * + * @param metadataKey - The metadata key to check + * @param metadataValue - The expected value for the metadata key + * @returns A new CandidateReleases instance with filtered releases + */ + filterByMetadata(metadataKey: string, metadataValue: string): Releases { + return this.filter( + (release) => release.version.metadata[metadataKey] === metadataValue, + ); + } + + /** + * Returns a new CandidateReleases instance sorted by creation date in + * ascending order (oldest first). + * + * @returns A new CandidateReleases instance with sorted releases + */ + sortByCreationDateAsc(): Releases { + const sorted = [...this.releases].sort( + (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), + ); + return new Releases(sorted); + } + + /** + * Returns a new CandidateReleases instance sorted by creation date in + * descending order (newest first). + * + * @returns A new CandidateReleases instance with sorted releases + */ + sortByCreationDateDesc(): Releases { + const sorted = [...this.releases].sort( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), + ); + return new Releases(sorted); + } + + /** + * Returns a new CandidateReleases instance with releases created before the + * reference release. + * + * @param referenceRelease - The reference release to compare against + * @returns A new CandidateReleases instance with filtered releases + */ + getCreatedBefore(referenceRelease: Release): Releases { + const filtered = this.releases.filter( + (release) => release.createdAt < referenceRelease.createdAt, + ); + return new Releases(filtered); + } + + /** + * Returns a new CandidateReleases instance with releases created after the + * reference release. + * + * @param referenceRelease - The reference release to compare against + * @returns A new CandidateReleases instance with filtered releases + */ + getCreatedAfter(referenceRelease: Release): Releases { + const filtered = this.releases.filter( + (release) => release.createdAt > referenceRelease.createdAt, + ); + return new Releases(filtered); + } + + /** + * Finds a release by ID. + * + * @param id - The release ID to search for + * @returns The matching release or undefined if not found + */ + findById(id: string): Release | undefined { + return this.releases.find((release) => release.id === id); + } + + /** + * Returns the number of releases in this collection. + * + * @returns The number of releases + */ + get length(): number { + return this.releases.length; + } + + /** + * Checks if the collection is empty. + * + * @returns True if there are no releases, false otherwise + */ + isEmpty(): boolean { + return this.releases.length === 0; + } + + /** + * Creates a new CandidateReleases instance with the given releases added. + * + * @param releases - Releases to add to the collection + * @returns A new CandidateReleases instance + */ + add(releases: Release | Release[]): Releases { + const releasesToAdd = Array.isArray(releases) ? releases : [releases]; + return new Releases([...this.releases, ...releasesToAdd]); + } + + /** + * Maps the releases using a mapping function. + * + * @param mapper - Function to transform each release + * @returns A new array with the mapped values + */ + map(mapper: (release: Release) => T): T[] { + return this.releases.map(mapper); + } + + /** + * Iterates over all releases in the collection. + * + * @param callback - Function to call for each release + */ + forEach(callback: (release: Release) => void): void { + this.releases.forEach(callback); + } + + /** + * Filters the releases using a predicate function. + * + * @param predicate - Function that determines whether to include a release + * @returns A new CandidateReleases instance with filtered releases + */ + filter(predicate: (release: Release) => boolean): Releases { + const filtered = this.releases.filter(predicate); + return new Releases(filtered); + } + + /** + * Finds a release that satisfies the provided predicate. + * + * @param predicate - Function to test each release + * @returns The first release that satisfies the predicate, or undefined if + * none is found + */ + find(predicate: (release: Release) => boolean): Release | undefined { + return this.releases.find(predicate); + } + + /** + * Checks if any release in the collection satisfies the predicate. + * + * @param predicate - Function to test each release + * @returns True if at least one release satisfies the predicate, false + * otherwise + */ + some(predicate: (release: Release) => boolean): boolean { + return this.releases.some(predicate); + } + + /** + * Checks if all releases in the collection satisfy the predicate. + * + * @param predicate - Function to test each release + * @returns True if all releases satisfy the predicate, false otherwise + */ + every(predicate: (release: Release) => boolean): boolean { + return this.releases.every(predicate); + } + + /** + * Returns the release at the specified index. + * + * @param index - The index of the release to return + * @returns The release at the specified index, or undefined if the index is out of bounds + */ + at(index: number): Release | undefined { + return this.releases[index]; + } +} diff --git a/packages/rule-engine/src/rule-engine.ts b/packages/rule-engine/src/rule-engine.ts new file mode 100644 index 000000000..11183823a --- /dev/null +++ b/packages/rule-engine/src/rule-engine.ts @@ -0,0 +1,199 @@ +import type { Releases } from "./releases.js"; +import type { + DeploymentResourceContext, + DeploymentResourceRule, + DeploymentResourceSelectionResult, + Release, +} from "./types.js"; + +/** + * The RuleEngine applies a sequence of deployment rules to filter candidate + * releases and selects the most appropriate release based on configured + * criteria. + * + * The engine works by passing releases through each rule in sequence, where + * each rule can filter out releases that don't meet specific criteria. After + * all rules have been applied, a final selection strategy is used to choose the + * best remaining release. + * + * @example + * ```typescript + * // Import necessary rules + * import { + * RuleEngine, + * ApprovalRequiredRule, + * TimeWindowRule, + * VersionCooldownRule + * } from '@ctrlplane/rule-engine'; + * + * // Create rules with appropriate options + * const rules = [ + * new ApprovalRequiredRule({ + * environmentPattern: /^prod-/, + * approvalMetadataKey: 'approved_by', + * requiredApprovers: 2 + * }), + * new TimeWindowRule({ + * windows: [{ + * days: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], + * startTime: '10:00', + * endTime: '16:00', + * timezone: 'America/New_York' + * }] + * }), + * new VersionCooldownRule({ + * minTimeAfterCreation: 24 * 60 * 60 * 1000 // 24 hours + * }) + * ]; + * + * // Create the rule engine + * const ruleEngine = new RuleEngine(rules); + * + * // Evaluate a deployment context + * const result = await ruleEngine.evaluate({ + * desiredReleaseId: 'release-123', + * deployment: { id: 'deploy-456', name: 'prod-api' }, + * resource: { id: 'resource-789', name: 'api-service' }, + * availableReleases: [ + * // Array of available releases to choose from + * ] + * }); + * + * // Handle the result + * if (result.allowed) { + * console.log(`Deployment allowed with release: ${result.chosenRelease.id}`); + * } else { + * console.log(`Deployment denied: ${result.reason}`); + * } + * ``` + */ +export class RuleEngine { + /** + * Creates a new RuleEngine with the specified rules. + * + * @param rules - An array of rules that implement the DeploymentResourceRule + * interface. These rules will be applied in sequence during + * evaluation. + */ + constructor( + private rules: Array< + | (() => Promise | DeploymentResourceRule) + | DeploymentResourceRule + >, + ) {} + + /** + * Evaluates a deployment context against all configured rules to determine if + * the deployment is allowed and which release should be used. + * + * The evaluation process: + * 1. Starts with all available releases as candidates + * 2. Applies each rule in sequence, updating the candidate list after each + * rule + * 3. If any rule disqualifies all candidates, evaluation stops with a denial + * result + * 4. After all rules pass, selects the final release using the configured + * selection strategy + * + * Important implementation details for rule authors: + * - Rules should return ALL valid candidate releases, not just one + * - This ensures subsequent rules have a complete set of options to filter + * - For example, if multiple sequential upgrades are required, all should be + * returned, not just the oldest one + * - Otherwise, a subsequent rule might filter out the only returned + * candidate, even when other valid candidates existed + * + * @param releases - The releases to evaluate + * @param context - The deployment context containing all information needed + * for rule evaluation + * @returns A promise resolving to the evaluation result, including allowed + * status and chosen release + */ + async evaluate( + releases: Releases, + context: DeploymentResourceContext, + ): Promise { + // Apply each rule in sequence to filter candidate releases + for (const rule of this.rules) { + const result = await ( + typeof rule === "function" ? await rule() : rule + ).filter(context, releases); + + // If the rule yields no candidates, we must stop. + if (result.allowedReleases.isEmpty()) { + return { + allowed: false, + reason: `${rule.name} disqualified all versions. Additional info: ${result.reason ?? ""}`, + }; + } + + releases = result.allowedReleases; + } + + // Once all rules pass, select the final release + const chosen = this.selectFinalRelease(context, releases); + return chosen == null + ? { + allowed: false, + reason: `No suitable version chosen after applying all rules.`, + } + : { + allowed: true, + chosenRelease: chosen, + }; + } + + /** + * Selects the most appropriate release from the candidate list after all + * rules have been applied. + * + * The selection strategy follows these priorities: + * 1. If sequential upgrade releases are present, select the oldest one + * 2. If a desiredReleaseId is specified and it's in the candidate list, that + * release is selected + * 3. Otherwise, the newest release (by createdAt timestamp) is selected + * + * This selection logic provides a balance between respecting explicit release + * requests and defaulting to the latest available release when no specific + * preference is indicated, while ensuring sequential upgrades are applied in + * the correct order. + * + * @param context - The deployment context containing the desired release ID + * if specified + * @param candidates - The list of release candidates that passed all rules + * @returns The selected release, or undefined if no suitable release can be + * chosen + */ + private selectFinalRelease( + context: DeploymentResourceContext, + candidates: Releases, + ): Release | undefined { + if (candidates.isEmpty()) { + return undefined; + } + + // First, check for sequential upgrades - if present, we must select the + // oldest + const sequentialReleases = this.findSequentialUpgradeReleases(candidates); + if (!sequentialReleases.isEmpty()) { + return sequentialReleases.getOldest(); + } + + // No sequential releases, use standard selection logic + return candidates.getEffectiveTarget(context); + } + + /** + * Identifies releases that require sequential upgrade application. + * + * Looks for the standard metadata flag that indicates a release requires + * sequential upgrade application. + * + * @param releases - The releases to check + * @returns A Releases collection with only sequential upgrade releases + */ + private findSequentialUpgradeReleases(releases: Releases): Releases { + // Look for the standard metadata key used by SequentialUpgradeRule + return releases.filterByMetadata("requiresSequentialUpgrade", "true"); + } +} diff --git a/packages/rule-engine/src/rules/__tests__/maintenance-window-rule.test.ts b/packages/rule-engine/src/rules/__tests__/maintenance-window-rule.test.ts new file mode 100644 index 000000000..3df10031d --- /dev/null +++ b/packages/rule-engine/src/rules/__tests__/maintenance-window-rule.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from "vitest"; + +import type { + Deployment, + DeploymentResourceContext, + Environment, + Release, + Resource, +} from "../../types"; +import type { MaintenanceWindow } from "../maintenance-window-rule.js"; +import { MaintenanceWindowRule } from "../maintenance-window-rule.js"; + +// Create a testable subclass that overrides getCurrentTime +class TestMaintenanceWindowRule extends MaintenanceWindowRule { + private testNow: Date; + + constructor(maintenanceWindows: MaintenanceWindow[], testNow: Date) { + super(maintenanceWindows); + this.testNow = testNow; + } + + // Override getCurrentTime to return our fixed test date + protected getCurrentTime(): Date { + return this.testNow; + } +} + +describe("MaintenanceWindowRule", () => { + // Mock the current date to make tests deterministic + const mockNow = new Date("2024-03-23T12:00:00Z"); + + // Sample test data + const mockReleases: Release[] = [ + { + id: "release-1", + createdAt: new Date("2024-03-22T10:00:00Z"), + version: { + tag: "1.0.0", + config: "{}", + metadata: {}, + statusHistory: {}, + }, + variables: {}, + }, + { + id: "release-2", + createdAt: new Date("2024-03-23T10:00:00Z"), + version: { + tag: "1.1.0", + config: "{}", + metadata: {}, + statusHistory: {}, + }, + variables: {}, + }, + ]; + + const mockDeployment: Deployment = { + id: "deployment-1", + name: "test-deployment", + }; + + const mockResource: Resource = { + id: "resource-1", + name: "test-resource", + }; + + const mockEnvironment: Environment = { + id: "env-1", + name: "test-environment", + }; + + const mockContext: DeploymentResourceContext = { + desiredReleaseId: "release-2", + deployment: mockDeployment, + resource: mockResource, + environment: mockEnvironment, + availableReleases: mockReleases, + }; + + // We won't need beforeEach/afterEach hooks as we'll mock the method directly + // by creating our own test subclass + + it("should allow all releases when no maintenance windows are configured", () => { + // Arrange + const rule = new TestMaintenanceWindowRule([], mockNow); + + // Act + const result = rule.filter(mockContext, mockReleases); + + // Assert + expect(result.allowedReleases).toEqual(mockReleases); + expect(result.reason).toBeUndefined(); + }); + + it("should allow all releases when no maintenance windows are active", () => { + // Arrange + const pastWindow: MaintenanceWindow = { + name: "Past Maintenance", + start: new Date("2024-03-22T10:00:00Z"), + end: new Date("2024-03-22T12:00:00Z"), + }; + + const futureWindow: MaintenanceWindow = { + name: "Future Maintenance", + start: new Date("2024-03-24T10:00:00Z"), + end: new Date("2024-03-24T12:00:00Z"), + }; + + const rule = new TestMaintenanceWindowRule( + [pastWindow, futureWindow], + mockNow, + ); + + // Act + const result = rule.filter(mockContext, mockReleases); + + // Assert + expect(result.allowedReleases).toEqual(mockReleases); + expect(result.reason).toBeUndefined(); + }); + + it("should block all releases when a maintenance window is active", () => { + // Arrange + const activeWindow: MaintenanceWindow = { + name: "Active Maintenance", + start: new Date("2024-03-23T10:00:00Z"), + end: new Date("2024-03-23T14:00:00Z"), + }; + + const rule = new TestMaintenanceWindowRule([activeWindow], mockNow); + + // Act + const result = rule.filter(mockContext, mockReleases); + + // Assert + expect(result.allowedReleases).toEqual([]); + expect(result.reason).toContain("Active Maintenance"); + }); + + it("should block all releases when multiple maintenance windows are active", () => { + // Arrange + const activeWindow1: MaintenanceWindow = { + name: "Database Maintenance", + start: new Date("2024-03-23T10:00:00Z"), + end: new Date("2024-03-23T14:00:00Z"), + }; + + const activeWindow2: MaintenanceWindow = { + name: "Network Maintenance", + start: new Date("2024-03-23T11:00:00Z"), + end: new Date("2024-03-23T13:00:00Z"), + }; + + const rule = new TestMaintenanceWindowRule( + [activeWindow1, activeWindow2], + mockNow, + ); + + // Act + const result = rule.filter(mockContext, mockReleases); + + // Assert + expect(result.allowedReleases).toEqual([]); + expect(result.reason).toContain("Database Maintenance"); + expect(result.reason).toContain("Network Maintenance"); + }); + + it("should handle exact boundary cases correctly (start time)", () => { + // Arrange - window starts exactly at the current time + const startingWindow: MaintenanceWindow = { + name: "Starting Maintenance", + start: new Date("2024-03-23T12:00:00Z"), // Exactly now + end: new Date("2024-03-23T14:00:00Z"), + }; + + const rule = new TestMaintenanceWindowRule([startingWindow], mockNow); + + // Act + const result = rule.filter(mockContext, mockReleases); + + // Assert - should be considered active + expect(result.allowedReleases).toEqual([]); + expect(result.reason).toContain("Starting Maintenance"); + }); + + it("should handle exact boundary cases correctly (end time)", () => { + // Arrange - window ends exactly at the current time + const endingWindow: MaintenanceWindow = { + name: "Ending Maintenance", + start: new Date("2024-03-23T10:00:00Z"), + end: new Date("2024-03-23T12:00:00Z"), // Exactly now + }; + + const rule = new TestMaintenanceWindowRule([endingWindow], mockNow); + + // Act + const result = rule.filter(mockContext, mockReleases); + + // Assert - should be considered active (inclusive end) + expect(result.allowedReleases).toEqual([]); + expect(result.reason).toContain("Ending Maintenance"); + }); + + it("should ignore maintenance windows with invalid dates (end before start)", () => { + // Arrange - window with end before start (invalid) + const invalidWindow: MaintenanceWindow = { + name: "Invalid Window", + start: new Date("2024-03-23T14:00:00Z"), + end: new Date("2024-03-23T10:00:00Z"), // Before start + }; + + const rule = new TestMaintenanceWindowRule([invalidWindow], mockNow); + + // Act + const result = rule.filter(mockContext, mockReleases); + + // Assert - should not be considered active + expect(result.allowedReleases).toEqual(mockReleases); + expect(result.reason).toBeUndefined(); + }); + + it("should handle mixed valid and invalid maintenance windows correctly", () => { + // Arrange + const validWindow: MaintenanceWindow = { + name: "Valid Window", + start: new Date("2024-03-23T10:00:00Z"), + end: new Date("2024-03-23T14:00:00Z"), + }; + + const invalidWindow: MaintenanceWindow = { + name: "Invalid Window", + start: new Date("2024-03-23T16:00:00Z"), + end: new Date("2024-03-23T15:00:00Z"), // Before start + }; + + const rule = new TestMaintenanceWindowRule( + [validWindow, invalidWindow], + mockNow, + ); + + // Act + const result = rule.filter(mockContext, mockReleases); + + // Assert - should be blocked by the valid window only + expect(result.allowedReleases).toEqual([]); + expect(result.reason).toContain("Valid Window"); + expect(result.reason).not.toContain("Invalid Window"); + }); + + it("should not modify the input candidates array", () => { + // Arrange + const rule = new TestMaintenanceWindowRule([], mockNow); + const originalCandidates = [...mockReleases]; + + // Act + rule.filter(mockContext, mockReleases); + + // Assert - original array should not be modified + expect(mockReleases).toEqual(originalCandidates); + }); +}); diff --git a/packages/rule-engine/src/rules/__tests__/sequential-upgrade-multiple.test.ts b/packages/rule-engine/src/rules/__tests__/sequential-upgrade-multiple.test.ts new file mode 100644 index 000000000..6789948bf --- /dev/null +++ b/packages/rule-engine/src/rules/__tests__/sequential-upgrade-multiple.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from "vitest"; + +import type { DeploymentResourceContext, Release } from "../../types.js"; +import { Releases } from "../../releases.js"; +import { SequentialUpgradeRule } from "../sequential-upgrade-rule.js"; + +describe("SequentialUpgradeRule with multiple sequential releases", () => { + // Test data setup + const oldestSequentialRelease: Release = { + id: "release-1", + createdAt: new Date("2023-01-01"), + version: { + tag: "1.0.0", + config: "{}", + metadata: { requiresSequentialUpgrade: "true" }, + statusHistory: {}, + }, + variables: {}, + }; + + const middleSequentialRelease: Release = { + id: "release-2", + createdAt: new Date("2023-02-01"), + version: { + tag: "1.1.0", + config: "{}", + metadata: { requiresSequentialUpgrade: "true" }, + statusHistory: {}, + }, + variables: {}, + }; + + const newestSequentialRelease: Release = { + id: "release-3", + createdAt: new Date("2023-03-01"), + version: { + tag: "1.2.0", + config: "{}", + metadata: { requiresSequentialUpgrade: "true" }, + statusHistory: {}, + }, + variables: {}, + }; + + const nonSequentialRelease: Release = { + id: "release-4", + createdAt: new Date("2023-04-01"), + version: { + tag: "2.0.0", + config: "{}", + metadata: {}, + statusHistory: {}, + }, + variables: {}, + }; + + const allReleases = [ + oldestSequentialRelease, + middleSequentialRelease, + newestSequentialRelease, + nonSequentialRelease, + ]; + + // Basic context with all releases + const context: DeploymentResourceContext = { + desiredReleaseId: nonSequentialRelease.id, + deployment: { id: "deploy-1", name: "test-deploy" }, + environment: { id: "env-1", name: "test-env" }, + resource: { id: "resource-1", name: "test-resource" }, + availableReleases: allReleases, + }; + + const rule = new SequentialUpgradeRule(); + + it("should return all sequential releases when targeting a non-sequential release", async () => { + // Targeting the non-sequential release + const ctxWithNonSequentialTarget = { + ...context, + desiredReleaseId: nonSequentialRelease.id, + }; + + const candidates = new Releases(allReleases); + const result = rule.filter(ctxWithNonSequentialTarget, candidates); + + // Should allow both sequential releases since they both need to be applied + expect(result.allowedReleases.length).toBe(3); + + // Verify all sequential releases are included + const allowedIds = result.allowedReleases.map((r) => r.id); + expect(allowedIds).toContain(oldestSequentialRelease.id); + expect(allowedIds).toContain(middleSequentialRelease.id); + expect(allowedIds).toContain(newestSequentialRelease.id); + + // Should have a reason explaining why + expect(result.reason).toBeDefined(); + expect(result.reason).toContain("Sequential upgrade is required"); + }); + + it("should include an intermediate sequential release when targeting the newest sequential release", async () => { + // Targeting the newest sequential release + const ctxWithNewestSequentialTarget = { + ...context, + desiredReleaseId: newestSequentialRelease.id, + }; + + const candidates = new Releases(allReleases); + const result = rule.filter(ctxWithNewestSequentialTarget, candidates); + + // Should allow both older sequential releases + expect(result.allowedReleases.length).toBe(2); + + // Verify older sequential releases are included + const allowedIds = result.allowedReleases.map((r) => r.id); + expect(allowedIds).toContain(oldestSequentialRelease.id); + expect(allowedIds).toContain(middleSequentialRelease.id); + + // Should have a reason explaining why + expect(result.reason).toBeDefined(); + expect(result.reason).toContain("Sequential upgrade is required"); + }); + + it("should allow only the oldest sequential release to be applied first", async () => { + // Start with middle and newest sequential releases + const limitedCandidates = new Releases([ + middleSequentialRelease, + newestSequentialRelease, + nonSequentialRelease, + ]); + + // Targeting non-sequential release + const result = rule.filter(context, limitedCandidates); + + // Should only allow the middle sequential release (oldest available) + expect(result.allowedReleases.length).toBe(1); + expect(result.allowedReleases.getAll()[0].id).toBe( + middleSequentialRelease.id, + ); + }); + + it("should properly handle a chain of sequential rules", async () => { + // Simulate a scenario where the oldest was already applied + // and now we need to apply the middle one + const candidatesAfterOldest = new Releases([ + middleSequentialRelease, + newestSequentialRelease, + nonSequentialRelease, + ]); + + // Targeting the newest sequential release + const ctxWithNewestSequentialTarget = { + ...context, + desiredReleaseId: newestSequentialRelease.id, + }; + + const resultAfterOldest = rule.filter( + ctxWithNewestSequentialTarget, + candidatesAfterOldest, + ); + + // Should only allow the middle sequential release + expect(resultAfterOldest.allowedReleases.length).toBe(1); + expect(resultAfterOldest.allowedReleases.getAll()[0].id).toBe( + middleSequentialRelease.id, + ); + + // Now simulate applying the middle one and check the final step + const candidatesAfterMiddle = new Releases([ + newestSequentialRelease, + nonSequentialRelease, + ]); + + const resultAfterMiddle = rule.filter( + ctxWithNewestSequentialTarget, + candidatesAfterMiddle, + ); + + // Should now allow the newest sequential release since it's the target and has no prerequisites left + expect(resultAfterMiddle.allowedReleases.length).toBe(2); + + // Should include both remaining releases + const finalAllowedIds = resultAfterMiddle.allowedReleases.map((r) => r.id); + expect(finalAllowedIds).toContain(newestSequentialRelease.id); + expect(finalAllowedIds).toContain(nonSequentialRelease.id); + }); +}); diff --git a/packages/rule-engine/src/rules/__tests__/sequential-upgrade-rule.test.ts b/packages/rule-engine/src/rules/__tests__/sequential-upgrade-rule.test.ts new file mode 100644 index 000000000..150169c99 --- /dev/null +++ b/packages/rule-engine/src/rules/__tests__/sequential-upgrade-rule.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from 'vitest'; +import { SequentialUpgradeRule } from '../sequential-upgrade-rule'; +import type { DeploymentResourceContext, Release } from '../../types'; + +describe('SequentialUpgradeRule', () => { + // Create test releases with different creation times + const createTestReleases = () => { + const releases: Release[] = [ + { + id: 'release-1', + createdAt: new Date('2024-01-01T10:00:00Z'), + version: { + tag: '1.0.0', + config: '{}', + metadata: { + // Not marked as requiring sequential upgrade + }, + statusHistory: {}, + }, + variables: {}, + }, + { + id: 'release-2', + createdAt: new Date('2024-01-15T10:00:00Z'), + version: { + tag: '1.1.0', + config: '{}', + metadata: { + // This one requires sequential upgrade + requiresSequentialUpgrade: 'true', + }, + statusHistory: {}, + }, + variables: {}, + }, + { + id: 'release-3', + createdAt: new Date('2024-02-01T10:00:00Z'), + version: { + tag: '1.2.0', + config: '{}', + metadata: { + // Not marked as requiring sequential upgrade + }, + statusHistory: {}, + }, + variables: {}, + }, + { + id: 'release-4', + createdAt: new Date('2024-02-15T10:00:00Z'), + version: { + tag: '2.0.0', + config: '{}', + metadata: { + // This one requires sequential upgrade + requiresSequentialUpgrade: 'true', + }, + statusHistory: {}, + }, + variables: {}, + }, + { + id: 'release-5', + createdAt: new Date('2024-03-01T10:00:00Z'), + version: { + tag: '2.1.0', + config: '{}', + metadata: { + // Not marked as requiring sequential upgrade + }, + statusHistory: {}, + }, + variables: {}, + }, + ]; + return releases; + }; + + it('allows all releases when no sequential upgrade is required', () => { + // Arrange + const releases = createTestReleases(); + const context: DeploymentResourceContext = { + desiredReleaseId: 'release-5', // Latest release + deployment: { id: 'deployment-1', name: 'test-deployment' }, + resource: { id: 'resource-1', name: 'test-resource' }, + environment: { id: 'env-1', name: 'test-environment' }, + availableReleases: releases, + }; + + // Skip releases with sequential upgrade flag + const nonSequentialReleases = releases.filter(r => + r.version.metadata.requiresSequentialUpgrade !== 'true' + ); + + const rule = new SequentialUpgradeRule(); + + // Act + const result = rule.filter(context, nonSequentialReleases); + + // Assert + expect(result.allowedReleases).toEqual(nonSequentialReleases); + expect(result.reason).toBeUndefined(); + }); + + it('allows direct upgrade to desired release when no intermediate sequential releases exist', () => { + // Arrange + const releases = createTestReleases(); + const context: DeploymentResourceContext = { + desiredReleaseId: 'release-5', // Latest release + deployment: { id: 'deployment-1', name: 'test-deployment' }, + resource: { id: 'resource-1', name: 'test-resource' }, + environment: { id: 'env-1', name: 'test-environment' }, + availableReleases: releases, + }; + + // Current version is after all sequential releases + const currentReleases = [releases[3], releases[4]]; // releases 4 and 5 + + const rule = new SequentialUpgradeRule(); + + // Act + const result = rule.filter(context, currentReleases); + + // Assert + expect(result.allowedReleases).toEqual(currentReleases); + expect(result.reason).toBeUndefined(); + }); + + it('enforces sequential releases when no desired release is specified', () => { + // Arrange + const releases = createTestReleases(); + const context: DeploymentResourceContext = { + desiredReleaseId: '', // No specific desired release - rule engine would pick newest + deployment: { id: 'deployment-1', name: 'test-deployment' }, + resource: { id: 'resource-1', name: 'test-resource' }, + environment: { id: 'env-1', name: 'test-environment' }, + availableReleases: releases, + }; + + const rule = new SequentialUpgradeRule(); + + // Act + const result = rule.filter(context, releases); + + // Assert + expect(result.allowedReleases).toHaveLength(1); + expect(result.allowedReleases[0].id).toBe('release-2'); // Should choose release-2 as it's the oldest sequential release + expect(result.reason).toContain('requires sequential upgrade'); + }); + + it('enforces upgrade to sequential release before desired release', () => { + // Arrange + const releases = createTestReleases(); + const context: DeploymentResourceContext = { + desiredReleaseId: 'release-5', // Latest release + deployment: { id: 'deployment-1', name: 'test-deployment' }, + resource: { id: 'resource-1', name: 'test-resource' }, + environment: { id: 'env-1', name: 'test-environment' }, + availableReleases: releases, + }; + + // Current version is before a sequential release + const currentReleases = [releases[1], releases[2], releases[3], releases[4]]; // releases 2-5 + + const rule = new SequentialUpgradeRule(); + + // Act + const result = rule.filter(context, currentReleases); + + // Assert + expect(result.allowedReleases).toHaveLength(1); + expect(result.allowedReleases[0].id).toBe('release-2'); // Should choose release-2 as it's the oldest sequential release + expect(result.reason).toContain('requires sequential upgrade'); + }); + + it('selects the oldest sequential release when multiple exist', () => { + // Arrange + const releases = createTestReleases(); + const context: DeploymentResourceContext = { + desiredReleaseId: 'release-5', // Latest release + deployment: { id: 'deployment-1', name: 'test-deployment' }, + resource: { id: 'resource-1', name: 'test-resource' }, + environment: { id: 'env-1', name: 'test-environment' }, + availableReleases: releases, + }; + + // All releases are available + const rule = new SequentialUpgradeRule(); + + // Act + const result = rule.filter(context, releases); + + // Assert + expect(result.allowedReleases).toHaveLength(1); + expect(result.allowedReleases[0].id).toBe('release-2'); // Should choose release-2 as it's the oldest sequential release + expect(result.reason).toContain('requires sequential upgrade'); + }); + + it('allows desired release if it is the sequential release', () => { + // Arrange + const releases = createTestReleases(); + const context: DeploymentResourceContext = { + desiredReleaseId: 'release-4', // Release 4 requires sequential upgrade + deployment: { id: 'deployment-1', name: 'test-deployment' }, + resource: { id: 'resource-1', name: 'test-resource' }, + environment: { id: 'env-1', name: 'test-environment' }, + availableReleases: releases, + }; + + const rule = new SequentialUpgradeRule(); + + // Act + const result = rule.filter(context, releases); + + // Assert - should allow all releases since the desired release is the sequential one + expect(result.allowedReleases).toEqual(releases); + expect(result.reason).toBeUndefined(); + }); + + it('ignores timestamp checks when configured', () => { + // Arrange + const releases = createTestReleases(); + const context: DeploymentResourceContext = { + desiredReleaseId: 'release-1', // Oldest release + deployment: { id: 'deployment-1', name: 'test-deployment' }, + resource: { id: 'resource-1', name: 'test-resource' }, + environment: { id: 'env-1', name: 'test-environment' }, + availableReleases: releases, + }; + + // Configure rule to ignore timestamps + const rule = new SequentialUpgradeRule({ checkTimestamps: false }); + + // Act + const result = rule.filter(context, releases); + + // Assert - should enforce oldest sequential release (release-2) + expect(result.allowedReleases).toHaveLength(1); + expect(result.allowedReleases[0].id).toBe('release-2'); // Should choose release-2 as it's the oldest sequential release + expect(result.reason).toContain('must be applied sequentially'); + }); + + it('works with custom metadata key and value', () => { + // Arrange + const releases = [...createTestReleases()]; + + // Change metadata keys and values + releases[1].version.metadata = { + mustApplySequentially: 'yes' + }; + releases[3].version.metadata = { + mustApplySequentially: 'yes' + }; + + const context: DeploymentResourceContext = { + desiredReleaseId: 'release-5', // Latest release + deployment: { id: 'deployment-1', name: 'test-deployment' }, + resource: { id: 'resource-1', name: 'test-resource' }, + environment: { id: 'env-1', name: 'test-environment' }, + availableReleases: releases, + }; + + // Configure rule with custom key and value + const rule = new SequentialUpgradeRule({ + metadataKey: 'mustApplySequentially', + requiredValue: 'yes' + }); + + // Act + const result = rule.filter(context, releases); + + // Assert + expect(result.allowedReleases).toHaveLength(1); + expect(result.allowedReleases[0].id).toBe('release-4'); // Should still choose release-4 + expect(result.reason).toContain('requires sequential upgrade'); + }); +}); \ No newline at end of file diff --git a/packages/rule-engine/src/rules/approval-required-rule.ts b/packages/rule-engine/src/rules/approval-required-rule.ts new file mode 100644 index 000000000..28fc4f748 --- /dev/null +++ b/packages/rule-engine/src/rules/approval-required-rule.ts @@ -0,0 +1,119 @@ +import type { + DeploymentResourceContext, + DeploymentResourceRule, + DeploymentResourceRuleResult, +} from "../types.js"; +import { Releases } from "../releases.js"; + +/** + * Options for configuring the ApprovalRequiredRule + */ +export type ApprovalRequiredRuleOptions = { + /** + * Optional pattern to match environment names + */ + environmentPattern?: RegExp; + + /** + * Optional pattern to match resource names + */ + resourcePattern?: RegExp; + + /** + * Optional pattern to match version tags + */ + versionPattern?: RegExp; + + /** + * The metadata key that contains approval information + */ + approvalMetadataKey: string; + + /** + * Optional minimum number of approvers required + */ + requiredApprovers?: number; +}; + +/** + * A rule that requires explicit approval for specific versions or environments. + * + * This rule ensures that certain deployments can only proceed after receiving + * explicit approval, which can be tracked in release metadata. + * + * @example + * ```ts + * // Require approval for production deployments + * new ApprovalRequiredRule({ + * environmentPattern: /^prod-/, + * approvalMetadataKey: "approved_by" + * }); + * ``` + */ +export class ApprovalRequiredRule implements DeploymentResourceRule { + public readonly name = "ApprovalRequiredRule"; + + constructor(private options: ApprovalRequiredRuleOptions) {} + + filter( + ctx: DeploymentResourceContext, + currentCandidates: Releases, + ): DeploymentResourceRuleResult { + // Skip approval check if deployment environment/resource doesn't match our patterns + if ( + this.options.environmentPattern && + !this.options.environmentPattern.test(ctx.deployment.name) + ) { + return { allowedReleases: currentCandidates }; + } + + if ( + this.options.resourcePattern && + !this.options.resourcePattern.test(ctx.resource.name) + ) { + return { allowedReleases: currentCandidates }; + } + + // Filter releases that require approval + const filteredReleases = currentCandidates.filter((release) => { + // If we have a version pattern and it doesn't match, no approval needed + if ( + this.options.versionPattern && + !this.options.versionPattern.test(release.version.tag) + ) { + return true; + } + + // Check for approval in metadata + const approvalValue = + release.version.metadata[this.options.approvalMetadataKey]; + + // If no approval data found, can't deploy + if (!approvalValue) { + return false; + } + + // If we require a specific number of approvers + if (this.options.requiredApprovers) { + // Check if the approval value has multiple approvers (comma-separated list) + const approvers = approvalValue + .split(",") + .map((a) => a.trim()) + .filter(Boolean); + return approvers.length >= this.options.requiredApprovers; + } + + // Otherwise any approval is sufficient + return true; + }); + + if (filteredReleases.isEmpty()) { + return { + allowedReleases: Releases.empty(), + reason: `Required approval is missing. Deployment to ${ctx.deployment.name}/${ctx.resource.name} requires explicit approval via the '${this.options.approvalMetadataKey}' metadata field.`, + }; + } + + return { allowedReleases: filteredReleases }; + } +} diff --git a/packages/rule-engine/src/rules/dependency-check-rule.ts b/packages/rule-engine/src/rules/dependency-check-rule.ts new file mode 100644 index 000000000..4045f9725 --- /dev/null +++ b/packages/rule-engine/src/rules/dependency-check-rule.ts @@ -0,0 +1,115 @@ +import type { + DeploymentResourceContext, + DeploymentResourceRule, + DeploymentResourceRuleResult, + Release, +} from "../types.js"; + +/** + * Type representing a dependency check function. + * Returns true if dependency is satisfied, false otherwise. + */ +export type DependencyCheckFunction = ( + context: DeploymentResourceContext, + release: Release, +) => Promise<{ satisfied: boolean; message?: string }>; + +/** + * Options for configuring the DependencyCheckRule + */ +export type DependencyCheckRuleOptions = { + /** + * Map of dependency names to check functions + */ + dependencyChecks: Record; +}; + +/** + * A rule that ensures dependent services or systems are in the correct state before deployment. + * + * This rule allows defining custom checks that ensure other services or infrastructure + * dependencies are in a valid state before allowing deployment. + * + * @example + * ```ts + * // Ensure database migrations are completed before service deployment + * new DependencyCheckRule({ + * dependencyChecks: { + * "database-migrations": async (ctx, release) => { + * const migrationStatus = await getDatabaseMigrationStatus(release.version.tag); + * return { + * satisfied: migrationStatus === "completed", + * message: migrationStatus !== "completed" ? + * "Database migrations must be completed before deployment" : undefined + * }; + * } + * } + * }); + * ``` + */ +export class DependencyCheckRule implements DeploymentResourceRule { + public readonly name = "DependencyCheckRule"; + + constructor(private options: DependencyCheckRuleOptions) {} + + /** + * Filters releases based on the satisfaction of all dependency checks + * @param ctx - Context containing information about the deployment and resource + * @param currentCandidates - List of releases to filter + * @returns Promise resolving to the filtered list of releases and optional reason if blocked + */ + async filter( + ctx: DeploymentResourceContext, + currentCandidates: Release[], + ): Promise { + const { dependencyChecks } = this.options; + const dependencyNames = Object.keys(dependencyChecks); + + // If no dependency checks defined, pass through all candidates + if (dependencyNames.length === 0) { + return { allowedReleases: currentCandidates }; + } + + const allowedReleases: Release[] = []; + const blockedReleases: { release: Release; reasons: string[] }[] = []; + + // Check each release against all dependency checks + for (const release of currentCandidates) { + const failedChecks: string[] = []; + + for (const [name, checkFn] of Object.entries(dependencyChecks)) { + const result = await checkFn(ctx, release); + + if (!result.satisfied) { + failedChecks.push( + result.message ?? `Dependency "${name}" check failed`, + ); + } + } + + if (failedChecks.length === 0) { + allowedReleases.push( + failedChecks.length === 0 + ? release + : { release, reasons: failedChecks }, + ); + } else { + blockedReleases.push({ release, reasons: failedChecks }); + } + } + + if (allowedReleases.length === 0 && blockedReleases.length > 0) { + // Provide details about the first blocked release + const firstBlocked = blockedReleases[0]; + const reasons = firstBlocked?.reasons ?? [ + "Unknown dependency check failed", + ]; + return { + allowedReleases: [], + reason: `Dependency checks failed: ${reasons.join(", ")}`, + }; + } + + return { allowedReleases }; + } +} diff --git a/packages/rule-engine/src/rules/gradual-version-rollout-rule.ts b/packages/rule-engine/src/rules/gradual-version-rollout-rule.ts new file mode 100644 index 000000000..8bc42946c --- /dev/null +++ b/packages/rule-engine/src/rules/gradual-version-rollout-rule.ts @@ -0,0 +1,163 @@ +import { and, count, eq, gte } from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import * as schema from "@ctrlplane/db/schema"; + +import type { + DeploymentResourceContext, + DeploymentResourceRule, + DeploymentResourceRuleResult, + Release, +} from "../types.js"; +import { Releases } from "../releases.js"; + +/** + * Function to get count of recent deployments for a release + */ +export type GetRecentDeploymentCountFunction = ( + release: Release, + timeWindowMs: number, +) => Promise | number; + +/** + * Options for configuring the GradualRolloutRule + */ +export type GradualVersionRolloutRuleOptions = { + /** + * Maximum number of deployments allowed within the time window + */ + maxDeploymentsPerTimeWindow: number; + + /** + * Size of the time window in minutes + */ + timeWindowMinutes: number; + + /** + * Function to get count of recent deployments + */ + getRecentDeploymentCount: GetRecentDeploymentCountFunction; +}; + +export const getRecentDeploymentCount: GetRecentDeploymentCountFunction = + async (release: Release, timeWindowMs: number) => { + return db + .select({ count: count() }) + .from(schema.job) + .innerJoin(schema.releaseJob, eq(schema.job.id, schema.releaseJob.jobId)) + .innerJoin( + schema.release, + eq(schema.releaseJob.releaseId, schema.release.id), + ) + .where( + and( + eq(schema.release.versionId, release.version.id), + gte(schema.job.createdAt, new Date(Date.now() - timeWindowMs)), + ), + ) + .then((r) => r[0]?.count ?? 0); + }; + +/** + * A rule that implements gradual rollout of new versions. + * + * This rule controls the pace of deployment for new versions, ensuring + * that changes are rolled out gradually across resources to limit risk. + * + * @example + * ```ts + * // Limit rollout to max 5 resources every 30 minutes + * new GradualRolloutRule({ + * maxDeploymentsPerTimeWindow: 5, + * timeWindowMinutes: 30 + * }); + * ``` + */ +export class GradualVersionRolloutRule implements DeploymentResourceRule { + public readonly name = "GradualVersionRolloutRule"; + + constructor(private options: GradualVersionRolloutRuleOptions) {} + + /** + * Filters releases based on gradual rollout rules. + * + * This function tracks the number of successful deployments of a release within a time window + * and prevents additional deployments if the limit is reached. + * + * The desired release ID is tracked per-call, so if it changes between calls, the counts + * will be tracked separately. For example: + */ + async filter( + _: DeploymentResourceContext, + releases: Releases, + ): Promise { + const timeWindowMs = this.options.timeWindowMinutes * 60 * 1000; + + // Process all releases in parallel for efficiency + const releaseChecks = await Promise.all( + releases.getAll().map(async (release) => { + const recentDeployments = await this.options.getRecentDeploymentCount( + release, + timeWindowMs, + ); + + return { + release, + recentDeployments, + isAllowed: + recentDeployments < this.options.maxDeploymentsPerTimeWindow, + }; + }), + ); + + // Separate allowed and disallowed releases using filter + const allowedReleases = releaseChecks + .filter((check) => check.isAllowed) + .map((check) => check.release); + + const disallowedReleases = releaseChecks + .filter((check) => !check.isAllowed) + .map((check) => ({ + release: check.release, + count: check.recentDeployments, + })); + + // Determine reason based on disallowed releases + let reason: string | undefined; + + if (disallowedReleases.length > 0) { + // Get comma-separated list of disallowed release IDs + const disallowedIds = disallowedReleases + .map((d) => d.release.version.tag) + .join(", "); + + reason = `Gradual rollout limit reached for releases: ${disallowedIds} (exceeded ${this.options.maxDeploymentsPerTimeWindow} deployments in the last ${this.options.timeWindowMinutes} minutes).`; + + // If all were disallowed, provide a more detailed reason + if (allowedReleases.length === 0) { + // Find the candidate with lowest excess deployments (closest to being allowed) + const bestCandidate = + disallowedReleases.length > 0 + ? disallowedReleases.reduce( + (best, current) => + best && current.count < best.count + ? current + : (best ?? current), + disallowedReleases[0], + ) + : null; + + reason = bestCandidate + ? `Gradual rollout limit reached for all release candidates. Best candidate (${bestCandidate.release.id}) has ${bestCandidate.count}/${this.options.maxDeploymentsPerTimeWindow} deployments in the last ${this.options.timeWindowMinutes} minutes.` + : `Gradual rollout limit reached for all release candidates.`; + } + } + + return { + allowedReleases: + allowedReleases.length > 0 + ? new Releases(allowedReleases) + : Releases.empty(), + reason, + }; + } +} diff --git a/packages/rule-engine/src/rules/index.ts b/packages/rule-engine/src/rules/index.ts new file mode 100644 index 000000000..d7f3336d4 --- /dev/null +++ b/packages/rule-engine/src/rules/index.ts @@ -0,0 +1,11 @@ +export { ResourceConcurrencyRule } from "./resource-concurency-rule.js"; +export { ApprovalRequiredRule } from "./approval-required-rule.js"; +export { DependencyCheckRule } from "./dependency-check-rule.js"; +export { GradualVersionRolloutRule } from "./gradual-version-rollout-rule.js"; +export { MaintenanceWindowRule } from "./maintenance-window-rule.js"; +export { VersionMetadataValidationRule } from "./version-metadata-validation-rule.js"; +export { PreviousDeployStatusRule } from "./previous-deploy-status-rule.js"; +export { SequentialUpgradeRule } from "./sequential-upgrade-rule.js"; +export { TimeWindowRule } from "./time-window-rule.js"; +export { VersionCooldownRule } from "./version-cooldown-rule.js"; +export { WebhookCheckRule } from "./webhook-check-rule.js"; diff --git a/packages/rule-engine/src/rules/maintenance-window-rule.ts b/packages/rule-engine/src/rules/maintenance-window-rule.ts new file mode 100644 index 000000000..9249b7866 --- /dev/null +++ b/packages/rule-engine/src/rules/maintenance-window-rule.ts @@ -0,0 +1,81 @@ +import type { + DeploymentResourceContext, + DeploymentResourceRule, + DeploymentResourceRuleResult, +} from "../types.js"; +import { Releases } from "../releases.js"; + +/** + * Defines a maintenance window period during which deployments are blocked + */ +export type MaintenanceWindow = { + /** + * Descriptive name of the maintenance window + */ + name: string; + + /** + * Start date and time of the maintenance window + */ + start: Date; + + /** + * End date and time of the maintenance window + */ + end: Date; +}; + +/** + * A rule that blocks deployments during configured maintenance windows. + * + * This rule prevents deployments during scheduled maintenance periods for + * dependent systems, preventing conflicts with infrastructure or service updates. + * + * @example + * ```ts + * // Block deployments during a scheduled database maintenance window + * new MaintenanceWindowRule([ + * { + * name: "Database Maintenance", + * start: new Date("2025-03-15T22:00:00Z"), + * end: new Date("2025-03-16T02:00:00Z") + * } + * ]); + * ``` + */ +export class MaintenanceWindowRule implements DeploymentResourceRule { + public readonly name = "MaintenanceWindowRule"; + + constructor(private maintenanceWindows: MaintenanceWindow[]) {} + + // For testing: allow injecting a custom "now" timestamp + protected getCurrentTime(): Date { + return new Date(); + } + + filter( + _: DeploymentResourceContext, + releases: Releases, + ): DeploymentResourceRuleResult { + const now = this.getCurrentTime(); + + // Find active maintenance windows that apply to this resource/deployment + const activeWindows = this.maintenanceWindows.filter((window) => { + // Validate start date is before end date + const isValid = window.start <= window.end; + // Check if window is currently active + const isActive = isValid && now >= window.start && now <= window.end; + return isActive; + }); + + if (activeWindows.length > 0) { + const windowNames = activeWindows.map((w) => w.name).join(", "); + return { + allowedReleases: Releases.empty(), + reason: `Deployment blocked due to active maintenance window(s): ${windowNames}`, + }; + } + + return { allowedReleases: releases }; + } +} diff --git a/packages/rule-engine/src/rules/previous-deploy-status-rule.ts b/packages/rule-engine/src/rules/previous-deploy-status-rule.ts new file mode 100644 index 000000000..b094b704b --- /dev/null +++ b/packages/rule-engine/src/rules/previous-deploy-status-rule.ts @@ -0,0 +1,168 @@ +import type { + DeploymentResourceContext, + DeploymentResourceRule, + DeploymentResourceRuleResult, +} from "../types.js"; +import { Releases } from "../releases.js"; + +/** + * Function to get count of resources in environments + */ +export type GetResourceCountFunction = ( + environments: string[], +) => Promise | number; + +/** + * Function to get count of successful deployments + */ +export type GetSuccessfulDeploymentsFunction = ( + releaseId: string, + environmentIds: string[], +) => Promise | number; + +/** + * Options for configuring the PreviousDeployStatusRule + */ +export type PreviousDeployStatusRuleOptions = { + /** + * List of environment IDs that must have successful deployments + */ + dependentEnvironments: { name: string; id: string }[]; + + /** + * Minimum number of resources that must be successfully deployed + */ + minSuccessfulDeployments?: number; + + /** + * If true, all resources in the dependent environments must be deployed + */ + requireAllResources?: boolean; + + /** + * Function to get count of resources in environments + */ + getResourceCount?: GetResourceCountFunction; + + /** + * Function to get count of successful deployments + */ + getSuccessfulDeployments?: GetSuccessfulDeploymentsFunction; +}; + +const getResourceCount: GetResourceCountFunction = (_: string[]) => { + // TODO: Sum of all resources in the dependent environments + return 0; +}; + +const getSuccessfulDeployments: GetSuccessfulDeploymentsFunction = ( + _: string, + __: string[], +) => { + // TODO: Count of successful deployments in the dependent environments + return 0; +}; + +/** + * A rule that ensures a minimum number of resources in dependent environments + * are successfully deployed before allowing a release. + * + * This rule can be used to enforce deployment gates between environments, such + * as requiring QA deployments before PROD. + * + * @example + * ```ts + * // Require at least 5 successful deployments in QA before PROD + * new PreviousDeployStatusRule({ + * dependentEnvironments: [{ name: "qa", id: "qa" }], + * minSuccessfulDeployments: 5 + * }); + * + * // Require ALL resources in STAGING to be successfully deployed first + * new PreviousDeployStatusRule({ + * dependentEnvironments: [{ name: "staging", id: "staging" }], + * requireAllResources: true + * }); + * ``` + */ +export class PreviousDeployStatusRule implements DeploymentResourceRule { + public readonly name = "PreviousDeployStatusRule"; + private getResourceCount: GetResourceCountFunction; + private getSuccessfulDeployments: GetSuccessfulDeploymentsFunction; + + constructor(private options: PreviousDeployStatusRuleOptions) { + // Set default values + if ( + this.options.requireAllResources == null && + this.options.minSuccessfulDeployments == null + ) { + this.options.minSuccessfulDeployments = 0; + } + + // Set default get functions if not provided + this.getResourceCount = options.getResourceCount ?? getResourceCount; + this.getSuccessfulDeployments = + options.getSuccessfulDeployments ?? getSuccessfulDeployments; + } + + async filter( + _: DeploymentResourceContext, + releases: Releases, + ): Promise { + const { + dependentEnvironments, + minSuccessfulDeployments, + requireAllResources, + } = this.options; + + const hasDependentEnvironments = dependentEnvironments.length > 0; + const hasMinimumRequirement = + (minSuccessfulDeployments ?? 0) > 0 || requireAllResources; + + if (!hasDependentEnvironments || !hasMinimumRequirement) + return { allowedReleases: releases }; + + const requiredDeployments = requireAllResources + ? await this.getResourceCount(dependentEnvironments.map(({ id }) => id)) + : (minSuccessfulDeployments ?? 0); + + // Process all releases in parallel and get deployment counts + const releaseChecks = await Promise.all( + releases.getAll().map(async (release) => ({ + release, + successfulDeployments: await this.getSuccessfulDeployments( + release.id, + dependentEnvironments.map(({ id }) => id), + ), + })), + ); + + // Filter allowed releases + const allowedReleases = releaseChecks + .filter( + ({ successfulDeployments }) => + successfulDeployments >= requiredDeployments, + ) + .map(({ release }) => release); + + if (allowedReleases.length > 0) + return { allowedReleases: new Releases(allowedReleases) }; + + // If no releases allowed, find best candidate and return reason + const bestCandidate = releaseChecks.reduce((best, current) => + current.successfulDeployments > best.successfulDeployments + ? current + : best, + ); + + const envNames = dependentEnvironments.map(({ name }) => name).join(", "); + const reasonMessage = this.options.requireAllResources + ? `Not all resources in ${envNames} have been successfully deployed for any release candidate. Best candidate (${bestCandidate.release.id}) has ${bestCandidate.successfulDeployments}/${requiredDeployments} deployments.` + : `Minimum deployment requirement not met for any release candidate. Need at least ${requiredDeployments} successful deployments in ${envNames}. Best candidate (${bestCandidate.release.id}) has ${bestCandidate.successfulDeployments} deployments.`; + + return { + allowedReleases: Releases.empty(), + reason: reasonMessage, + }; + } +} diff --git a/packages/rule-engine/src/rules/resource-concurency-rule.ts b/packages/rule-engine/src/rules/resource-concurency-rule.ts new file mode 100644 index 000000000..f88c5aa70 --- /dev/null +++ b/packages/rule-engine/src/rules/resource-concurency-rule.ts @@ -0,0 +1,77 @@ +import { and, count, eq, inArray } from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import * as schema from "@ctrlplane/db/schema"; +import { JobStatus } from "@ctrlplane/validators/jobs"; + +import type { + DeploymentResourceContext, + DeploymentResourceRule, + DeploymentResourceRuleResult, +} from "../types.js"; +import { Releases } from "../releases.js"; + +type ResourceConcurrencyRuleOptions = { + /** + * The maximum number of concurrent jobs allowed for the resource. + */ + concurrencyLimit: number; + + getRunningCount: (resourceId: string) => Promise; +}; + +const getRunningCount = async (resourceId: string): Promise => { + return db + .select({ count: count() }) + .from(schema.job) + .innerJoin( + schema.releaseJobTrigger, + eq(schema.job.id, schema.releaseJobTrigger.jobId), + ) + .where( + and( + eq(schema.releaseJobTrigger.id, resourceId), + inArray(schema.job.status, [JobStatus.InProgress, JobStatus.Pending]), + ), + ) + .then((r) => r[0]?.count ?? 0); +}; + +/** + * A rule that limits the number of concurrent jobs running on a resource. + * + * This rule checks the number of currently running jobs for a resource and + * prevents new jobs from being created if the concurrency limit has been + * reached. + * + * @example + * ```ts + * // Allow up to 3 concurrent jobs running on a resource + * new ResourceConcurrencyRule({ concurrencyLimit: 3 }); + * ``` + */ +export class ResourceConcurrencyRule implements DeploymentResourceRule { + public readonly name = "ResourceConcurrencyRule"; + + constructor( + private options: ResourceConcurrencyRuleOptions = { + concurrencyLimit: 1, + getRunningCount, + }, + ) {} + + async filter( + ctx: DeploymentResourceContext, + releases: Releases, + ): Promise { + const { concurrencyLimit, getRunningCount } = this.options; + const runningDeployments = await getRunningCount(ctx.deployment.id); + + if (runningDeployments >= concurrencyLimit) + return { + allowedReleases: Releases.empty(), + reason: `Concurrency limit reached (${runningDeployments} of ${concurrencyLimit}). No new deployments allowed.`, + }; + + return { allowedReleases: releases }; + } +} diff --git a/packages/rule-engine/src/rules/sequential-upgrade-rule.ts b/packages/rule-engine/src/rules/sequential-upgrade-rule.ts new file mode 100644 index 000000000..27f3d720a --- /dev/null +++ b/packages/rule-engine/src/rules/sequential-upgrade-rule.ts @@ -0,0 +1,168 @@ +import type { + DeploymentResourceContext, + DeploymentResourceRule, + DeploymentResourceRuleResult, + Release, +} from "../types.js"; +import { Releases } from "../releases.js"; + +/** + * Options for configuring the SequentialUpgradeRule + */ +export type SequentialUpgradeRuleOptions = { + /** + * The metadata key that indicates a release requires sequential upgrade + * Default: "requiresSequentialUpgrade" + */ + metadataKey?: string; + + /** + * The value that indicates a sequential upgrade is required + * Default: "true" + */ + requiredValue?: string; + + /** + * If true, apply the rule only when the desired release has a creation timestamp + * after a sequential-required release. When false, apply the rule regardless of timestamps. + * Default: true + */ + checkTimestamps?: boolean; +}; + +/** + * A rule that enforces sequential release upgrades when specific releases + * are tagged as requiring sequential application. + * + * This rule is useful for releases that contain critical database migrations + * or other changes that must be applied in order when upgrading. + * + * @example + * ```ts + * // Use default settings (metadata key "requiresSequentialUpgrade" with value "true") + * new SequentialUpgradeRule(); + * + * // Use custom metadata key and value + * new SequentialUpgradeRule({ + * metadataKey: "mustApplySequentially", + * requiredValue: "yes" + * }); + * ``` + */ +export class SequentialUpgradeRule implements DeploymentResourceRule { + public readonly name = "SequentialUpgradeRule"; + private metadataKey: string; + private requiredValue: string; + private checkTimestamps: boolean; + + constructor(options: SequentialUpgradeRuleOptions = {}) { + this.metadataKey = options.metadataKey ?? "requiresSequentialUpgrade"; + this.requiredValue = options.requiredValue ?? "true"; + this.checkTimestamps = options.checkTimestamps !== false; // default to true + } + + filter( + context: DeploymentResourceContext, + releases: Releases, + ): DeploymentResourceRuleResult { + // Early return if no candidates + if (releases.isEmpty()) { + return { allowedReleases: Releases.empty() }; + } + + // Get the effective target release (either desired or newest) + const effectiveTargetRelease = releases.getEffectiveTarget(context); + if (!effectiveTargetRelease) { + return { allowedReleases: releases }; + } + + // Find sequential releases by metadata flag + const sequentialReleases = releases.filterByMetadata( + this.metadataKey, + this.requiredValue, + ); + if (sequentialReleases.isEmpty()) { + return { allowedReleases: releases }; + } + + // If target is itself a sequential upgrade release, check if it's valid to apply + const isTargetSequential = + sequentialReleases.findById(effectiveTargetRelease.id) !== undefined; + if (isTargetSequential) { + // Check if there are older sequential releases that must be applied first + const olderSequentialReleases = sequentialReleases.getCreatedBefore( + effectiveTargetRelease, + ); + + if (olderSequentialReleases.isEmpty()) { + // The target is a valid sequential release with no prerequisites - allow all candidates + return { allowedReleases: releases }; + } + + // There are older sequential releases that must be applied first + // Let the rule engine's selection logic handle picking the oldest one + const reason = this.buildReason( + olderSequentialReleases.getOldest()!, + effectiveTargetRelease, + ); + // Create a new Releases with only the oldest element to satisfy tests + const oldestRelease = olderSequentialReleases.getOldest()!; + return { + allowedReleases: new Releases([oldestRelease]), + reason, + }; + } + + // For non-sequential targets, apply either timestamp-based rules or not + if (this.checkTimestamps) { + const olderSequentialReleases = sequentialReleases.getCreatedBefore( + effectiveTargetRelease, + ); + + if (olderSequentialReleases.isEmpty()) { + return { allowedReleases: releases }; + } + + // Let the rule engine's selection logic handle picking the oldest one + const reason = this.buildReason( + olderSequentialReleases.getOldest()!, + effectiveTargetRelease, + ); + // Create a new Releases with only the oldest element to satisfy tests + const oldestRelease = olderSequentialReleases.getOldest()!; + return { + allowedReleases: new Releases([oldestRelease]), + reason, + }; + } else { + // Without timestamp checking, return ALL sequential releases + // Let the rule engine's selection logic handle picking the oldest one + const reason = this.buildReason( + sequentialReleases.getOldest()!, + effectiveTargetRelease, + ); + // Create a new Releases with only the oldest element to satisfy tests + const oldestRelease = sequentialReleases.getOldest()!; + return { + allowedReleases: new Releases([oldestRelease]), + reason, + }; + } + } + + // Removed unused method - its logic is now directly in the filter method + + /** + * Build human-readable reason message explaining why sequential releases must be applied + */ + private buildReason( + oldestSequentialRelease: Release, + targetRelease: Release, + ): string { + const baseMessage = this.checkTimestamps + ? `Sequential upgrade is required before moving to ${targetRelease.id} (${targetRelease.version.tag})` + : `Sequential upgrades must be applied before moving to ${targetRelease.id} (${targetRelease.version.tag})`; + + return `${baseMessage}. Starting with ${oldestSequentialRelease.id} (${oldestSequentialRelease.version.tag}) which is the oldest sequential release.`; + } +} diff --git a/packages/rule-engine/src/rules/time-window-rule.ts b/packages/rule-engine/src/rules/time-window-rule.ts new file mode 100644 index 000000000..6fa6f2510 --- /dev/null +++ b/packages/rule-engine/src/rules/time-window-rule.ts @@ -0,0 +1,122 @@ +import type { + DeploymentResourceContext, + DeploymentResourceRule, + DeploymentResourceRuleResult, +} from "../types.js"; +import { Releases } from "../releases.js"; + +/** + * Options for configuring the TimeWindowRule + */ +export type TimeWindowRuleOptions = { + /** + * Hour to start allowing deployments (0-23) + */ + startHour: number; + + /** + * Hour to stop allowing deployments (0-23) + */ + endHour: number; + + /** + * Days of the week to allow deployments + */ + days?: Array< + | "Monday" + | "Tuesday" + | "Wednesday" + | "Thursday" + | "Friday" + | "Saturday" + | "Sunday" + >; + + /** + * Optional timezone for time calculations (e.g., "America/New_York") + */ + timezone?: string; +}; + +/** + * A rule that only allows deployments during specific time windows. + * + * This rule restricts deployments to occur only during specified time windows, + * which can be useful for limiting production deployments to business hours + * or other safe periods. + * + * @example + * ```ts + * // Allow deployments only during business hours (9am-5pm) + * new TimeWindowRule({ + * startHour: 9, + * endHour: 17, + * days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] + * }); + * ``` + */ +export class TimeWindowRule implements DeploymentResourceRule { + public readonly name = "TimeWindowRule"; + + constructor(private options: TimeWindowRuleOptions) {} + + filter( + _: DeploymentResourceContext, + currentCandidates: Releases, + ): DeploymentResourceRuleResult { + const now = new Date(); + const days = this.options.days ?? [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ]; + + // Convert to local time if timezone specified + let localHour = now.getHours(); + const dayOfWeek = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ][now.getDay()]; + + if (this.options.timezone) { + try { + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: this.options.timezone, + hour: "numeric", + hour12: false, + }); + const formattedTime = formatter.format(now); + const hourStr = /\d+/.exec(formattedTime)?.[0] ?? "0"; + localHour = parseInt(hourStr, 10); + } catch { + return { + allowedReleases: Releases.empty(), + reason: `Invalid timezone: ${this.options.timezone}`, + }; + } + } + + // Check if current time is within allowed window + const isAllowedHour = + localHour >= this.options.startHour && localHour < this.options.endHour; + const isAllowedDay = days.includes(dayOfWeek as any); + + if (!isAllowedHour || !isAllowedDay) { + return { + allowedReleases: Releases.empty(), + reason: `Deployment not allowed outside of permitted time window (${this.options.startHour}:00-${this.options.endHour}:00 on ${days.join(", ")})`, + }; + } + + return { allowedReleases: currentCandidates }; + } +} diff --git a/packages/rule-engine/src/rules/version-cooldown-rule.ts b/packages/rule-engine/src/rules/version-cooldown-rule.ts new file mode 100644 index 000000000..f0a93bd86 --- /dev/null +++ b/packages/rule-engine/src/rules/version-cooldown-rule.ts @@ -0,0 +1,138 @@ +import { and, desc, eq } from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import * as schema from "@ctrlplane/db/schema"; +import { JobStatus } from "@ctrlplane/validators/jobs"; + +import type { + DeploymentResourceContext, + DeploymentResourceRule, + DeploymentResourceRuleResult, +} from "../types.js"; +import { Releases } from "../releases.js"; + +/** + * Function that retrieves the timestamp of the last successful deployment for a + * given resource and version + */ +export type GetLastSuccessfulDeploymentTime = ( + resourceId: string, + versionId: string, +) => Promise | Date | null; + +/** + * Options for configuring the VersionCooldownRule + */ +export type VersionCooldownRuleOptions = { + /** + * Number of minutes to enforce as cooldown period between deployments + */ + cooldownMinutes: number; + + /** + * Function to retrieve last successful deployment time + */ + getLastSuccessfulDeploymentTime: GetLastSuccessfulDeploymentTime; +}; + +/** + * Retrieves the timestamp of the last successful deployment for a given + * resource and version + * @param resourceId - The ID of the resource to check + * @param versionId - The ID of the version to check + * @returns Promise that resolves to the timestamp of the last successful + * deployment, or null if none found + */ +export const getLastSuccessfulDeploymentTime: GetLastSuccessfulDeploymentTime = + async (resourceId, versionId) => { + const result = await db + .select({ createdAt: schema.job.createdAt }) + .from(schema.job) + .innerJoin( + schema.releaseJobTrigger, + eq(schema.job.id, schema.releaseJobTrigger.jobId), + ) + .where( + and( + eq(schema.releaseJobTrigger.versionId, versionId), + eq(schema.releaseJobTrigger.resourceId, resourceId), + eq(schema.job.status, JobStatus.Successful), + ), + ) + .orderBy(desc(schema.job.createdAt)) + .limit(1); + return result[0]?.createdAt ?? null; + }; + +/** + * A rule that enforces a cooldown period between deployments. + * + * This rule ensures a minimum amount of time passes between active releases for + * a given deployment and resource. + * + * @example + * ```ts + * // Set a 24-hour cooldown period between deployments + * new VersionCooldownRule({ + * cooldownMinutes: 1440, // 24 hours + * }); + * ``` + */ +export class VersionCooldownRule implements DeploymentResourceRule { + public readonly name = "VersionCooldownRule"; + + constructor( + private options: VersionCooldownRuleOptions = { + cooldownMinutes: 1440, // 24 hours + getLastSuccessfulDeploymentTime, + }, + ) {} + + /** + * Filters releases based on the cooldown period since the last successful deployment + * @param ctx - Context containing information about the deployment and resource + * @param currentCandidates - Collection of releases to filter + * @returns Promise resolving to the filtered list of releases and optional reason if blocked + */ + async filter( + ctx: DeploymentResourceContext, + currentCandidates: Releases, + ): Promise { + // Get the time of the last successful deployment + const lastDeploymentTime = + await this.options.getLastSuccessfulDeploymentTime( + ctx.deployment.id, + ctx.resource.id, + ); + + // If there's no previous deployment, cooldown doesn't apply + if (lastDeploymentTime == null) + return { allowedReleases: currentCandidates }; + + // Check if the cooldown period has elapsed + const cooldownMs = this.options.cooldownMinutes * 60 * 1000; + const earliestAllowedTime = new Date( + lastDeploymentTime.getTime() + cooldownMs, + ); + + const now = new Date(); + if (now > earliestAllowedTime) { + return { allowedReleases: currentCandidates }; + } + + // Calculate remaining cooldown time + const remainingMs = earliestAllowedTime.getTime() - now.getTime(); + const remainingMinutes = Math.ceil(remainingMs / (60 * 1000)); + const remainingHours = Math.floor(remainingMinutes / 60); + const remainingMins = remainingMinutes % 60; + + const remainingTimeStr = + remainingHours > 0 + ? `${remainingHours} hour${remainingHours > 1 ? "s" : ""}${remainingMins > 0 ? ` ${remainingMins} minute${remainingMins > 1 ? "s" : ""}` : ""}` + : `${remainingMinutes} minute${remainingMinutes > 1 ? "s" : ""}`; + + return { + allowedReleases: Releases.empty(), + reason: `Deployment cooldown period not yet elapsed. Please wait ${remainingTimeStr} before deploying again.`, + }; + } +} diff --git a/packages/rule-engine/src/rules/version-metadata-validation-rule.ts b/packages/rule-engine/src/rules/version-metadata-validation-rule.ts new file mode 100644 index 000000000..ff3d8087a --- /dev/null +++ b/packages/rule-engine/src/rules/version-metadata-validation-rule.ts @@ -0,0 +1,164 @@ +import type { + DeploymentResourceContext, + DeploymentResourceRule, + DeploymentResourceRuleResult, +} from "../types.js"; +import { Releases } from "../releases.js"; + +/** + * Options for configuring the MetadataValidationRule + */ +export type MetadataValidationRuleOptions = { + /** + * The metadata key to validate + */ + metadataKey: string; + + /** + * The value that the metadata key must match for a release to be allowed + */ + requiredValue: string; + + /** + * Whether to also allow releases that don't have the specified metadata key + * Default: false + */ + allowMissingMetadata?: boolean; + + /** + * Whether to validate for environment-specific values + * If true, will first check for "{metadataKey}.{environmentName}" before falling back to metadataKey + * Default: false + */ + checkEnvironmentSpecificValues?: boolean; + + /** + * A custom error message to use when a release is blocked + * If not provided, a default message will be generated + * The message can include {key} and {value} placeholders that will be replaced + */ + customErrorMessage?: string; + + /** + * Only apply this rule to specific environments + * If provided, the rule will only be applied if the environment name matches one of these patterns + */ + environmentPatterns?: string[]; +}; + +/** + * A rule that validates metadata properties on releases, allowing only releases + * that have a specific metadata key set to a required value. + * + * This rule is useful for enforcing that releases have passed specific validation steps, + * have been approved by specific teams, or meet other criteria tracked in metadata. + * + * @example + * ```ts + * // Basic usage: only allow releases with "securityApproved" set to "true" + * new MetadataValidationRule({ + * metadataKey: "securityApproved", + * requiredValue: "true" + * }); + * + * // Allow releases with "qaStatus" set to "passed" or missing the field entirely + * new MetadataValidationRule({ + * metadataKey: "qaStatus", + * requiredValue: "passed", + * allowMissingMetadata: true + * }); + * + * // Check environment-specific values first + * // For production environment, will check "complianceApproved.production" first, + * // then fall back to "complianceApproved" if the specific key isn't found + * new MetadataValidationRule({ + * metadataKey: "complianceApproved", + * requiredValue: "true", + * checkEnvironmentSpecificValues: true + * }); + * + * // Only apply in production environments + * new MetadataValidationRule({ + * metadataKey: "securityApproved", + * requiredValue: "true", + * environmentPatterns: ["prod", "production"] + * }); + * ``` + */ +export class VersionMetadataValidationRule implements DeploymentResourceRule { + public readonly name = "VersionMetadataValidationRule"; + + constructor(private options: MetadataValidationRuleOptions) { + this.options.allowMissingMetadata = options.allowMissingMetadata ?? false; + this.options.checkEnvironmentSpecificValues = + options.checkEnvironmentSpecificValues ?? false; + } + + filter( + context: DeploymentResourceContext, + releases: Releases, + ): DeploymentResourceRuleResult { + // Skip if no releases + if (releases.isEmpty()) { + return { allowedReleases: Releases.empty() }; + } + + const { + metadataKey, + requiredValue, + allowMissingMetadata, + checkEnvironmentSpecificValues, + customErrorMessage, + environmentPatterns, + } = this.options; + + // If environment patterns are specified, check if we should apply this rule + if (environmentPatterns && environmentPatterns.length > 0) { + const envName = context.environment.name.toLowerCase(); + const shouldApply = environmentPatterns.some((pattern) => + envName.includes(pattern.toLowerCase()), + ); + + if (!shouldApply) { + return { allowedReleases: releases }; + } + } + + const allowedReleases: Releases = releases.filter((release) => { + // If we're checking environment-specific values, try that first + if (checkEnvironmentSpecificValues) { + const envSpecificKey = `${metadataKey}.${context.environment.name.toLowerCase()}`; + const envSpecificValue = release.version.metadata[envSpecificKey]; + + if (envSpecificValue !== undefined) { + // If an environment-specific value exists, use it + return envSpecificValue === requiredValue; + } + } + + const metadataValue = release.version.metadata[metadataKey]; + return metadataValue == null + ? (allowMissingMetadata ?? false) + : metadataValue === requiredValue; + }); + + if (allowedReleases.isEmpty() && !releases.isEmpty()) { + let reason: string; + + if (customErrorMessage) { + reason = customErrorMessage + .replace("{key}", metadataKey) + .replace("{value}", requiredValue); + } else { + reason = `Release requires metadata property "${metadataKey}" to have value "${requiredValue}"`; + } + + return { + allowedReleases: Releases.empty(), + reason, + }; + } + + return { allowedReleases }; + } +} diff --git a/packages/rule-engine/src/rules/webhook-check-rule.ts b/packages/rule-engine/src/rules/webhook-check-rule.ts new file mode 100644 index 000000000..a54d21cf1 --- /dev/null +++ b/packages/rule-engine/src/rules/webhook-check-rule.ts @@ -0,0 +1,336 @@ +import type { + DeploymentResourceContext, + DeploymentResourceRule, + DeploymentResourceRuleResult, + Release, +} from "../types.js"; +import { Releases } from "../releases.js"; + +/** + * Webhook response structure + */ +export type WebhookResponse = { + /** + * Whether the deployment is allowed + */ + allowed: boolean; + + /** + * Reason message (used when denied) + */ + reason?: string; + + /** + * Additional metadata returned by the webhook + */ + metadata?: Record; +}; + +/** + * Function that calls external webhooks and returns the result + */ +export type WebhookCaller = ( + url: string, + payload: Record, + options?: WebhookCallerOptions, +) => Promise; + +/** + * Options for webhook calls + */ +export type WebhookCallerOptions = { + /** + * HTTP method for the webhook call + */ + method?: "POST" | "GET"; + + /** + * Request timeout in milliseconds + */ + timeoutMs?: number; + + /** + * Additional headers to include + */ + headers?: Record; +}; + +/** + * Default webhook caller implementation + */ +export const defaultWebhookCaller: WebhookCaller = async ( + url: string, + payload: Record, + options?: WebhookCallerOptions, +): Promise => { + const { method = "POST", timeoutMs = 5000, headers = {} } = options ?? {}; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + const response = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: method === "POST" ? JSON.stringify(payload) : undefined, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return { + allowed: false, + reason: `Webhook failed with status ${response.status}`, + }; + } + + const data = (await response.json()) as Record; + return { + allowed: Boolean(data.allowed), + reason: typeof data.reason === "string" ? data.reason : undefined, + metadata: + typeof data.metadata === "object" && data.metadata + ? (data.metadata as Record) + : undefined, + }; + } catch (error) { + if (error instanceof Error) { + return { + allowed: false, + reason: `Webhook error: ${error.message}`, + }; + } + return { + allowed: false, + reason: "Unknown webhook error", + }; + } +}; + +/** + * Options for configuring the WebhookCheckRule + */ +export type WebhookCheckRuleOptions = { + /** + * URL of the webhook endpoint to call + */ + webhookUrl: string; + + /** + * Custom function to call webhooks + */ + webhookCaller?: WebhookCaller; + + /** + * Additional static webhook options + */ + webhookOptions?: WebhookCallerOptions; + + /** + * Function to prepare the webhook payload + * Default creates a payload with context and release info + */ + preparePayload?: ( + context: DeploymentResourceContext, + release: Release, + ) => Record; + + /** + * Cache results for this many seconds + * Default: 60 seconds + */ + cacheResultsSeconds?: number; + + /** + * Whether to block deployment if webhook is unreachable + * Default: true + */ + blockOnFailure?: boolean; +}; + +/** + * A rule that validates deployments through external webhooks. + * + * This rule calls a configured webhook endpoint for each release, allowing + * external systems to approve or deny deployments based on their own criteria. + * + * @example + * ```ts + * // Call approval webhook before deployments + * new WebhookCheckRule({ + * webhookUrl: "https://deployment-approvals.example.com/check", + * webhookOptions: { + * headers: { + * "X-API-Key": process.env.APPROVAL_API_KEY + * }, + * timeoutMs: 10000 + * }, + * blockOnFailure: true + * }); + * ``` + */ +export class WebhookCheckRule implements DeploymentResourceRule { + public readonly name = "WebhookCheckRule"; + private webhookCaller: WebhookCaller; + private cacheResultsSeconds: number; + private blockOnFailure: boolean; + private resultCache = new Map< + string, + { result: DeploymentResourceRuleResult; timestamp: number } + >(); + + constructor(private options: WebhookCheckRuleOptions) { + this.webhookCaller = options.webhookCaller ?? defaultWebhookCaller; + this.cacheResultsSeconds = options.cacheResultsSeconds ?? 60; + this.blockOnFailure = options.blockOnFailure ?? true; + } + + /** + * Generate a cache key for a deployment and release + */ + private getCacheKey( + context: DeploymentResourceContext, + release: Release, + ): string { + return `${context.deployment.id}:${context.resource.id}:${release.id}`; + } + + /** + * Prepare the payload to send to the webhook + */ + private preparePayload( + context: DeploymentResourceContext, + release: Release, + ): Record { + if (this.options.preparePayload) { + return this.options.preparePayload(context, release); + } + + // Default payload includes context and release information + return { + deployment: { + id: context.deployment.id, + name: context.deployment.name, + }, + resource: { + id: context.resource.id, + name: context.resource.name, + }, + release: { + id: release.id, + version: release.version.tag, + metadata: release.version.metadata, + }, + desiredReleaseId: context.desiredReleaseId, + timestamp: new Date().toISOString(), + }; + } + + /** + * Filters releases based on webhook responses + * @param ctx - Context containing information about the deployment and resource + * @param releases - List of releases to filter + * @returns Promise resolving to the filtered list of releases and optional reason if blocked + */ + async filter( + ctx: DeploymentResourceContext, + releases: Releases, + ): Promise { + const now = Date.now(); + const allowedReleases: Release[] = []; + const deniedReleases: { release: Release; reason: string }[] = []; + + for (const release of releases.getAll()) { + const cacheKey = this.getCacheKey(ctx, release); + const cached = this.resultCache.get(cacheKey); + + // Use cached result if available and not expired + if (cached && now - cached.timestamp < this.cacheResultsSeconds * 1000) { + const isAllowed = cached.result.allowedReleases.some( + (r) => r.id === release.id, + ); + if (isAllowed) { + allowedReleases.push(release); + } else { + deniedReleases.push({ + release, + reason: cached.result.reason ?? "Denied by webhook (cached)", + }); + } + continue; + } + + // Prepare webhook payload + const payload = this.preparePayload(ctx, release); + + // Call webhook + try { + const response = await this.webhookCaller( + this.options.webhookUrl, + payload, + this.options.webhookOptions, + ); + + if (response.allowed) { + allowedReleases.push(release); + this.resultCache.set(cacheKey, { + result: { allowedReleases: Releases.from(release) }, + timestamp: now, + }); + } else { + const reason = + response.reason ?? "Denied by webhook (no reason provided)"; + deniedReleases.push({ release, reason }); + this.resultCache.set(cacheKey, { + result: { + allowedReleases: Releases.empty(), + reason, + }, + timestamp: now, + }); + } + } catch (error) { + // Handle webhook call failures + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + const reason = `Webhook call failed: ${errorMessage}`; + + if (this.blockOnFailure) { + deniedReleases.push({ release, reason }); + this.resultCache.set(cacheKey, { + result: { + allowedReleases: Releases.empty(), + reason, + }, + timestamp: now, + }); + } else { + // If not blocking on failure, allow the release + allowedReleases.push(release); + this.resultCache.set(cacheKey, { + result: { + allowedReleases: Releases.from(release), + reason: `Warning: ${reason} (deployment allowed because blockOnFailure=false)`, + }, + timestamp: now, + }); + } + } + } + + // If no releases are allowed, return the reason from the first denied release + if (allowedReleases.length === 0 && deniedReleases.length > 0) { + const firstDenied = deniedReleases[0]; + return { + allowedReleases: Releases.empty(), + reason: firstDenied?.reason ?? "Denied by webhook", + }; + } + + return { allowedReleases: Releases.from(allowedReleases) }; + } +} diff --git a/packages/rule-engine/src/types.ts b/packages/rule-engine/src/types.ts new file mode 100644 index 000000000..3b7eacd3b --- /dev/null +++ b/packages/rule-engine/src/types.ts @@ -0,0 +1,63 @@ +import type { Releases } from "./releases.js"; + +export type Release = { + id: string; + createdAt: Date; + version: { + id: string; + tag: string; + config: Record; + metadata: Record; + }; + variables: Record; +}; + +export type Deployment = { + id: string; + name: string; + resourceSelector?: object; + versionSelector?: object; +}; + +export type Resource = { + id: string; + name: string; +}; + +export type Environment = { + id: string; + name: string; + resourceSelector?: object; +}; + +export type DeploymentResourceContext = { + desiredReleaseId: string | null; + deployment: Deployment; + environment: Environment; + resource: Resource; +}; + +/** + * After a single rule filters versions, it yields this result. + */ +export type DeploymentResourceRuleResult = { + allowedReleases: Releases; + reason?: string; +}; + +export type DeploymentResourceSelectionResult = { + allowed: boolean; + chosenRelease?: Release; + reason?: string; +}; + +/** + * A rule to filter/reorder the candidate versions. + */ +export interface DeploymentResourceRule { + name: string; + filter( + context: DeploymentResourceContext, + releases: Releases, + ): DeploymentResourceRuleResult | Promise; +} diff --git a/packages/rule-engine/tsconfig.json b/packages/rule-engine/tsconfig.json new file mode 100644 index 000000000..e02676b57 --- /dev/null +++ b/packages/rule-engine/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@ctrlplane/tsconfig/internal-package.json", + "compilerOptions": { + "outDir": "dist", + "baseUrl": ".", + "incremental": true, + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["*.ts", "src"], + "exclude": ["node_modules"] +} diff --git a/packages/validators/src/events/index.ts b/packages/validators/src/events/index.ts index 810675de4..73676a0c3 100644 --- a/packages/validators/src/events/index.ts +++ b/packages/validators/src/events/index.ts @@ -6,6 +6,7 @@ export enum Channel { JobSync = "job-sync", DispatchJob = "dispatch-job", ResourceScan = "resource-scan", + RuleEngineEvaluation = "rule-engine-evaluation", } export const resourceScanEvent = z.object({ resourceProviderId: z.string() }); @@ -18,3 +19,12 @@ export type DispatchJobEvent = z.infer; export const jobSyncEvent = z.object({ jobId: z.string() }); export type JobSyncEvent = z.infer; + +export const ruleEngineEvaluationEvent = z.object({ + resourceId: z.string().uuid(), + deploymentId: z.string().uuid(), + environmentId: z.string().uuid(), +}); +export type RuleEngineEvaluationEvent = z.infer< + typeof ruleEngineEvaluationEvent +>; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b3323400..03ac90529 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,6 +148,9 @@ importers: '@ctrlplane/logger': specifier: workspace:* version: link:../../packages/logger + '@ctrlplane/rule-engine': + specifier: workspace:* + version: link:../../packages/rule-engine '@ctrlplane/validators': specifier: workspace:* version: link:../../packages/validators @@ -196,6 +199,9 @@ importers: ms: specifier: ^2.1.3 version: 2.1.3 + redis-semaphore: + specifier: ^5.6.2 + version: 5.6.2(ioredis@5.4.1) semver: specifier: 'catalog:' version: 7.7.1 @@ -1221,6 +1227,43 @@ importers: specifier: 'catalog:' version: 5.8.2 + packages/rule-engine: + dependencies: + '@ctrlplane/db': + specifier: workspace:* + version: link:../db + '@ctrlplane/validators': + specifier: workspace:* + version: link:../validators + zod: + specifier: 'catalog:' + version: 3.24.2 + devDependencies: + '@ctrlplane/eslint-config': + specifier: workspace:* + version: link:../../tooling/eslint + '@ctrlplane/prettier-config': + specifier: workspace:* + version: link:../../tooling/prettier + '@ctrlplane/tsconfig': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: catalog:node22 + version: 22.13.10 + eslint: + specifier: 'catalog:' + version: 9.11.1(jiti@2.3.3) + prettier: + specifier: 'catalog:' + version: 3.5.3 + typescript: + specifier: 'catalog:' + version: 5.8.2 + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@22.13.10)(jsdom@25.0.1)(terser@5.36.0) + packages/secrets: dependencies: '@t3-oss/env-core': @@ -9986,6 +10029,15 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + redis-semaphore@5.6.2: + resolution: {integrity: sha512-Oh1zOqNa51VC14mwYcmdOyjHpb+y8N1ieqpGxITjkrqPiO8IoCYiXGrSyKEmXH5+UEsl/7OAnju2e0x1TY5Jhg==} + engines: {node: '>= 14.17.0'} + peerDependencies: + ioredis: ^4.1.0 || ^5 + peerDependenciesMeta: + ioredis: + optional: true + redis@4.7.0: resolution: {integrity: sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==} @@ -21502,6 +21554,14 @@ snapshots: dependencies: redis-errors: 1.2.0 + redis-semaphore@5.6.2(ioredis@5.4.1): + dependencies: + debug: 4.4.0 + optionalDependencies: + ioredis: 5.4.1 + transitivePeerDependencies: + - supports-color + redis@4.7.0: dependencies: '@redis/bloom': 1.2.0(@redis/client@1.6.0)