diff --git a/apps/jobs/src/policy-checker/index.ts b/apps/jobs/src/policy-checker/index.ts index 8197a5d52..71b51f5bd 100644 --- a/apps/jobs/src/policy-checker/index.ts +++ b/apps/jobs/src/policy-checker/index.ts @@ -1,4 +1,7 @@ +import { alias, eq } from "@ctrlplane/db"; import { db } from "@ctrlplane/db/client"; +import * as schema from "@ctrlplane/db/schema"; +import { Channel, getQueue } from "@ctrlplane/events"; import { logger } from "@ctrlplane/logger"; const triggerPolicyEvaluation = async () => { @@ -11,10 +14,27 @@ const triggerPolicyEvaluation = async () => { while (hasMore) { try { - const releaseTargets = await db.query.releaseTarget.findMany({ - limit: PAGE_SIZE, - offset, - }); + const ct = alias(schema.computedPolicyTargetReleaseTarget, "ct"); + + const releaseTargets = await db + .select() + .from(schema.policy) + .innerJoin( + schema.policyTarget, + eq(schema.policyTarget.policyId, schema.policy.id), + ) + .innerJoin(ct, eq(ct.policyTargetId, schema.policyTarget.id)) + .innerJoin( + schema.releaseTarget, + eq(ct.releaseTargetId, schema.releaseTarget.id), + ) + .innerJoin( + schema.policyRuleGradualRollout, + eq(schema.policyRuleGradualRollout.policyId, schema.policy.id), + ) + .limit(PAGE_SIZE) + .offset(offset) + .then((rows) => rows.map((row) => row.release_target)); if (releaseTargets.length === 0) { hasMore = false; @@ -26,15 +46,15 @@ const triggerPolicyEvaluation = async () => { ); totalProcessed += releaseTargets.length; - // await getQueue(Channel.EvaluateReleaseTarget).addBulk( - // releaseTargets.map((rt) => ({ - // name: `${rt.resourceId}-${rt.environmentId}-${rt.deploymentId}`, - // data: rt, - // priority: 10, - // })), - // ); - offset += PAGE_SIZE; + + await getQueue(Channel.EvaluateReleaseTarget).addBulk( + releaseTargets.map((rt) => ({ + name: `${rt.resourceId}-${rt.environmentId}-${rt.deploymentId}`, + data: rt, + priority: 10, + })), + ); } catch (error) { logger.error("Error during policy evaluation:", error); throw error; diff --git a/apps/webservice/src/app/api/v1/deployment-versions/[deploymentVersionId]/releases/openapi.ts b/apps/webservice/src/app/api/v1/deployment-versions/[deploymentVersionId]/releases/openapi.ts new file mode 100644 index 000000000..dffbf8ea0 --- /dev/null +++ b/apps/webservice/src/app/api/v1/deployment-versions/[deploymentVersionId]/releases/openapi.ts @@ -0,0 +1,79 @@ +import type { Swagger } from "atlassian-openapi"; + +export const openapi: Swagger.SwaggerV3 = { + openapi: "3.0.0", + info: { + title: "Ctrlplane API", + version: "1.0.0", + }, + components: { + schemas: { + Release: { + type: "object", + properties: { + resource: { $ref: "#/components/schemas/Resource" }, + environment: { $ref: "#/components/schemas/Environment" }, + deployment: { $ref: "#/components/schemas/Deployment" }, + version: { $ref: "#/components/schemas/DeploymentVersion" }, + variables: { type: "object", additionalProperties: true }, + }, + }, + }, + }, + paths: { + "/v1/deployment-versions/{deploymentVersionId}/releases": { + get: { + summary: "Get all releases for a deployment version", + parameters: [ + { + name: "deploymentVersionId", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + }, + ], + responses: { + 200: { + description: "OK", + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Release" }, + }, + }, + }, + }, + 404: { + description: "Not Found", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { type: "string" }, + }, + required: ["error"], + }, + }, + }, + }, + 500: { + description: "Internal Server Error", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { type: "string" }, + }, + required: ["error"], + }, + }, + }, + }, + }, + }, + }, + }, +}; diff --git a/apps/webservice/src/app/api/v1/deployment-versions/[deploymentVersionId]/releases/route.ts b/apps/webservice/src/app/api/v1/deployment-versions/[deploymentVersionId]/releases/route.ts new file mode 100644 index 000000000..2526a2b61 --- /dev/null +++ b/apps/webservice/src/app/api/v1/deployment-versions/[deploymentVersionId]/releases/route.ts @@ -0,0 +1,113 @@ +import type { Tx } from "@ctrlplane/db"; +import { NextResponse } from "next/server"; +import { NOT_FOUND } from "http-status"; + +import { eq, sql } from "@ctrlplane/db"; +import * as SCHEMA from "@ctrlplane/db/schema"; +import { Permission } from "@ctrlplane/validators/auth"; + +import { authn, authz } from "~/app/api/v1/auth"; +import { request } from "~/app/api/v1/middleware"; + +export const GET = request() + .use(authn) + .use( + authz(({ can, params }) => + can.perform(Permission.DeploymentVersionGet).on({ + type: "deploymentVersion", + id: params.deploymentVersionId ?? "", + }), + ), + ) + .handle<{ db: Tx }, { params: Promise<{ deploymentVersionId: string }> }>( + async ({ db }, { params }) => { + const { deploymentVersionId } = await params; + + const deploymentVersion = await db.query.deploymentVersion.findFirst({ + where: eq(SCHEMA.deploymentVersion.id, deploymentVersionId), + with: { metadata: true }, + }); + + if (deploymentVersion == null) + return NextResponse.json( + { error: "Deployment version not found" }, + { status: NOT_FOUND }, + ); + + const variableReleaseSubquery = db + .select({ + variableSetReleaseId: SCHEMA.variableSetRelease.id, + variables: sql>`COALESCE(jsonb_object_agg( + ${SCHEMA.variableValueSnapshot.key}, + ${SCHEMA.variableValueSnapshot.value} + ) FILTER (WHERE ${SCHEMA.variableValueSnapshot.id} IS NOT NULL), '{}'::jsonb)`.as( + "variables", + ), + }) + .from(SCHEMA.variableSetRelease) + .leftJoin( + SCHEMA.variableSetReleaseValue, + eq( + SCHEMA.variableSetRelease.id, + SCHEMA.variableSetReleaseValue.variableSetReleaseId, + ), + ) + .leftJoin( + SCHEMA.variableValueSnapshot, + eq( + SCHEMA.variableSetReleaseValue.variableValueSnapshotId, + SCHEMA.variableValueSnapshot.id, + ), + ) + .groupBy(SCHEMA.variableSetRelease.id) + .as("variableRelease"); + + const releases = await db + .select() + .from(SCHEMA.release) + .innerJoin( + SCHEMA.versionRelease, + eq(SCHEMA.release.versionReleaseId, SCHEMA.versionRelease.id), + ) + .innerJoin( + variableReleaseSubquery, + eq( + SCHEMA.release.variableReleaseId, + variableReleaseSubquery.variableSetReleaseId, + ), + ) + .innerJoin( + SCHEMA.releaseTarget, + eq(SCHEMA.versionRelease.releaseTargetId, SCHEMA.releaseTarget.id), + ) + .innerJoin( + SCHEMA.resource, + eq(SCHEMA.releaseTarget.resourceId, SCHEMA.resource.id), + ) + .innerJoin( + SCHEMA.environment, + eq(SCHEMA.releaseTarget.environmentId, SCHEMA.environment.id), + ) + .innerJoin( + SCHEMA.deployment, + eq(SCHEMA.releaseTarget.deploymentId, SCHEMA.deployment.id), + ) + .where(eq(SCHEMA.versionRelease.versionId, deploymentVersionId)) + .limit(500); + + const fullReleases = releases.map((release) => ({ + resource: release.resource, + environment: release.environment, + deployment: release.deployment, + version: { + ...deploymentVersion, + metadata: Object.fromEntries( + deploymentVersion.metadata.map((m) => [m.key, m.value]), + ), + }, + variables: release.variableRelease.variables, + })); + + return NextResponse.json(fullReleases); + }, + ); diff --git a/apps/webservice/src/app/api/v1/policies/[policyId]/openapi.ts b/apps/webservice/src/app/api/v1/policies/[policyId]/openapi.ts index 656eff368..1ac00291d 100644 --- a/apps/webservice/src/app/api/v1/policies/[policyId]/openapi.ts +++ b/apps/webservice/src/app/api/v1/policies/[policyId]/openapi.ts @@ -77,6 +77,10 @@ export const openapi: Swagger.SwaggerV3 = { required: ["roleId"], }, }, + gradualRollout: { + $ref: "#/components/schemas/GradualRollout", + nullable: true, + }, }, }, }, diff --git a/apps/webservice/src/app/api/v1/policies/openapi.ts b/apps/webservice/src/app/api/v1/policies/openapi.ts index 2559c1f2f..e74b54f1d 100644 --- a/apps/webservice/src/app/api/v1/policies/openapi.ts +++ b/apps/webservice/src/app/api/v1/policies/openapi.ts @@ -67,6 +67,16 @@ export const openapi: Swagger.SwaggerV3 = { }, required: ["roleId", "requiredApprovalsCount"], }, + GradualRollout: { + type: "object", + properties: { + deployRate: { type: "number" }, + windowSizeMinutes: { type: "number" }, + name: { type: "string" }, + description: { type: "string" }, + }, + required: ["deployRate", "windowSizeMinutes", "name"], + }, Policy: { type: "object", properties: { @@ -99,6 +109,10 @@ export const openapi: Swagger.SwaggerV3 = { type: "array", items: { $ref: "#/components/schemas/VersionRoleApproval" }, }, + gradualRollout: { + $ref: "#/components/schemas/GradualRollout", + nullable: true, + }, }, required: [ "id", @@ -111,6 +125,7 @@ export const openapi: Swagger.SwaggerV3 = { "denyWindows", "versionUserApprovals", "versionRoleApprovals", + "gradualRollout", ], }, }, @@ -166,6 +181,9 @@ export const openapi: Swagger.SwaggerV3 = { $ref: "#/components/schemas/VersionRoleApproval", }, }, + gradualRollout: { + $ref: "#/components/schemas/GradualRollout", + }, }, required: ["name", "workspaceId", "targets"], }, diff --git a/e2e/api/schema.ts b/e2e/api/schema.ts index 3a03f4759..f141be287 100644 --- a/e2e/api/schema.ts +++ b/e2e/api/schema.ts @@ -58,6 +58,66 @@ export interface paths { patch: operations["updateDeploymentVersion"]; trace?: never; }; + "/v1/deployment-versions/{deploymentVersionId}/releases": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get all releases for a deployment version */ + get: { + parameters: { + query?: never; + header?: never; + path: { + deploymentVersionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Release"][]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/deployment-versions": { parameters: { query?: never; @@ -910,6 +970,15 @@ export interface components { */ longitude: number; }; + Release: { + resource?: components["schemas"]["Resource"]; + environment?: components["schemas"]["Environment"]; + deployment?: components["schemas"]["Deployment"]; + version?: components["schemas"]["DeploymentVersion"]; + variables?: { + [key: string]: unknown; + }; + }; BaseVariableValue: { resourceSelector?: { [key: string]: unknown; @@ -1036,7 +1105,7 @@ export interface components { } & (WithRequired & { [key: string]: unknown; }); - Release: { + Release1: { /** Format: uuid */ id: string; name: string; @@ -1290,6 +1359,12 @@ export interface components { roleId: string; requiredApprovalsCount: number; }; + GradualRollout: { + deployRate: number; + windowSizeMinutes: number; + name: string; + description?: string; + }; Policy1: { /** Format: uuid */ id: string; @@ -1304,9 +1379,10 @@ export interface components { targets: components["schemas"]["PolicyTarget"][]; denyWindows: components["schemas"]["DenyWindow"][]; deploymentVersionSelector?: components["schemas"]["DeploymentVersionSelector"]; - versionAnyApprovals?: components["schemas"]["VersionAnyApproval"][]; + versionAnyApprovals?: components["schemas"]["VersionAnyApproval"]; versionUserApprovals: components["schemas"]["VersionUserApproval"][]; versionRoleApprovals: components["schemas"]["VersionRoleApproval"][]; + gradualRollout: components["schemas"]["GradualRollout"]; }; UpdateResourceRelationshipRule: { name?: string; @@ -1993,11 +2069,7 @@ export interface operations { config: { [key: string]: unknown; }; - values?: (components["schemas"]["VariableValue"] & { - resourceSelector?: { - [key: string]: unknown; - } | null; - })[]; + values?: components["schemas"]["VariableValue"][]; }; }; }; @@ -2626,6 +2698,7 @@ export interface operations { roleId: string; requiredApprovalsCount?: number; }[]; + gradualRollout?: components["schemas"]["GradualRollout"]; }; }; }; @@ -2725,14 +2798,10 @@ export interface operations { dtend?: string; }[]; deploymentVersionSelector?: components["schemas"]["DeploymentVersionSelector"]; - versionAnyApprovals?: { - requiredApprovalsCount?: number; - }[]; + versionAnyApprovals?: components["schemas"]["VersionAnyApproval"]; versionUserApprovals?: components["schemas"]["VersionUserApproval"][]; - versionRoleApprovals?: { - roleId: string; - requiredApprovalsCount?: number; - }[]; + versionRoleApprovals?: components["schemas"]["VersionRoleApproval"][]; + gradualRollout?: components["schemas"]["GradualRollout"]; }; }; }; diff --git a/e2e/tests/api/policies/gradual-rollout.spec.ts b/e2e/tests/api/policies/gradual-rollout.spec.ts new file mode 100644 index 000000000..bae125248 --- /dev/null +++ b/e2e/tests/api/policies/gradual-rollout.spec.ts @@ -0,0 +1,106 @@ +import path from "path"; +import { faker } from "@faker-js/faker"; +import { expect } from "@playwright/test"; + +import { + cleanupImportedEntities, + ImportedEntities, + importEntitiesFromYaml, +} from "../../../api"; +import { test } from "../../fixtures"; + +const TEN_MINUTES = 10 * 60 * 1_000; + +const yamlPath = path.join(__dirname, "gradual-rollout.spec.yaml"); + +test.describe("Gradual Rollout", () => { + let importedEntities: ImportedEntities; + test.setTimeout(TEN_MINUTES); + + test.beforeAll(async ({ api, workspace }) => { + importedEntities = await importEntitiesFromYaml( + api, + workspace.id, + yamlPath, + ); + + await new Promise((resolve) => setTimeout(resolve, 1_000)); + }); + + test.afterAll(async ({ api, workspace }) => { + await cleanupImportedEntities(api, importedEntities, workspace.id); + }); + + test("should create a gradual rollout policy", async ({ + api, + workspace, + page, + }) => { + const { system } = importedEntities; + + const deploymentSlug = faker.string.alphanumeric(10); + + const deploymentResponse = await api.POST("/v1/deployments", { + body: { + systemId: system.id, + name: "Gradual Rollout Deployment", + slug: deploymentSlug, + description: "Gradual Rollout Deployment", + }, + }); + expect(deploymentResponse.response.status).toBe(201); + + const policyResponse = await api.POST("/v1/policies", { + body: { + name: faker.string.alphanumeric(10), + workspaceId: workspace.id, + targets: [ + { + deploymentSelector: { + type: "slug", + operator: "equals", + value: deploymentSlug, + }, + }, + ], + gradualRollout: { + deployRate: 1, + windowSizeMinutes: 1, + name: faker.string.alphanumeric(10), + }, + }, + }); + expect(policyResponse.response.status).toBe(200); + + const versionTag = faker.string.alphanumeric(10); + const versionResponse = await api.POST("/v1/deployment-versions", { + body: { + deploymentId: deploymentResponse.data?.id ?? "", + tag: versionTag, + }, + }); + expect(versionResponse.response.status).toBe(201); + + await page.waitForTimeout(1_000); + + let expectedReleaseTargets = 1; + + for (let i = 0; i < 5; i++) { + const releaseResponse = await api.GET( + `/v1/deployment-versions/{deploymentVersionId}/releases`, + { + params: { + path: { + deploymentVersionId: versionResponse.data?.id ?? "", + }, + }, + }, + ); + expect(releaseResponse.response.status).toBe(200); + expect(releaseResponse.data?.length).toBe(expectedReleaseTargets); + expectedReleaseTargets++; + + await page.waitForTimeout(60_000); + } + }); +}); diff --git a/e2e/tests/api/policies/gradual-rollout.spec.yaml b/e2e/tests/api/policies/gradual-rollout.spec.yaml new file mode 100644 index 000000000..f7e932a7d --- /dev/null +++ b/e2e/tests/api/policies/gradual-rollout.spec.yaml @@ -0,0 +1,104 @@ +system: + name: "{{ prefix }}-gradual-rollout" + slug: "{{ prefix }}-gradual-rollout" + description: "Gradual Rollout System" + +environments: + - name: "{{ prefix }}-prod" + description: "Prod environment" + systemId: "{{ prefix }}-gradual-rollout" + resourceSelector: + type: identifier + operator: contains + value: "{{ prefix }}" + +resources: + - name: "{{ prefix }}-1" + kind: service + identifier: "{{ prefix }}-1" + version: 1.0.0 + config: + enabled: true + metadata: + env: prod + + - name: "{{ prefix }}-2" + kind: service + identifier: "{{ prefix }}-2" + version: 1.0.0 + config: + enabled: true + metadata: + env: qa + + - name: "{{ prefix }}-3" + kind: service + identifier: "{{ prefix }}-3" + version: 1.0.0 + config: + enabled: true + metadata: + env: prod + + - name: "{{ prefix }}-4" + kind: service + identifier: "{{ prefix }}-4" + version: 1.0.0 + config: + enabled: true + metadata: + env: qa + + - name: "{{ prefix }}-5" + kind: service + identifier: "{{ prefix }}-5" + version: 1.0.0 + config: + enabled: true + metadata: + env: prod + + - name: "{{ prefix }}-6" + kind: service + identifier: "{{ prefix }}-6" + version: 1.0.0 + config: + enabled: true + metadata: + env: qa + + - name: "{{ prefix }}-7" + kind: service + identifier: "{{ prefix }}-7" + version: 1.0.0 + config: + enabled: true + metadata: + env: prod + + - name: "{{ prefix }}-8" + kind: service + identifier: "{{ prefix }}-8" + version: 1.0.0 + config: + enabled: true + metadata: + env: qa + + - name: "{{ prefix }}-9" + kind: service + identifier: "{{ prefix }}-9" + version: 1.0.0 + config: + enabled: true + metadata: + env: prod + + - name: "{{ prefix }}-10" + kind: service + identifier: "{{ prefix }}-10" + version: 1.0.0 + config: + enabled: true + metadata: + env: qa diff --git a/openapi.v1.json b/openapi.v1.json index 9229590c4..894975e66 100644 --- a/openapi.v1.json +++ b/openapi.v1.json @@ -328,6 +328,73 @@ } } }, + "/v1/deployment-versions/{deploymentVersionId}/releases": { + "get": { + "summary": "Get all releases for a deployment version", + "parameters": [ + { + "name": "deploymentVersionId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Release" + } + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + }, + "required": [ + "error" + ] + } + } + } + } + } + } + }, "/v1/deployment-versions": { "post": { "summary": "Upserts a deployment version", @@ -1901,6 +1968,10 @@ "roleId" ] } + }, + "gradualRollout": { + "$ref": "#/components/schemas/GradualRollout", + "nullable": true } } } @@ -2124,6 +2195,9 @@ "items": { "$ref": "#/components/schemas/VersionRoleApproval" } + }, + "gradualRollout": { + "$ref": "#/components/schemas/GradualRollout" } }, "required": [ @@ -4742,6 +4816,27 @@ } } }, + "Release": { + "type": "object", + "properties": { + "resource": { + "$ref": "#/components/schemas/Resource" + }, + "environment": { + "$ref": "#/components/schemas/Environment" + }, + "deployment": { + "$ref": "#/components/schemas/Deployment" + }, + "version": { + "$ref": "#/components/schemas/DeploymentVersion" + }, + "variables": { + "type": "object", + "additionalProperties": true + } + } + }, "BaseVariableValue": { "type": "object", "properties": { @@ -5134,7 +5229,7 @@ ], "additionalProperties": true }, - "Release": { + "Release1": { "type": "object", "properties": { "id": { @@ -5809,6 +5904,28 @@ "requiredApprovalsCount" ] }, + "GradualRollout": { + "type": "object", + "properties": { + "deployRate": { + "type": "number" + }, + "windowSizeMinutes": { + "type": "number" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "deployRate", + "windowSizeMinutes", + "name" + ] + }, "Policy1": { "type": "object", "properties": { @@ -5865,6 +5982,10 @@ "items": { "$ref": "#/components/schemas/VersionRoleApproval" } + }, + "gradualRollout": { + "$ref": "#/components/schemas/GradualRollout", + "nullable": true } }, "required": [ @@ -5877,7 +5998,8 @@ "targets", "denyWindows", "versionUserApprovals", - "versionRoleApprovals" + "versionRoleApprovals", + "gradualRollout" ] }, "UpdateResourceRelationshipRule": { diff --git a/packages/api/src/router/policy/evaluate.ts b/packages/api/src/router/policy/evaluate.ts index 3998254ac..a8eb0c537 100644 --- a/packages/api/src/router/policy/evaluate.ts +++ b/packages/api/src/router/policy/evaluate.ts @@ -59,6 +59,7 @@ const getApplicablePoliciesWithoutResourceScope = async ( versionAnyApprovals: true, versionRoleApprovals: true, versionUserApprovals: true, + gradualRollout: true, }, }); }; diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 561885d4c..9b6ef9c57 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -745,4 +745,4 @@ "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/src/schema/policy-relations.ts b/packages/db/src/schema/policy-relations.ts index 2ea5d8662..bc66cb5ab 100644 --- a/packages/db/src/schema/policy-relations.ts +++ b/packages/db/src/schema/policy-relations.ts @@ -10,6 +10,7 @@ import { policyRuleAnyApproval, policyRuleDenyWindow, policyRuleDeploymentVersionSelector, + policyRuleGradualRollout, policyRuleRoleApproval, policyRuleUserApproval, } from "./rules/index.js"; @@ -24,6 +25,8 @@ export const policyRelations = relations(policy, ({ many, one }) => ({ denyWindows: many(policyRuleDenyWindow), deploymentVersionSelector: one(policyRuleDeploymentVersionSelector), + gradualRollout: one(policyRuleGradualRollout), + versionUserApprovals: many(policyRuleUserApproval), versionRoleApprovals: many(policyRuleRoleApproval), versionAnyApprovals: one(policyRuleAnyApproval), diff --git a/packages/db/src/schema/policy.ts b/packages/db/src/schema/policy.ts index 311db3de1..15c0e81f1 100644 --- a/packages/db/src/schema/policy.ts +++ b/packages/db/src/schema/policy.ts @@ -37,6 +37,7 @@ import { createPolicyRuleRoleApproval } from "./rules/approval-role.js"; import { createPolicyRuleUserApproval } from "./rules/approval-user.js"; import { createPolicyRuleDenyWindow } from "./rules/deny-window.js"; import { createPolicyRuleDeploymentVersionSelector } from "./rules/deployment-selector.js"; +import { createPolicyRuleGradualRollout } from "./rules/gradual-rollout.js"; import { workspace } from "./workspace.js"; export const policy = pgTable( @@ -147,6 +148,10 @@ export const createPolicy = z.intersection( .array(createPolicyRuleRoleApproval.omit({ policyId: true })) .optional() .nullable(), + gradualRollout: createPolicyRuleGradualRollout + .omit({ policyId: true }) + .optional() + .nullable(), }), ); export type CreatePolicy = z.infer; @@ -172,6 +177,10 @@ export const updatePolicy = policyInsertSchema.partial().extend({ versionRoleApprovals: z .array(createPolicyRuleRoleApproval.omit({ policyId: true })) .optional(), + gradualRollout: createPolicyRuleGradualRollout + .omit({ policyId: true }) + .optional() + .nullable(), }); export type UpdatePolicy = z.infer; diff --git a/packages/db/src/schema/rules/gradual-rollout.ts b/packages/db/src/schema/rules/gradual-rollout.ts new file mode 100644 index 000000000..82f391bd9 --- /dev/null +++ b/packages/db/src/schema/rules/gradual-rollout.ts @@ -0,0 +1,30 @@ +import { integer, pgTable, text, uuid } from "drizzle-orm/pg-core"; +import { createInsertSchema } from "drizzle-zod"; +import { z } from "zod"; + +import { policy } from "../policy.js"; + +export const policyRuleGradualRollout = pgTable("policy_rule_gradual_rollout", { + id: uuid("id").primaryKey().defaultRandom(), + policyId: uuid("policy_id") + .notNull() + .unique() + .references(() => policy.id, { onDelete: "cascade" }), + + name: text("name").notNull(), + description: text("description"), + + deployRate: integer("deploy_rate").notNull(), + windowSizeMinutes: integer("window_size_minutes").notNull(), +}); + +export const createPolicyRuleGradualRollout = createInsertSchema( + policyRuleGradualRollout, + { + policyId: z.string().uuid(), + deployRate: z.number().min(1), + windowSizeMinutes: z.number().min(1), + }, +).omit({ id: true }); + +export type PolicyGradualRollout = typeof policyRuleGradualRollout.$inferSelect; diff --git a/packages/db/src/schema/rules/index.ts b/packages/db/src/schema/rules/index.ts index 04efaff96..785453731 100644 --- a/packages/db/src/schema/rules/index.ts +++ b/packages/db/src/schema/rules/index.ts @@ -9,3 +9,4 @@ export * from "./approval-role.js"; export * from "./approval-any.js"; export * from "./rule-relations.js"; export * from "./deployment-selector.js"; +export * from "./gradual-rollout.js"; diff --git a/packages/db/src/schema/rules/rule-relations.ts b/packages/db/src/schema/rules/rule-relations.ts index 3ae6af698..061f3b6e2 100644 --- a/packages/db/src/schema/rules/rule-relations.ts +++ b/packages/db/src/schema/rules/rule-relations.ts @@ -17,6 +17,7 @@ import { } from "./approval-user.js"; import { policyRuleDenyWindow } from "./deny-window.js"; import { policyRuleDeploymentVersionSelector } from "./deployment-selector.js"; +import { policyRuleGradualRollout } from "./gradual-rollout.js"; // User relations to approval records export const userApprovalRelations = relations(user, ({ many }) => ({ @@ -127,3 +128,13 @@ export const policyDeploymentVersionSelectorRelations = relations( }), }), ); + +export const policyRuleGradualRolloutRelations = relations( + policyRuleGradualRollout, + ({ one }) => ({ + policy: one(policy, { + fields: [policyRuleGradualRollout.policyId], + references: [policy.id], + }), + }), +); diff --git a/packages/rule-engine/src/db/create-policy.ts b/packages/rule-engine/src/db/create-policy.ts index 8c0127310..c6a7b1c61 100644 --- a/packages/rule-engine/src/db/create-policy.ts +++ b/packages/rule-engine/src/db/create-policy.ts @@ -92,6 +92,7 @@ export const createPolicyInTx = async (tx: Tx, input: CreatePolicyInput) => { versionAnyApprovals, versionUserApprovals, versionRoleApprovals, + gradualRollout, ...rest } = input; @@ -177,6 +178,11 @@ export const createPolicyInTx = async (tx: Tx, input: CreatePolicyInput) => { ]), }); + if (gradualRollout != null) + await tx + .insert(SCHEMA.policyRuleGradualRollout) + .values({ ...gradualRollout, policyId }); + return { ...policy, targets, diff --git a/packages/rule-engine/src/db/get-applicable-policies.ts b/packages/rule-engine/src/db/get-applicable-policies.ts index 272e08129..f4bbadb01 100644 --- a/packages/rule-engine/src/db/get-applicable-policies.ts +++ b/packages/rule-engine/src/db/get-applicable-policies.ts @@ -40,6 +40,7 @@ export const getApplicablePolicies = withSpan( versionAnyApprovals: true, versionRoleApprovals: true, versionUserApprovals: true, + gradualRollout: true, }, }, }, diff --git a/packages/rule-engine/src/db/update-policy.ts b/packages/rule-engine/src/db/update-policy.ts index b07b40ba9..e2b6daae3 100644 --- a/packages/rule-engine/src/db/update-policy.ts +++ b/packages/rule-engine/src/db/update-policy.ts @@ -136,6 +136,23 @@ const updateVersionRoleApprovals = async ( ); }; +const updateGradualRollout = async ( + tx: Tx, + policyId: string, + gradualRollout: SCHEMA.UpdatePolicy["gradualRollout"], +) => { + if (gradualRollout === undefined) return; + if (gradualRollout === null) + return tx + .delete(SCHEMA.policyRuleGradualRollout) + .where(eq(SCHEMA.policyRuleGradualRollout.policyId, policyId)); + + await tx.update(SCHEMA.policyRuleGradualRollout).set({ + ...gradualRollout, + policyId, + }); +}; + export const updatePolicyInTx = async ( tx: Tx, id: string, @@ -148,6 +165,7 @@ export const updatePolicyInTx = async ( versionAnyApprovals, versionUserApprovals, versionRoleApprovals, + gradualRollout, ...rest } = input; @@ -172,6 +190,7 @@ export const updatePolicyInTx = async ( updateVersionAnyApprovals(tx, policy.id, versionAnyApprovals), updateVersionUserApprovals(tx, policy.id, versionUserApprovals), updateVersionRoleApprovals(tx, policy.id, versionRoleApprovals), + updateGradualRollout(tx, policy.id, gradualRollout), ]); const updatedPolicy = await tx.query.policy.findFirst({ @@ -183,6 +202,7 @@ export const updatePolicyInTx = async ( versionAnyApprovals: true, versionUserApprovals: true, versionRoleApprovals: true, + gradualRollout: true, }, }); diff --git a/packages/rule-engine/src/manager/version-manager-rules.ts b/packages/rule-engine/src/manager/version-manager-rules.ts index 8a361f3a9..7881ca3ff 100644 --- a/packages/rule-engine/src/manager/version-manager-rules.ts +++ b/packages/rule-engine/src/manager/version-manager-rules.ts @@ -1,6 +1,17 @@ +import _ from "lodash"; +import { isPresent } from "ts-is-present"; + +import { and, eq, sql, takeFirst } from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import * as schema from "@ctrlplane/db/schema"; + import type { FilterRule, Policy, PreValidationRule } from "../types"; import type { Version } from "./version-rule-engine"; import { DeploymentDenyRule } from "../rules/deployment-deny-rule.js"; +import { + EnvironmentVersionRolloutRule, + linearDeploymentOffset, +} from "../rules/environment-version-rollout-rule.js"; import { ReleaseTargetConcurrencyRule } from "../rules/release-target-concurrency-rule.js"; import { getAnyApprovalRecords, @@ -67,6 +78,88 @@ export const getVersionApprovalRules = ( ...versionRoleApprovalRule(policy?.versionRoleApprovals), ]; +export const gradualRolloutRule = ( + policy: Policy | null, + releaseTargetId: string, +) => { + if (policy?.gradualRollout == null) return null; + const getRolloutStartTime = async (version: Version) => { + const versionApprovalRules = getVersionApprovalRules(policy); + if (versionApprovalRules.length === 0) return version.createdAt; + + // Check if version passes all approval rules + const allApprovalsPassed = await versionApprovalRules.reduce( + async (passedSoFar, rule) => { + if (!(await passedSoFar)) return false; + const { allowedCandidates } = await rule.filter([version]); + return allowedCandidates.length > 0; + }, + Promise.resolve(true), + ); + + if (!allApprovalsPassed) return null; + + // Get most recent approval timestamp + const allApprovalRecords = [ + ...(await getAnyApprovalRecords([version.id])), + ...(await getUserApprovalRecords([version.id])), + ...(await getRoleApprovalRecords([version.id])), + ]; + + const latestApprovalRecord = _.chain(allApprovalRecords) + .filter((record) => record.approvedAt != null) + .maxBy((record) => record.approvedAt) + .value(); + + return latestApprovalRecord.approvedAt ?? version.createdAt; + }; + + const getReleaseTargetPosition = async (version: Version) => { + const releaseTarget = await db.query.releaseTarget.findFirst({ + where: eq(schema.releaseTarget.id, releaseTargetId), + }); + + if (releaseTarget == null) + throw new Error(`Release target ${releaseTargetId} not found`); + + const orderedTargetsSubquery = db + .select({ + id: schema.releaseTarget.id, + position: + sql`ROW_NUMBER() OVER (ORDER BY md5(id || ${version.id}) ASC) - 1`.as( + "position", + ), + }) + .from(schema.releaseTarget) + .where( + and( + eq(schema.releaseTarget.environmentId, releaseTarget.environmentId), + eq(schema.releaseTarget.deploymentId, releaseTarget.deploymentId), + ), + ) + .as("ordered_targets"); + + return db + .select() + .from(orderedTargetsSubquery) + .where(eq(orderedTargetsSubquery.id, releaseTargetId)) + .then(takeFirst) + .then((r) => r.position); + }; + + const getDeploymentOffsetMinutes = linearDeploymentOffset( + policy.environmentVersionRollout.positionGrowthFactor, + policy.environmentVersionRollout.timeScaleInterval, + ); + + return new EnvironmentVersionRolloutRule({ + ...policy.gradualRollout, + getRolloutStartTime, + getReleaseTargetPosition, + getDeploymentOffsetMinutes, + }); +}; + export const getRules = ( policy: Policy | null, releaseTargetId: string, @@ -74,7 +167,8 @@ export const getRules = ( return [ new ReleaseTargetConcurrencyRule(releaseTargetId), ...getVersionApprovalRules(policy), - ]; + gradualRolloutRule(policy, releaseTargetId), + ].filter(isPresent); // The rrule package is being stupid and deny windows is not top priority // right now so I am commenting this out // https://github.com/jkbrzt/rrule/issues/478 diff --git a/packages/rule-engine/src/rules/environment-version-rollout-rule.ts b/packages/rule-engine/src/rules/environment-version-rollout-rule.ts new file mode 100644 index 000000000..312f3e233 --- /dev/null +++ b/packages/rule-engine/src/rules/environment-version-rollout-rule.ts @@ -0,0 +1,74 @@ +import { addMinutes, isAfter, isEqual, startOfMinute } from "date-fns"; + +import type { Version } from "../manager/version-rule-engine"; +import type { FilterRule, RuleEngineRuleResult } from "../types"; + +type GetDeploymentOffsetMinutes = (targetPosition: number) => number; + +export const linearDeploymentOffset = + ( + positionGrowthFactor: number, + timeScaleInterval: number, + ): GetDeploymentOffsetMinutes => + (x: number) => + timeScaleInterval * (x / positionGrowthFactor); + +export const exponentialDeploymentOffset = + ( + positionGrowthFactor: number, + timeScaleInterval: number, + ): GetDeploymentOffsetMinutes => + (x: number) => + timeScaleInterval * (1 - Math.exp(-1 * (x / positionGrowthFactor))); + +type EnvironmentVersionRolloutRuleOptions = { + getRolloutStartTime: (version: Version) => Date | Promise | null; + getReleaseTargetPosition: (version: Version) => number | Promise; + getDeploymentOffsetMinutes: GetDeploymentOffsetMinutes; + skipReason?: string; +}; + +export class EnvironmentVersionRolloutRule implements FilterRule { + public readonly name = "EnvironmentVersionRolloutRule"; + + constructor(private readonly options: EnvironmentVersionRolloutRuleOptions) {} + + protected getCurrentTime() { + return new Date(); + } + + async getDeploymentTime(version: Version, startTime: Date) { + const targetPosition = await this.options.getReleaseTargetPosition(version); + const minutes = this.options.getDeploymentOffsetMinutes(targetPosition); + return addMinutes(startOfMinute(startTime), minutes); + } + + async filter(candidates: Version[]): Promise> { + const now = this.getCurrentTime(); + const rejectionReasons = new Map(); + const skip = + this.options.skipReason ?? + "Version not eligible for deployment in the current time window"; + + for (const candidate of candidates) { + const startTime = await this.options.getRolloutStartTime(candidate); + if (startTime == null) { + rejectionReasons.set( + candidate.id, + "Rollout has not started for this version", + ); + continue; + } + const deploymentTime = await this.getDeploymentTime(candidate, startTime); + + const isEligible = + isAfter(now, deploymentTime) || isEqual(now, deploymentTime); + if (!isEligible) rejectionReasons.set(candidate.id, skip); + } + + return { + allowedCandidates: candidates.filter((c) => !rejectionReasons.has(c.id)), + rejectionReasons, + }; + } +} diff --git a/packages/rule-engine/src/rules/version-approval-rule.ts b/packages/rule-engine/src/rules/version-approval-rule.ts index 50f36a128..7eb115861 100644 --- a/packages/rule-engine/src/rules/version-approval-rule.ts +++ b/packages/rule-engine/src/rules/version-approval-rule.ts @@ -12,6 +12,7 @@ type Record = { status: "approved" | "rejected"; userId: string; reason: string | null; + approvedAt: Date | null; }; export type GetApprovalRecordsFunc = ( diff --git a/packages/rule-engine/src/types.ts b/packages/rule-engine/src/types.ts index 03cbb7060..1387d6f4c 100644 --- a/packages/rule-engine/src/types.ts +++ b/packages/rule-engine/src/types.ts @@ -69,6 +69,7 @@ export type Policy = schema.Policy & { versionAnyApprovals: schema.PolicyRuleAnyApproval | null; versionUserApprovals: schema.PolicyRuleUserApproval[]; versionRoleApprovals: schema.PolicyRuleRoleApproval[]; + gradualRollout: schema.PolicyGradualRollout | null; }; export type ReleaseTargetIdentifier = {