diff --git a/packages/rule-engine/src/rules/__tests__/deployment-deny-rule.test.ts b/packages/rule-engine/src/rules/__tests__/deployment-deny-rule.test.ts index 3882db26d..31cac1bc9 100644 --- a/packages/rule-engine/src/rules/__tests__/deployment-deny-rule.test.ts +++ b/packages/rule-engine/src/rules/__tests__/deployment-deny-rule.test.ts @@ -24,6 +24,7 @@ describe("DeploymentDenyRule", () => { tag: "v1.0.0", config: {}, metadata: {}, + createdAt: new Date("2023-01-01T12:00:00Z"), }, variables: {}, }, @@ -35,6 +36,7 @@ describe("DeploymentDenyRule", () => { tag: "v1.1.0", config: {}, metadata: {}, + createdAt: new Date("2023-01-02T12:00:00Z"), }, variables: {}, }, diff --git a/packages/rule-engine/src/rules/__tests__/rate-rollout-rule.test.ts b/packages/rule-engine/src/rules/__tests__/rate-rollout-rule.test.ts new file mode 100644 index 000000000..5d085065b --- /dev/null +++ b/packages/rule-engine/src/rules/__tests__/rate-rollout-rule.test.ts @@ -0,0 +1,101 @@ +import { addMinutes, addSeconds } from "date-fns"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ResolvedRelease } from "../../types.js"; +import { Releases } from "../../releases.js"; +import { RateRolloutRule } from "../rate-rollout-rule.js"; + +describe("RateRolloutRule", () => { + const mockDeploymentContext = { + desiredReleaseId: null, + deployment: { + id: "deployment-1", + name: "Test Deployment", + }, + environment: { + id: "env-1", + name: "Test Environment", + }, + resource: { + id: "resource-1", + name: "Test Resource", + }, + }; + + // Set a fixed base date for testing + const baseDate = new Date("2025-01-01T00:00:00Z"); + + beforeEach(() => vi.resetAllMocks()); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const mockRelease: ResolvedRelease = { + id: "1", + createdAt: baseDate, + version: { + id: "1", + tag: "1", + config: {}, + metadata: {}, + createdAt: baseDate, + }, + variables: {}, + }; + + it("should allow a release if their rollout period is complete", () => { + const rule = new RateRolloutRule({ rolloutDurationSeconds: 600 }); + const now = addMinutes(baseDate, 10); + vi.spyOn(rule as any, "getCurrentTime").mockReturnValue(now); + + vi.spyOn(rule as any, "getHashValue").mockReturnValue(100); + + const result = rule.filter( + mockDeploymentContext, + new Releases([mockRelease]), + ); + + expect(result.allowedReleases.getAll().length).toBe(1); + expect(result.rejectionReasons).toEqual(new Map()); + }); + + it("should allow a release if the hash is less than or equal to the rollout percentage", () => { + const rule = new RateRolloutRule({ rolloutDurationSeconds: 600 }); + const now = addSeconds(baseDate, 300); + vi.spyOn(rule as any, "getCurrentTime").mockReturnValue(now); + + vi.spyOn(rule as any, "getHashValue").mockReturnValue(50); + + const result = rule.filter( + mockDeploymentContext, + new Releases([mockRelease]), + ); + + expect(result.allowedReleases.getAll().length).toBe(1); + expect(result.rejectionReasons).toEqual(new Map()); + }); + + it("should reject a release if the hash is greater than the rollout percentage", () => { + const rolloutDurationSeconds = 600; + const nowSecondsAfterBase = 300; + const rule = new RateRolloutRule({ rolloutDurationSeconds }); + const now = addSeconds(baseDate, nowSecondsAfterBase); + vi.spyOn(rule as any, "getCurrentTime").mockReturnValue(now); + + vi.spyOn(rule as any, "getHashValue").mockReturnValue(51); + + const result = rule.filter( + mockDeploymentContext, + new Releases([mockRelease]), + ); + + expect(result.allowedReleases.getAll().length).toBe(0); + const expectedRejectionReason = `Release denied due to rate-based rollout restrictions (${Math.round( + (nowSecondsAfterBase / rolloutDurationSeconds) * 100, + )}% complete, eligible in ~5m)`; + expect(result.rejectionReasons).toEqual( + new Map([[mockRelease.id, expectedRejectionReason]]), + ); + }); +}); diff --git a/packages/rule-engine/src/rules/index.ts b/packages/rule-engine/src/rules/index.ts index 992f6e43e..9c950ce7c 100644 --- a/packages/rule-engine/src/rules/index.ts +++ b/packages/rule-engine/src/rules/index.ts @@ -1 +1,2 @@ export * from "./deployment-deny-rule.js"; +export * from "./rate-rollout-rule.js"; diff --git a/packages/rule-engine/src/rules/rate-rollout-rule.ts b/packages/rule-engine/src/rules/rate-rollout-rule.ts new file mode 100644 index 000000000..b4df998e7 --- /dev/null +++ b/packages/rule-engine/src/rules/rate-rollout-rule.ts @@ -0,0 +1,112 @@ +import { differenceInSeconds } from "date-fns"; + +import type { Releases } from "../releases.js"; +import type { + DeploymentResourceContext, + DeploymentResourceRule, + DeploymentResourceRuleResult, + ResolvedRelease, +} from "../types.js"; + +export interface RateRolloutRuleOptions { + /** + * Duration in seconds over which the rollout should occur + */ + rolloutDurationSeconds: number; + + /** + * Custom reason to return when a release is denied due to rate limits + */ + denyReason?: string; +} + +export class RateRolloutRule implements DeploymentResourceRule { + public readonly name = "RateRolloutRule"; + private rolloutDurationSeconds: number; + private denyReason: string; + + constructor({ + rolloutDurationSeconds, + denyReason = "Release denied due to rate-based rollout restrictions", + }: RateRolloutRuleOptions) { + this.rolloutDurationSeconds = rolloutDurationSeconds; + this.denyReason = denyReason; + } + + // For testing: allow injecting a custom "now" timestamp + protected getCurrentTime(): Date { + return new Date(); + } + + filter( + _: DeploymentResourceContext, + releases: Releases, + ): DeploymentResourceRuleResult { + const now = this.getCurrentTime(); + + const rejectionReasons = new Map(); + const allowedReleases = releases.filter((release) => { + // Calculate how much time has passed since the release was created + const versionCreatedAt = this.getVersionCreatedAt(release); + const versionAge = differenceInSeconds(now, versionCreatedAt); + + // Calculate what percentage of the rollout period has elapsed + const rolloutPercentage = Math.min( + (versionAge / this.rolloutDurationSeconds) * 100, + 100, + ); + + // Generate a deterministic value based on the release ID + // Using a simple hash function to get a value between 0-100 + const releaseValue = this.getHashValue(release.version.id); + // If the release's hash value is less than the rollout percentage, + // it's allowed to be deployed + if (releaseValue <= rolloutPercentage) { + return true; + } + // Otherwise, it's rejected with a reason + const remainingTimeSeconds = Math.max( + 0, + this.rolloutDurationSeconds - versionAge, + ); + const remainingTimeHours = Math.floor(remainingTimeSeconds / 3600); + const remainingTimeMinutes = Math.floor( + (remainingTimeSeconds % 3600) / 60, + ); + + const timeDisplay = + remainingTimeHours > 0 + ? `~${remainingTimeHours}h ${remainingTimeMinutes}m` + : `~${remainingTimeMinutes}m`; + + rejectionReasons.set( + release.id, + `${this.denyReason} (${Math.floor(rolloutPercentage)}% complete, eligible in ${timeDisplay})`, + ); + return false; + }); + + return { allowedReleases, rejectionReasons }; + } + + /** + * Get the creation date of a release, preferring version.createdAt if available + */ + private getVersionCreatedAt(release: ResolvedRelease): Date { + return release.version.createdAt; + } + + /** + * Generate a deterministic value between 0-100 based on the release ID + * This ensures releases are consistently evaluated + */ + private getHashValue(id: string): number { + let hash = 0; + for (let i = 0; i < id.length; i++) { + hash = (hash << 5) - hash + id.charCodeAt(i); + hash |= 0; // Convert to 32bit integer + } + // Normalize to 0-100 range + return Math.abs(hash % 101); + } +} diff --git a/packages/rule-engine/src/types.ts b/packages/rule-engine/src/types.ts index c839054d3..c30e3df94 100644 --- a/packages/rule-engine/src/types.ts +++ b/packages/rule-engine/src/types.ts @@ -12,6 +12,7 @@ export type ResolvedRelease = { tag: string; config: Record; metadata: Record; + createdAt: Date; }; variables: Record; };