From 407ecf07d8a65fdde6d0a6c6062c8545c2d6bebf Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sat, 29 Mar 2025 18:50:55 -0400 Subject: [PATCH 01/22] init template --- .../db/src/schema/deployment-variables.ts | 43 +- packages/release-manager/README.md | 215 ++++++++++ packages/release-manager/eslint.config.js | 13 + packages/release-manager/package.json | 38 ++ .../src/db-variable-providers.tsx | 127 ++++++ packages/release-manager/src/index.ts | 5 + packages/release-manager/src/rules/index.ts | 2 + .../src/rules/variable-rules.ts | 148 +++++++ .../src/rules/version-rules.ts | 175 +++++++++ packages/release-manager/src/types.ts | 20 + packages/release-manager/src/variables.ts | 30 ++ .../test/release-manager.test.ts | 243 ++++++++++++ .../test/variable-resolution.test.ts | 368 ++++++++++++++++++ packages/release-manager/tsconfig.json | 11 + packages/release-manager/vitest.config.ts | 15 + pnpm-lock.yaml | 53 ++- 16 files changed, 1499 insertions(+), 7 deletions(-) create mode 100644 packages/release-manager/README.md create mode 100644 packages/release-manager/eslint.config.js create mode 100644 packages/release-manager/package.json create mode 100644 packages/release-manager/src/db-variable-providers.tsx create mode 100644 packages/release-manager/src/index.ts create mode 100644 packages/release-manager/src/rules/index.ts create mode 100644 packages/release-manager/src/rules/variable-rules.ts create mode 100644 packages/release-manager/src/rules/version-rules.ts create mode 100644 packages/release-manager/src/types.ts create mode 100644 packages/release-manager/src/variables.ts create mode 100644 packages/release-manager/test/release-manager.test.ts create mode 100644 packages/release-manager/test/variable-resolution.test.ts create mode 100644 packages/release-manager/tsconfig.json create mode 100644 packages/release-manager/vitest.config.ts diff --git a/packages/db/src/schema/deployment-variables.ts b/packages/db/src/schema/deployment-variables.ts index 455307470..f32883ada 100644 --- a/packages/db/src/schema/deployment-variables.ts +++ b/packages/db/src/schema/deployment-variables.ts @@ -2,7 +2,7 @@ import type { ResourceCondition } from "@ctrlplane/validators/resources"; import type { VariableConfigType } from "@ctrlplane/validators/variables"; import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; import type { AnyPgColumn, ColumnsWithTable } from "drizzle-orm/pg-core"; -import { sql } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; import { foreignKey, jsonb, @@ -116,3 +116,44 @@ export const deploymentVariableSet = pgTable( }, (t) => ({ uniq: uniqueIndex().on(t.deploymentId, t.variableSetId) }), ); + +export const deploymentVariableRelationships = relations( + deploymentVariable, + ({ one, many }) => ({ + deployment: one(deployment, { + fields: [deploymentVariable.deploymentId], + references: [deployment.id], + }), + + defaultValue: one(deploymentVariableValue, { + fields: [deploymentVariable.defaultValueId], + references: [deploymentVariableValue.id], + }), + + values: many(deploymentVariableValue), + }), +); + +export const deploymentVariableValueRelationships = relations( + deploymentVariableValue, + ({ one }) => ({ + variable: one(deploymentVariable, { + fields: [deploymentVariableValue.variableId], + references: [deploymentVariable.id], + }), + }), +); + +export const deploymentVariableSetRelationships = relations( + deploymentVariableSet, + ({ one }) => ({ + deployment: one(deployment, { + fields: [deploymentVariableSet.deploymentId], + references: [deployment.id], + }), + variableSet: one(variableSet, { + fields: [deploymentVariableSet.variableSetId], + references: [variableSet.id], + }), + }), +); diff --git a/packages/release-manager/README.md b/packages/release-manager/README.md new file mode 100644 index 000000000..e93f4dfd4 --- /dev/null +++ b/packages/release-manager/README.md @@ -0,0 +1,215 @@ +# @ctrlplane/release-manager + +A flexible and extensible framework for managing releases triggered by variable changes or version updates, with support for context-specific releases and variable resolution hierarchy. + +## Features + +- **Idempotent Release Creation**: Creates releases only when variables or versions actually change +- **Context-Specific Releases**: Create releases for specific resources, environments, and deployments +- **Variable Resolution Hierarchy**: Resolve variables with a priority order: resource variables > deployment variables > standard variables +- **Selector-Based Matching**: Use selectors to match deployments to resources +- **Flexible Storage**: Abstract storage interface with ready implementations for both in-memory (testing) and database storage +- **Rule Engine**: Process releases through customizable rules with conditions and actions +- **Strongly Typed**: Full TypeScript support with Zod validation schemas +- **Testable**: Built with testing in mind + +## Installation + +```bash +# From within the monorepo +pnpm add @ctrlplane/release-manager@workspace:* +``` + +## Usage + +### Variable Types and Resolution Hierarchy + +The framework supports three types of variables with a priority hierarchy: + +1. **Resource Variables** (highest priority): Specific to a resource and optionally an environment +2. **Deployment Variables** (medium priority): Associated with a deployment and matched to resources via selectors +3. **Standard Variables** (lowest priority): Global variables with no specific context + +When resolving a variable value, the framework checks each level in order and returns the highest priority value available. + +### Creating Context-Specific Releases + +```typescript +import { ReleaseManager, InMemoryReleaseStorage } from "@ctrlplane/release-manager"; +import { randomUUID } from "crypto"; + +// Setup storage and release manager +const storage = new InMemoryReleaseStorage(); +const releaseManager = new ReleaseManager({ + storage, + generateId: () => randomUUID(), +}); + +// Set up a resource +const resource = { + id: "app-server-1", + name: "Application Server", + labels: { type: "app", tier: "backend" }, + environmentId: "production", +}; + +// Set resource in storage +storage.setResources([resource]); + +// Create a resource-specific variable +const resourceVariable = { + id: "var-1", + type: "resourceVariable", + name: "API_URL", + value: "https://api.prod.example.com", + resourceId: "app-server-1", + environmentId: "production", + updatedAt: new Date(), +}; + +// Store the variable +storage.setResourceVariables([resourceVariable]); + +// Create a context for the release +const context = { + resourceId: "app-server-1", + environmentId: "production", + resource: resource +}; + +// Create a release for the variable in this specific context +const release = await releaseManager.createReleaseForVariable("API_URL", context); +console.log(`Release created: ${release.id}`); +``` + +### Working with Variable Resolution + +```typescript +// Set up various variable types +storage.setVariables([ + { + id: "var-global", + type: "variable", + name: "DEBUG", + value: false, + } +]); + +storage.setDeploymentVariables([ + { + id: "var-deploy", + type: "deploymentVariable", + name: "DEBUG", + value: true, + deploymentId: "backend-deploy", + selectors: [{ key: "type", value: "app" }] + } +]); + +// Resolve a variable in a specific context +const debugValue = await releaseManager.getVariable("DEBUG", context); +console.log(`Debug value: ${debugValue.value}`); // true (from deployment variable) + +// Get all resolved variables for a context +const allVars = await releaseManager.getVariablesForContext(context); +console.log(`Total variables: ${allVars.length}`); +``` + +### Creating Version Releases in Context + +```typescript +// Create a version +const version = { + id: "app-version", + version: "2.0.0", + updatedAt: new Date(), +}; + +// Store the version +storage.setVersions([version]); + +// Create a release for the version change in a specific context +const versionRelease = await releaseManager.createReleaseForVersion(version, context); +console.log(`Version release created: ${versionRelease.id}`); +``` + +### Context-Specific Rules + +```typescript +import { + RuleEngine, + ContextSpecificCondition, + VersionChangedCondition, + SemverCondition, + TriggerDeploymentAction, +} from "@ctrlplane/release-manager"; + +// Create a deployment service (mock) +const deploymentService = { + triggerDeployment: async (props) => { + console.log(`Deploying ${props.version} to ${props.resourceId} in ${props.environmentId}`); + } +}; + +// Create environment-specific rules +const ruleEngine = new RuleEngine({ + rules: [ + { + id: "prod-deploy-rule", + name: "Production Deployment Rule", + // Only trigger for production environment and major version changes + condition: { + async evaluate(props) { + const isProd = new ContextSpecificCondition( + undefined, "production" + ).evaluate(props); + + const isMajorVersion = new SemverCondition( + "^2.0.0" + ).evaluate(props); + + return (await isProd) && (await isMajorVersion); + } + }, + action: new TriggerDeploymentAction(deploymentService), + }, + ], +}); + +// Process the version release through rules +await ruleEngine.processRelease( + versionRelease, + undefined, + version, + context +); +``` + +### Database Integration + +```typescript +import { DatabaseReleaseStorage } from "@ctrlplane/release-manager"; + +// Assuming you have a database client from @ctrlplane/db +const dbClient = createDbClient(); + +// Create a database-backed storage +const dbStorage = new DatabaseReleaseStorage(dbClient); + +// Use the storage with release manager +const releaseManager = new ReleaseManager({ + storage: dbStorage, + generateId: () => randomUUID(), +}); +``` + +## Testing + +```bash +# Run the test suite +pnpm test +``` + +## License + +MIT diff --git a/packages/release-manager/eslint.config.js b/packages/release-manager/eslint.config.js new file mode 100644 index 000000000..d09a7dae7 --- /dev/null +++ b/packages/release-manager/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/release-manager/package.json b/packages/release-manager/package.json new file mode 100644 index 000000000..b39446483 --- /dev/null +++ b/packages/release-manager/package.json @@ -0,0 +1,38 @@ +{ + "name": "@ctrlplane/release-manager", + "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/release-manager/src/db-variable-providers.tsx b/packages/release-manager/src/db-variable-providers.tsx new file mode 100644 index 000000000..06a6e1ca9 --- /dev/null +++ b/packages/release-manager/src/db-variable-providers.tsx @@ -0,0 +1,127 @@ +import type { Tx } from "@ctrlplane/db"; + +import { and, eq, takeFirstOrNull } from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import { + deploymentVariable, + resource, + resourceMatchesMetadata, + resourceVariable, +} from "@ctrlplane/db/schema"; + +import type { MaybeVariable, Variable, VariableProvider } from "./types"; + +export type DatabaseResourceVariableOptions = { + resourceId: string; + db?: Tx; +}; + +export class DatabaseResourceVariableProvider implements VariableProvider { + private db: Tx; + private variables: Promise | null = null; + + constructor(private options: DatabaseResourceVariableOptions) { + this.db = options.db ?? db; + } + + private async loadVariables() { + const variables = await this.db.query.resourceVariable.findMany({ + where: and(eq(resourceVariable.resourceId, this.options.resourceId)), + }); + return variables.map((v) => ({ + id: v.id, + key: v.key, + value: v.value, + sensitive: v.sensitive, + })); + } + + private getVariablesPromise() { + return (this.variables ??= this.loadVariables()); + } + + async getVariable(key: string): Promise { + const variables = await this.getVariablesPromise(); + return variables.find((v) => v.key === key) ?? null; + } +} + +export type DatabaseDeploymentVariableOptions = { + resourceId: string; + deploymentId: string; + keys: string[]; + db?: Tx; +}; + +type DeploymentVariableValue = { + value: any; + resourceSelector: any; + sensitive: boolean; +}; + +type DeploymentVariable = { + id: string; + key: string; + value: string; + sensitive: boolean; + defaultValue: DeploymentVariableValue | null; + values: DeploymentVariableValue[]; +}; + +export class DatabaseDeploymentVariableProvider implements VariableProvider { + private db: Tx; + private variables: Promise | null = null; + + constructor(private options: DatabaseDeploymentVariableOptions) { + this.db = options.db ?? db; + } + + private loadVariables() { + return this.db.query.deploymentVariable.findMany({ + where: eq(deploymentVariable.deploymentId, this.options.deploymentId), + with: { + defaultValue: true, + values: true, + }, + }); + } + + private getVariablesPromise() { + return (this.variables ??= this.loadVariables()); + } + + async getVariable(key: string): Promise { + const variables = await this.getVariablesPromise(); + const variable = variables.find((v) => v.key === key) ?? null; + if (variable == null) return null; + + for (const value of variable.values) { + const res = await this.db + .select() + .from(resource) + .where( + and( + eq(resource.id, this.options.resourceId), + resourceMatchesMetadata(this.db, value.resourceSelector), + ), + ) + .then(takeFirstOrNull); + + if (res != null) + return { + id: variable.id, + key, + ...value, + }; + } + + if (variable.defaultValue != null) + return { + id: variable.id, + key, + ...variable.defaultValue, + }; + + return null; + } +} diff --git a/packages/release-manager/src/index.ts b/packages/release-manager/src/index.ts new file mode 100644 index 000000000..c47034c09 --- /dev/null +++ b/packages/release-manager/src/index.ts @@ -0,0 +1,5 @@ +export * from "./types.js"; +export * from "./releases.js"; +export * from "./rule-engine.js"; +export * from "./rules/index.js"; +export * from "./db-storage.js"; diff --git a/packages/release-manager/src/rules/index.ts b/packages/release-manager/src/rules/index.ts new file mode 100644 index 000000000..2ef8353e7 --- /dev/null +++ b/packages/release-manager/src/rules/index.ts @@ -0,0 +1,2 @@ +export * from "./variable-rules.js"; +export * from "./version-rules.js"; \ No newline at end of file diff --git a/packages/release-manager/src/rules/variable-rules.ts b/packages/release-manager/src/rules/variable-rules.ts new file mode 100644 index 000000000..718bf2d58 --- /dev/null +++ b/packages/release-manager/src/rules/variable-rules.ts @@ -0,0 +1,148 @@ +import { ReleaseAction, ReleaseCondition, ReleaseRuleEvaluationProps } from "../rule-engine.js"; +import { AnyVariable } from "../types.js"; + +// Variable-specific conditions +export class VariableChangedCondition implements ReleaseCondition { + constructor(private variableName?: string, private variableType?: string) {} + + async evaluate(props: ReleaseRuleEvaluationProps): Promise { + if (props.release.triggerType !== "variable" || !props.variable) { + return false; + } + + // Check variable name if specified + if (this.variableName && props.variable.name !== this.variableName) { + return false; + } + + // Check variable type if specified + if (this.variableType && props.variable.type !== this.variableType) { + return false; + } + + return true; + } +} + +export class VariableValueCondition implements ReleaseCondition { + constructor( + private predicate: (value: unknown) => boolean, + private variableName?: string, + ) {} + + async evaluate(props: ReleaseRuleEvaluationProps): Promise { + if (props.release.triggerType !== "variable" || !props.variable) { + return false; + } + + // Check variable name if specified + if (this.variableName && props.variable.name !== this.variableName) { + return false; + } + + return this.predicate(props.variable.value); + } +} + +export class ContextSpecificCondition implements ReleaseCondition { + constructor( + private resourceId?: string, + private environmentId?: string, + private deploymentId?: string, + ) {} + + async evaluate(props: ReleaseRuleEvaluationProps): Promise { + // Check resource ID if specified + if (this.resourceId) { + const currentResourceId = props.resource?.id || props.release.resourceId || props.context.resourceId; + if (currentResourceId !== this.resourceId) { + return false; + } + } + + // Check environment ID if specified + if (this.environmentId) { + const currentEnvironmentId = props.environment?.id || props.release.environmentId || props.context.environmentId; + if (currentEnvironmentId !== this.environmentId) { + return false; + } + } + + // Check deployment ID if specified + if (this.deploymentId) { + const currentDeploymentId = props.deployment?.id || props.release.deploymentId || props.context.deploymentId; + if (currentDeploymentId !== this.deploymentId) { + return false; + } + } + + return true; + } +} + +export class ResourceLabelCondition implements ReleaseCondition { + constructor( + private key: string, + private value: string, + ) {} + + async evaluate(props: ReleaseRuleEvaluationProps): Promise { + if (!props.resource || !props.resource.labels) { + return false; + } + + return props.resource.labels[this.key] === this.value; + } +} + +// Variable-specific actions +export class LogVariableChangeAction implements ReleaseAction { + async execute(props: ReleaseRuleEvaluationProps): Promise { + if (props.variable) { + const variable = props.variable as AnyVariable; + let contextInfo = ""; + + if (variable.type === "resourceVariable" && variable.resourceId) { + contextInfo += ` for resource ${variable.resourceId}`; + } else if (variable.type === "deploymentVariable" && variable.deploymentId) { + contextInfo += ` for deployment ${variable.deploymentId}`; + } + + console.log( + `Variable changed: ${props.variable.name} (${variable.type})${contextInfo} = ${JSON.stringify( + props.variable.value, + )}`, + ); + } + } +} + +export class UpdateDependentVariablesAction implements ReleaseAction { + constructor( + private dependentVariableComputer: ( + source: AnyVariable, + context: ReleaseRuleEvaluationProps, + ) => Promise, + ) {} + + async execute(props: ReleaseRuleEvaluationProps): Promise { + if (!props.variable) return; + + try { + // Compute dependent variables based on the changed variable + const dependentVariables = await this.dependentVariableComputer( + props.variable as AnyVariable, + props, + ); + + // Log the dependent variables that were updated + dependentVariables.forEach((variable) => { + console.log( + `Updated dependent variable: ${variable.name} = ${JSON.stringify(variable.value)}`, + ); + }); + } catch (error) { + console.error(`Failed to update dependent variables:`, error); + } + } +} diff --git a/packages/release-manager/src/rules/version-rules.ts b/packages/release-manager/src/rules/version-rules.ts new file mode 100644 index 000000000..7ecb24e98 --- /dev/null +++ b/packages/release-manager/src/rules/version-rules.ts @@ -0,0 +1,175 @@ +import { ReleaseAction, ReleaseCondition, ReleaseRuleEvaluationProps } from "../rule-engine.js"; + +// Version-specific conditions +export class VersionChangedCondition implements ReleaseCondition { + constructor(private versionId?: string) {} + + async evaluate(props: ReleaseRuleEvaluationProps): Promise { + if (props.release.triggerType !== "version" || !props.version) { + return false; + } + + // Check version ID if specified + if (this.versionId && props.version.id !== this.versionId) { + return false; + } + + return true; + } +} + +export class VersionValueCondition implements ReleaseCondition { + constructor( + private predicate: (version: string) => boolean, + private versionId?: string, + ) {} + + async evaluate(props: ReleaseRuleEvaluationProps): Promise { + if (props.release.triggerType !== "version" || !props.version) { + return false; + } + + // Check version ID if specified + if (this.versionId && props.version.id !== this.versionId) { + return false; + } + + return this.predicate(props.version.version); + } +} + +export class SemverCondition implements ReleaseCondition { + constructor( + private semverRequirement: string, + private versionId?: string, + ) {} + + async evaluate(props: ReleaseRuleEvaluationProps): Promise { + if (props.release.triggerType !== "version" || !props.version) { + return false; + } + + // Check version ID if specified + if (this.versionId && props.version.id !== this.versionId) { + return false; + } + + // Simple semver check (would normally use a proper semver library) + const version = props.version.version; + + // For simple major version check for demonstration + if (this.semverRequirement.startsWith("^")) { + const requiredMajor = this.semverRequirement.substring(1).split(".")[0]; + const actualMajor = version.split(".")[0]; + return requiredMajor === actualMajor; + } + + // For exact version match + if (this.semverRequirement.startsWith("=")) { + const requiredVersion = this.semverRequirement.substring(1); + return version === requiredVersion; + } + + // For greater than + if (this.semverRequirement.startsWith(">")) { + const requiredVersion = this.semverRequirement.substring(1); + return this.compareVersions(version, requiredVersion) > 0; + } + + // For less than + if (this.semverRequirement.startsWith("<")) { + const requiredVersion = this.semverRequirement.substring(1); + return this.compareVersions(version, requiredVersion) < 0; + } + + // Default to exact match + return version === this.semverRequirement; + } + + private compareVersions(a: string, b: string): number { + const aParts = a.split(".").map(Number); + const bParts = b.split(".").map(Number); + + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + const aPart = aParts[i] || 0; + const bPart = bParts[i] || 0; + + if (aPart !== bPart) { + return aPart - bPart; + } + } + + return 0; + } +} + +// Version-specific actions +export class LogVersionChangeAction implements ReleaseAction { + async execute(props: ReleaseRuleEvaluationProps): Promise { + if (props.version) { + let contextInfo = ""; + + if (props.resource) { + contextInfo += ` for resource ${props.resource.id}`; + } + + if (props.environment) { + contextInfo += ` in environment ${props.environment.id}`; + } + + console.log( + `Version changed: ${props.version.id} = ${props.version.version}${contextInfo}`, + ); + } + } +} + +export class MajorVersionChangeAction implements ReleaseAction { + constructor(private callback: (version: string, context: ReleaseRuleEvaluationProps) => Promise) {} + + async execute(props: ReleaseRuleEvaluationProps): Promise { + if (!props.version) return; + + // Extract major version using semver conventions + const version = props.version.version; + const majorVersion = version.split(".")[0]; + + console.log(`Major version change detected: ${majorVersion}`); + await this.callback(majorVersion, props); + } +} + +export class TriggerDeploymentAction implements ReleaseAction { + constructor(private deploymentService: any) {} + + async execute(props: ReleaseRuleEvaluationProps): Promise { + if (!props.version || !props.resource) { + return; + } + + // Extract context information + const resourceId = props.resource.id; + const version = props.version.version; + const environmentId = props.environment?.id || props.context.environmentId as string; + + if (!environmentId) { + console.error("Cannot deploy without an environment"); + return; + } + + console.log(`Triggering deployment of version ${version} to resource ${resourceId} in environment ${environmentId}`); + + try { + // This would call an external deployment service + await this.deploymentService.triggerDeployment({ + resourceId, + environmentId, + version, + }); + + console.log("Deployment triggered successfully"); + } catch (error) { + console.error("Failed to trigger deployment:", error); + } + } +} diff --git a/packages/release-manager/src/types.ts b/packages/release-manager/src/types.ts new file mode 100644 index 000000000..32f961b14 --- /dev/null +++ b/packages/release-manager/src/types.ts @@ -0,0 +1,20 @@ +export type Variable = { + id: string; + key: string; + value: T; + sensitive: boolean; +}; + +export type Release = { + resourceId: string; + deploymentId: string; + versionId: string; + variables: Variable[]; +}; + +export type MaybePromise = T | Promise; +export type MaybeVariable = Variable | null; + +export type VariableProvider = { + getVariable(key: string): MaybePromise; +}; diff --git a/packages/release-manager/src/variables.ts b/packages/release-manager/src/variables.ts new file mode 100644 index 000000000..61b66dd3d --- /dev/null +++ b/packages/release-manager/src/variables.ts @@ -0,0 +1,30 @@ +import type { MaybeVariable, VariableProvider } from "./types"; + +type ReleaseManagerOptions = { + deploymentId: string; + resourceId: string; + + keys: string[]; +}; + +export class VariableManager { + constructor( + private options: ReleaseManagerOptions, + private variableProviders: VariableProvider[], + ) {} + + async getVariables(): Promise { + const variables = await Promise.all( + this.options.keys.map((key) => this.getVariable(key)), + ); + return variables; + } + + async getVariable(key: string): Promise { + for (const provider of this.variableProviders) { + const variable = await provider.getVariable(key); + if (variable) return variable; + } + return null; + } +} diff --git a/packages/release-manager/test/release-manager.test.ts b/packages/release-manager/test/release-manager.test.ts new file mode 100644 index 000000000..18d417bac --- /dev/null +++ b/packages/release-manager/test/release-manager.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, test, vi } from "vitest"; +import { InMemoryReleaseStorage, ReleaseManager } from "../src"; +import { + LogVariableChangeAction, + LogVersionChangeAction, + VariableChangedCondition, + VersionChangedCondition, +} from "../src"; +import { RuleEngine } from "../src"; + +// Mock for generateId +const generateId = () => `id-${Math.floor(Math.random() * 1000)}`; + +describe("ReleaseManager", () => { + test("should create a release for a variable change", async () => { + // Setup + const storage = new InMemoryReleaseStorage(); + const releaseManager = new ReleaseManager({ storage, generateId }); + + // Create a variable and release + const variable = { + id: "var-1", + name: "test-variable", + value: "test-value", + updatedAt: new Date(), + }; + + // Set the variables in storage + storage.setVariables([variable]); + + // Create a release + const release = await releaseManager.createReleaseForVariable(variable); + + // Assertions + expect(release).not.toBeNull(); + expect(release?.triggerType).toBe("variable"); + expect(release?.triggerId).toBe(variable.id); + + // Verify idempotency - creating again should return the same release + const sameRelease = await releaseManager.createReleaseForVariable(variable); + expect(sameRelease?.id).toBe(release?.id); + }); + + test("should create a release for a version change", async () => { + // Setup + const storage = new InMemoryReleaseStorage(); + const releaseManager = new ReleaseManager({ storage, generateId }); + + // Create a version and release + const version = { + id: "ver-1", + version: "1.0.0", + updatedAt: new Date(), + }; + + // Set the versions in storage + storage.setVersions([version]); + + // Create a release + const release = await releaseManager.createReleaseForVersion(version); + + // Assertions + expect(release).not.toBeNull(); + expect(release?.triggerType).toBe("version"); + expect(release?.triggerId).toBe(version.id); + + // Verify idempotency - creating again should return the same release + const sameRelease = await releaseManager.createReleaseForVersion(version); + expect(sameRelease?.id).toBe(release?.id); + }); +}); + +describe("RuleEngine", () => { + test("should process release through rules", async () => { + // Setup + const storage = new InMemoryReleaseStorage(); + const releaseManager = new ReleaseManager({ storage, generateId }); + + // Create a version and release + const version = { + id: "ver-1", + version: "2.0.0", + updatedAt: new Date(), + }; + + // Set up version in storage + storage.setVersions([version]); + + // Create a mock action for testing + const mockAction = { execute: vi.fn() }; + + // Create a rule engine + const ruleEngine = new RuleEngine({ + rules: [ + { + id: "rule-1", + name: "Test Version Rule", + condition: new VersionChangedCondition(), + action: mockAction, + }, + ], + }); + + // Create a release + const release = await releaseManager.createReleaseForVersion(version); + if (!release) throw new Error("Failed to create release"); + + // Process the release + await ruleEngine.processRelease(release, undefined, version); + + // Assertions + expect(mockAction.execute).toHaveBeenCalledTimes(1); + expect(mockAction.execute).toHaveBeenCalledWith({ + release, + version, + context: {}, + }); + }); + + test("should only execute actions for matching conditions", async () => { + // Setup + const storage = new InMemoryReleaseStorage(); + const releaseManager = new ReleaseManager({ storage, generateId }); + + // Create a variable + const variable = { + id: "var-1", + name: "test-variable", + value: "test-value", + updatedAt: new Date(), + }; + + // Set the variable in storage + storage.setVariables([variable]); + + // Create mock actions for testing + const mockVariableAction = { execute: vi.fn() }; + const mockVersionAction = { execute: vi.fn() }; + + // Create a rule engine with both rules + const ruleEngine = new RuleEngine({ + rules: [ + { + id: "rule-1", + name: "Variable Rule", + condition: new VariableChangedCondition(), + action: mockVariableAction, + }, + { + id: "rule-2", + name: "Version Rule", + condition: new VersionChangedCondition(), + action: mockVersionAction, + }, + ], + }); + + // Create a variable release + const release = await releaseManager.createReleaseForVariable(variable); + if (!release) throw new Error("Failed to create release"); + + // Process the release + await ruleEngine.processRelease(release, variable); + + // Assertions - only the variable action should have been called + expect(mockVariableAction.execute).toHaveBeenCalledTimes(1); + expect(mockVersionAction.execute).not.toHaveBeenCalled(); + }); +}); + +describe("Integration test", () => { + test("should implement a complete release workflow", async () => { + // Setup storage + const storage = new InMemoryReleaseStorage(); + + // Setup release manager + const releaseManager = new ReleaseManager({ storage, generateId }); + + // Set up variables and versions + const variable = { + id: "config-1", + name: "API_URL", + value: "https://api.example.com", + updatedAt: new Date(), + }; + + const version = { + id: "app-version", + version: "1.2.0", + updatedAt: new Date(), + }; + + // Initialize storage + storage.setVariables([variable]); + storage.setVersions([version]); + + // Create action spies + const variableLogSpy = vi.spyOn(console, "log"); + const versionLogSpy = vi.spyOn(console, "log"); + + // Setup rule engine + const ruleEngine = new RuleEngine({ + rules: [ + { + id: "variable-log-rule", + name: "Log Variable Changes", + condition: new VariableChangedCondition(), + action: new LogVariableChangeAction(), + }, + { + id: "version-log-rule", + name: "Log Version Changes", + condition: new VersionChangedCondition(), + action: new LogVersionChangeAction(), + }, + ], + }); + + // Create variable release + const varRelease = await releaseManager.createReleaseForVariable(variable); + expect(varRelease).not.toBeNull(); + + // Process variable release through rules + await ruleEngine.processRelease(varRelease!, variable); + expect(variableLogSpy).toHaveBeenCalledWith( + expect.stringContaining("Variable changed: API_URL") + ); + + // Create version release + const verRelease = await releaseManager.createReleaseForVersion(version); + expect(verRelease).not.toBeNull(); + + // Process version release through rules + await ruleEngine.processRelease(verRelease!, undefined, version); + expect(versionLogSpy).toHaveBeenCalledWith( + expect.stringContaining("Version changed: app-version = 1.2.0") + ); + + // Verify releases were saved in storage + const allReleases = await releaseManager.getReleases(); + expect(allReleases.length).toBe(2); + }); +}); diff --git a/packages/release-manager/test/variable-resolution.test.ts b/packages/release-manager/test/variable-resolution.test.ts new file mode 100644 index 000000000..85cdf826e --- /dev/null +++ b/packages/release-manager/test/variable-resolution.test.ts @@ -0,0 +1,368 @@ +import { describe, expect, test, vi } from "vitest"; +import { InMemoryReleaseStorage, ReleaseManager } from "../src"; +import { + ContextSpecificCondition, + LogVariableChangeAction, + VariableChangedCondition, +} from "../src"; +import { RuleEngine } from "../src"; + +// Mock for generateId +const generateId = () => `id-${Math.floor(Math.random() * 1000)}`; + +describe("Variable Resolution", () => { + test("should resolve variables in the correct priority order", async () => { + // Setup + const storage = new InMemoryReleaseStorage(); + const releaseManager = new ReleaseManager({ storage, generateId }); + + // Setup resources, deployments, environments + const environment = { + id: "env-1", + name: "Production", + updatedAt: new Date(), + }; + + const resource = { + id: "res-1", + name: "API Server", + labels: { + type: "backend", + tier: "api", + }, + environmentId: "env-1", + updatedAt: new Date(), + }; + + const deployment = { + id: "deploy-1", + name: "Backend Deployment", + selectors: [ + { key: "type", value: "backend" }, + ], + updatedAt: new Date(), + }; + + // Setup variables at different levels + const standardVariable = { + id: "var-1", + type: "variable" as const, + name: "API_URL", + value: "https://api.example.com", + updatedAt: new Date(), + }; + + const deploymentVariable = { + id: "var-2", + type: "deploymentVariable" as const, + name: "API_URL", + value: "https://api.staging.example.com", + selectors: [ + { key: "type", value: "backend" }, + ], + deploymentId: "deploy-1", + updatedAt: new Date(), + }; + + const resourceVariable = { + id: "var-3", + type: "resourceVariable" as const, + name: "API_URL", + value: "https://api.prod.example.com", + resourceId: "res-1", + environmentId: "env-1", + updatedAt: new Date(), + }; + + // Also create a variable that only exists at standard level + const standardOnlyVariable = { + id: "var-4", + type: "variable" as const, + name: "LOG_LEVEL", + value: "info", + updatedAt: new Date(), + }; + + // Initialize storage + storage.setEnvironments([environment]); + storage.setResources([resource]); + storage.setDeployments([deployment]); + storage.setVariables([standardVariable, standardOnlyVariable]); + storage.setDeploymentVariables([deploymentVariable]); + storage.setResourceVariables([resourceVariable]); + + // Create a context for resolution + const context = { + resourceId: "res-1", + environmentId: "env-1", + deploymentId: "deploy-1", + }; + + // Test resource variable (highest priority) + const resolvedApiUrl = await releaseManager.getVariable("API_URL", context); + expect(resolvedApiUrl).not.toBeNull(); + expect(resolvedApiUrl?.value).toBe("https://api.prod.example.com"); + expect(resolvedApiUrl?.type).toBe("resourceVariable"); + + // Test standard variable (fallback when no higher priority exists) + const resolvedLogLevel = await releaseManager.getVariable("LOG_LEVEL", context); + expect(resolvedLogLevel).not.toBeNull(); + expect(resolvedLogLevel?.value).toBe("info"); + expect(resolvedLogLevel?.type).toBe("variable"); + + // Test all variables for the context + const allVariables = await releaseManager.getVariablesForContext(context); + expect(allVariables.length).toBe(2); // API_URL and LOG_LEVEL + + // Verify we get the highest priority for each name + const apiUrlVar = allVariables.find(v => v.name === "API_URL"); + expect(apiUrlVar).not.toBeNull(); + expect(apiUrlVar?.type).toBe("resourceVariable"); + expect(apiUrlVar?.value).toBe("https://api.prod.example.com"); + + const logLevelVar = allVariables.find(v => v.name === "LOG_LEVEL"); + expect(logLevelVar).not.toBeNull(); + expect(logLevelVar?.type).toBe("variable"); + expect(logLevelVar?.value).toBe("info"); + + // Test with a context that has no resource variable + const newContext = { + resourceId: "res-2", // Different resource + environmentId: "env-1", + deploymentId: "deploy-1", + }; + + // Should fall back to deployment variable + const fallbackApiUrl = await releaseManager.getVariable("API_URL", newContext); + expect(fallbackApiUrl).toBeNull(); // Since res-2 doesn't exist, and resource labels can't be checked + + // Create a resource with matching labels for deployment variable + const newResource = { + id: "res-2", + name: "Secondary API", + labels: { + type: "backend", // Matches the deployment selector + tier: "secondary", + }, + environmentId: "env-1", + updatedAt: new Date(), + }; + + storage.setResources([...storage.getResources(), newResource]); + + // Now should resolve to deployment variable + const deploymentApiUrl = await releaseManager.getVariable("API_URL", { + ...newContext, + resource: newResource, + }); + + expect(deploymentApiUrl).not.toBeNull(); + expect(deploymentApiUrl?.type).toBe("deploymentVariable"); + expect(deploymentApiUrl?.value).toBe("https://api.staging.example.com"); + }); + + test("should create releases with context-specific variables", async () => { + // Setup + const storage = new InMemoryReleaseStorage(); + const releaseManager = new ReleaseManager({ storage, generateId }); + + // Setup resource and variables + const resource = { + id: "res-1", + name: "API Server", + labels: { + type: "backend", + }, + environmentId: "env-1", + updatedAt: new Date(), + }; + + const resourceVariable = { + id: "var-1", + type: "resourceVariable" as const, + name: "API_URL", + value: "https://api.prod.example.com", + resourceId: "res-1", + environmentId: "env-1", + updatedAt: new Date(), + }; + + // Initialize storage + storage.setResources([resource]); + storage.setResourceVariables([resourceVariable]); + + // Create a release for the variable in a specific context + const context = { + resourceId: "res-1", + environmentId: "env-1", + resource: resource, + }; + + const release = await releaseManager.createReleaseForVariable("API_URL", context); + + // Check release metadata + expect(release).not.toBeNull(); + expect(release?.triggerType).toBe("variable"); + expect(release?.triggerId).toBe("API_URL"); + expect(release?.resourceId).toBe("res-1"); + expect(release?.environmentId).toBe("env-1"); + expect(release?.metadata?.variableType).toBe("resourceVariable"); + expect(release?.metadata?.variableName).toBe("API_URL"); + expect(release?.metadata?.variableValue).toBe("https://api.prod.example.com"); + + // Update the variable value + const updatedVariable = { + ...resourceVariable, + value: "https://api.v2.prod.example.com", + updatedAt: new Date(Date.now() + 1000), // Ensure it's newer + }; + + storage.setResourceVariables([updatedVariable]); + + // Create another release - should be a new one due to the value change + const newRelease = await releaseManager.createReleaseForVariable("API_URL", context); + + expect(newRelease).not.toBeNull(); + expect(newRelease?.id).not.toBe(release?.id); + expect(newRelease?.metadata?.variableValue).toBe("https://api.v2.prod.example.com"); + + // Get releases for this context + const contextReleases = await releaseManager.getReleases(context); + expect(contextReleases.length).toBe(2); + + // Should be sorted by date (newest first) + expect(contextReleases[0].id).toBe(newRelease?.id); + }); + + test("should process releases with context-specific rules", async () => { + // Setup + const storage = new InMemoryReleaseStorage(); + const releaseManager = new ReleaseManager({ storage, generateId }); + + // Setup resources and variables + const prodEnv = { + id: "env-prod", + name: "Production", + updatedAt: new Date(), + }; + + const stagingEnv = { + id: "env-staging", + name: "Staging", + updatedAt: new Date(), + }; + + const prodResource = { + id: "res-prod", + name: "Production API", + labels: { environment: "production" }, + environmentId: "env-prod", + updatedAt: new Date(), + }; + + const stagingResource = { + id: "res-staging", + name: "Staging API", + labels: { environment: "staging" }, + environmentId: "env-staging", + updatedAt: new Date(), + }; + + const prodVariable = { + id: "var-prod", + type: "resourceVariable" as const, + name: "FEATURE_FLAG", + value: true, + resourceId: "res-prod", + environmentId: "env-prod", + updatedAt: new Date(), + }; + + const stagingVariable = { + id: "var-staging", + type: "resourceVariable" as const, + name: "FEATURE_FLAG", + value: true, + resourceId: "res-staging", + environmentId: "env-staging", + updatedAt: new Date(), + }; + + // Initialize storage + storage.setEnvironments([prodEnv, stagingEnv]); + storage.setResources([prodResource, stagingResource]); + storage.setResourceVariables([prodVariable, stagingVariable]); + + // Setup rule engine with context-specific conditions + const prodLogSpy = vi.fn(); + const stagingLogSpy = vi.fn(); + + // Production-specific rule + const prodRule = { + id: "rule-prod", + name: "Production Feature Flag Rule", + condition: new ContextSpecificCondition("res-prod", "env-prod"), + action: { + execute: async () => { + prodLogSpy(); + console.log("Production feature flag enabled"); + }, + }, + }; + + // Staging-specific rule + const stagingRule = { + id: "rule-staging", + name: "Staging Feature Flag Rule", + condition: new ContextSpecificCondition("res-staging", "env-staging"), + action: { + execute: async () => { + stagingLogSpy(); + console.log("Staging feature flag enabled"); + }, + }, + }; + + const ruleEngine = new RuleEngine({ + rules: [prodRule, stagingRule], + }); + + // Create releases for both environments + const prodContext = { + resourceId: "res-prod", + environmentId: "env-prod", + resource: prodResource, + environment: prodEnv, + }; + + const stagingContext = { + resourceId: "res-staging", + environmentId: "env-staging", + resource: stagingResource, + environment: stagingEnv, + }; + + const prodRelease = await releaseManager.createReleaseForVariable("FEATURE_FLAG", prodContext); + const stagingRelease = await releaseManager.createReleaseForVariable("FEATURE_FLAG", stagingContext); + + expect(prodRelease).not.toBeNull(); + expect(stagingRelease).not.toBeNull(); + + // Process the production release + await ruleEngine.processRelease(prodRelease!, prodVariable, undefined, prodContext); + + // Only the production rule should have been executed + expect(prodLogSpy).toHaveBeenCalledTimes(1); + expect(stagingLogSpy).not.toHaveBeenCalled(); + + // Reset mocks + vi.resetAllMocks(); + + // Process the staging release + await ruleEngine.processRelease(stagingRelease!, stagingVariable, undefined, stagingContext); + + // Only the staging rule should have been executed + expect(stagingLogSpy).toHaveBeenCalledTimes(1); + expect(prodLogSpy).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/packages/release-manager/tsconfig.json b/packages/release-manager/tsconfig.json new file mode 100644 index 000000000..e02676b57 --- /dev/null +++ b/packages/release-manager/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/release-manager/vitest.config.ts b/packages/release-manager/vitest.config.ts new file mode 100644 index 000000000..8a7a111b2 --- /dev/null +++ b/packages/release-manager/vitest.config.ts @@ -0,0 +1,15 @@ +import { resolve } from "path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/__tests__/**/*.test.ts"], + exclude: ["**/node_modules/**", "**/dist/**"], + }, + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acdcd049c..45dc02a6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1227,6 +1227,52 @@ importers: specifier: 'catalog:' version: 5.8.2 + packages/release-manager: + dependencies: + '@ctrlplane/db': + specifier: workspace:* + version: link:../db + '@ctrlplane/validators': + specifier: workspace:* + version: link:../validators + '@date-fns/tz': + specifier: ^1.2.0 + version: 1.2.0 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 + rrule: + specifier: ^2.8.1 + version: 2.8.1 + 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/rule-engine: dependencies: '@ctrlplane/db': @@ -2159,9 +2205,6 @@ packages: '@dabh/diagnostics@2.0.3': resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==} - '@date-fns/tz@1.1.2': - resolution: {integrity: sha512-Xmg2cPmOPQieCLAdf62KtFPU9y7wbQDq1OAzrs/bEQFvhtCPXDiks1CHDE/sTXReRfh/MICVkw/vY6OANHUGiA==} - '@date-fns/tz@1.2.0': resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==} @@ -12528,8 +12571,6 @@ snapshots: enabled: 2.0.0 kuler: 2.0.0 - '@date-fns/tz@1.1.2': {} - '@date-fns/tz@1.2.0': {} '@discoveryjs/json-ext@0.5.7': {} @@ -21263,7 +21304,7 @@ snapshots: react-day-picker@9.2.1(react@19.0.0): dependencies: - '@date-fns/tz': 1.1.2 + '@date-fns/tz': 1.2.0 date-fns: 4.1.0 react: 19.0.0 From 355ce70f459114d1ef4ed9d19ac0fed4ea220c8d Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sat, 29 Mar 2025 18:55:09 -0400 Subject: [PATCH 02/22] get variables --- packages/release-manager/src/db-variable-providers.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/release-manager/src/db-variable-providers.tsx b/packages/release-manager/src/db-variable-providers.tsx index 06a6e1ca9..a8fca4f52 100644 --- a/packages/release-manager/src/db-variable-providers.tsx +++ b/packages/release-manager/src/db-variable-providers.tsx @@ -86,12 +86,12 @@ export class DatabaseDeploymentVariableProvider implements VariableProvider { }); } - private getVariablesPromise() { + private getVariables() { return (this.variables ??= this.loadVariables()); } async getVariable(key: string): Promise { - const variables = await this.getVariablesPromise(); + const variables = await this.getVariables(); const variable = variables.find((v) => v.key === key) ?? null; if (variable == null) return null; From 87c23280fb120bc0ad03069509c94dadd00253e3 Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sat, 29 Mar 2025 18:57:41 -0400 Subject: [PATCH 03/22] fix typing --- packages/release-manager/src/db-variable-providers.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/release-manager/src/db-variable-providers.tsx b/packages/release-manager/src/db-variable-providers.tsx index a8fca4f52..f756e94b1 100644 --- a/packages/release-manager/src/db-variable-providers.tsx +++ b/packages/release-manager/src/db-variable-providers.tsx @@ -56,14 +56,11 @@ export type DatabaseDeploymentVariableOptions = { type DeploymentVariableValue = { value: any; resourceSelector: any; - sensitive: boolean; }; type DeploymentVariable = { id: string; key: string; - value: string; - sensitive: boolean; defaultValue: DeploymentVariableValue | null; values: DeploymentVariableValue[]; }; @@ -111,6 +108,7 @@ export class DatabaseDeploymentVariableProvider implements VariableProvider { return { id: variable.id, key, + sensitive: false, ...value, }; } @@ -119,6 +117,7 @@ export class DatabaseDeploymentVariableProvider implements VariableProvider { return { id: variable.id, key, + sensitive: false, ...variable.defaultValue, }; From f25f5fb109fb91da870da97a8ef252e3a9a29069 Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sat, 29 Mar 2025 18:59:09 -0400 Subject: [PATCH 04/22] clean up --- packages/release-manager/src/index.ts | 4 - packages/release-manager/src/rules/index.ts | 2 - .../src/rules/variable-rules.ts | 148 --------------- .../src/rules/version-rules.ts | 175 ------------------ packages/release-manager/src/variables.ts | 5 +- 5 files changed, 1 insertion(+), 333 deletions(-) delete mode 100644 packages/release-manager/src/rules/index.ts delete mode 100644 packages/release-manager/src/rules/variable-rules.ts delete mode 100644 packages/release-manager/src/rules/version-rules.ts diff --git a/packages/release-manager/src/index.ts b/packages/release-manager/src/index.ts index c47034c09..a0c4ebf25 100644 --- a/packages/release-manager/src/index.ts +++ b/packages/release-manager/src/index.ts @@ -1,5 +1 @@ export * from "./types.js"; -export * from "./releases.js"; -export * from "./rule-engine.js"; -export * from "./rules/index.js"; -export * from "./db-storage.js"; diff --git a/packages/release-manager/src/rules/index.ts b/packages/release-manager/src/rules/index.ts deleted file mode 100644 index 2ef8353e7..000000000 --- a/packages/release-manager/src/rules/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./variable-rules.js"; -export * from "./version-rules.js"; \ No newline at end of file diff --git a/packages/release-manager/src/rules/variable-rules.ts b/packages/release-manager/src/rules/variable-rules.ts deleted file mode 100644 index 718bf2d58..000000000 --- a/packages/release-manager/src/rules/variable-rules.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { ReleaseAction, ReleaseCondition, ReleaseRuleEvaluationProps } from "../rule-engine.js"; -import { AnyVariable } from "../types.js"; - -// Variable-specific conditions -export class VariableChangedCondition implements ReleaseCondition { - constructor(private variableName?: string, private variableType?: string) {} - - async evaluate(props: ReleaseRuleEvaluationProps): Promise { - if (props.release.triggerType !== "variable" || !props.variable) { - return false; - } - - // Check variable name if specified - if (this.variableName && props.variable.name !== this.variableName) { - return false; - } - - // Check variable type if specified - if (this.variableType && props.variable.type !== this.variableType) { - return false; - } - - return true; - } -} - -export class VariableValueCondition implements ReleaseCondition { - constructor( - private predicate: (value: unknown) => boolean, - private variableName?: string, - ) {} - - async evaluate(props: ReleaseRuleEvaluationProps): Promise { - if (props.release.triggerType !== "variable" || !props.variable) { - return false; - } - - // Check variable name if specified - if (this.variableName && props.variable.name !== this.variableName) { - return false; - } - - return this.predicate(props.variable.value); - } -} - -export class ContextSpecificCondition implements ReleaseCondition { - constructor( - private resourceId?: string, - private environmentId?: string, - private deploymentId?: string, - ) {} - - async evaluate(props: ReleaseRuleEvaluationProps): Promise { - // Check resource ID if specified - if (this.resourceId) { - const currentResourceId = props.resource?.id || props.release.resourceId || props.context.resourceId; - if (currentResourceId !== this.resourceId) { - return false; - } - } - - // Check environment ID if specified - if (this.environmentId) { - const currentEnvironmentId = props.environment?.id || props.release.environmentId || props.context.environmentId; - if (currentEnvironmentId !== this.environmentId) { - return false; - } - } - - // Check deployment ID if specified - if (this.deploymentId) { - const currentDeploymentId = props.deployment?.id || props.release.deploymentId || props.context.deploymentId; - if (currentDeploymentId !== this.deploymentId) { - return false; - } - } - - return true; - } -} - -export class ResourceLabelCondition implements ReleaseCondition { - constructor( - private key: string, - private value: string, - ) {} - - async evaluate(props: ReleaseRuleEvaluationProps): Promise { - if (!props.resource || !props.resource.labels) { - return false; - } - - return props.resource.labels[this.key] === this.value; - } -} - -// Variable-specific actions -export class LogVariableChangeAction implements ReleaseAction { - async execute(props: ReleaseRuleEvaluationProps): Promise { - if (props.variable) { - const variable = props.variable as AnyVariable; - let contextInfo = ""; - - if (variable.type === "resourceVariable" && variable.resourceId) { - contextInfo += ` for resource ${variable.resourceId}`; - } else if (variable.type === "deploymentVariable" && variable.deploymentId) { - contextInfo += ` for deployment ${variable.deploymentId}`; - } - - console.log( - `Variable changed: ${props.variable.name} (${variable.type})${contextInfo} = ${JSON.stringify( - props.variable.value, - )}`, - ); - } - } -} - -export class UpdateDependentVariablesAction implements ReleaseAction { - constructor( - private dependentVariableComputer: ( - source: AnyVariable, - context: ReleaseRuleEvaluationProps, - ) => Promise, - ) {} - - async execute(props: ReleaseRuleEvaluationProps): Promise { - if (!props.variable) return; - - try { - // Compute dependent variables based on the changed variable - const dependentVariables = await this.dependentVariableComputer( - props.variable as AnyVariable, - props, - ); - - // Log the dependent variables that were updated - dependentVariables.forEach((variable) => { - console.log( - `Updated dependent variable: ${variable.name} = ${JSON.stringify(variable.value)}`, - ); - }); - } catch (error) { - console.error(`Failed to update dependent variables:`, error); - } - } -} diff --git a/packages/release-manager/src/rules/version-rules.ts b/packages/release-manager/src/rules/version-rules.ts deleted file mode 100644 index 7ecb24e98..000000000 --- a/packages/release-manager/src/rules/version-rules.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { ReleaseAction, ReleaseCondition, ReleaseRuleEvaluationProps } from "../rule-engine.js"; - -// Version-specific conditions -export class VersionChangedCondition implements ReleaseCondition { - constructor(private versionId?: string) {} - - async evaluate(props: ReleaseRuleEvaluationProps): Promise { - if (props.release.triggerType !== "version" || !props.version) { - return false; - } - - // Check version ID if specified - if (this.versionId && props.version.id !== this.versionId) { - return false; - } - - return true; - } -} - -export class VersionValueCondition implements ReleaseCondition { - constructor( - private predicate: (version: string) => boolean, - private versionId?: string, - ) {} - - async evaluate(props: ReleaseRuleEvaluationProps): Promise { - if (props.release.triggerType !== "version" || !props.version) { - return false; - } - - // Check version ID if specified - if (this.versionId && props.version.id !== this.versionId) { - return false; - } - - return this.predicate(props.version.version); - } -} - -export class SemverCondition implements ReleaseCondition { - constructor( - private semverRequirement: string, - private versionId?: string, - ) {} - - async evaluate(props: ReleaseRuleEvaluationProps): Promise { - if (props.release.triggerType !== "version" || !props.version) { - return false; - } - - // Check version ID if specified - if (this.versionId && props.version.id !== this.versionId) { - return false; - } - - // Simple semver check (would normally use a proper semver library) - const version = props.version.version; - - // For simple major version check for demonstration - if (this.semverRequirement.startsWith("^")) { - const requiredMajor = this.semverRequirement.substring(1).split(".")[0]; - const actualMajor = version.split(".")[0]; - return requiredMajor === actualMajor; - } - - // For exact version match - if (this.semverRequirement.startsWith("=")) { - const requiredVersion = this.semverRequirement.substring(1); - return version === requiredVersion; - } - - // For greater than - if (this.semverRequirement.startsWith(">")) { - const requiredVersion = this.semverRequirement.substring(1); - return this.compareVersions(version, requiredVersion) > 0; - } - - // For less than - if (this.semverRequirement.startsWith("<")) { - const requiredVersion = this.semverRequirement.substring(1); - return this.compareVersions(version, requiredVersion) < 0; - } - - // Default to exact match - return version === this.semverRequirement; - } - - private compareVersions(a: string, b: string): number { - const aParts = a.split(".").map(Number); - const bParts = b.split(".").map(Number); - - for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { - const aPart = aParts[i] || 0; - const bPart = bParts[i] || 0; - - if (aPart !== bPart) { - return aPart - bPart; - } - } - - return 0; - } -} - -// Version-specific actions -export class LogVersionChangeAction implements ReleaseAction { - async execute(props: ReleaseRuleEvaluationProps): Promise { - if (props.version) { - let contextInfo = ""; - - if (props.resource) { - contextInfo += ` for resource ${props.resource.id}`; - } - - if (props.environment) { - contextInfo += ` in environment ${props.environment.id}`; - } - - console.log( - `Version changed: ${props.version.id} = ${props.version.version}${contextInfo}`, - ); - } - } -} - -export class MajorVersionChangeAction implements ReleaseAction { - constructor(private callback: (version: string, context: ReleaseRuleEvaluationProps) => Promise) {} - - async execute(props: ReleaseRuleEvaluationProps): Promise { - if (!props.version) return; - - // Extract major version using semver conventions - const version = props.version.version; - const majorVersion = version.split(".")[0]; - - console.log(`Major version change detected: ${majorVersion}`); - await this.callback(majorVersion, props); - } -} - -export class TriggerDeploymentAction implements ReleaseAction { - constructor(private deploymentService: any) {} - - async execute(props: ReleaseRuleEvaluationProps): Promise { - if (!props.version || !props.resource) { - return; - } - - // Extract context information - const resourceId = props.resource.id; - const version = props.version.version; - const environmentId = props.environment?.id || props.context.environmentId as string; - - if (!environmentId) { - console.error("Cannot deploy without an environment"); - return; - } - - console.log(`Triggering deployment of version ${version} to resource ${resourceId} in environment ${environmentId}`); - - try { - // This would call an external deployment service - await this.deploymentService.triggerDeployment({ - resourceId, - environmentId, - version, - }); - - console.log("Deployment triggered successfully"); - } catch (error) { - console.error("Failed to trigger deployment:", error); - } - } -} diff --git a/packages/release-manager/src/variables.ts b/packages/release-manager/src/variables.ts index 61b66dd3d..fb7982abc 100644 --- a/packages/release-manager/src/variables.ts +++ b/packages/release-manager/src/variables.ts @@ -14,10 +14,7 @@ export class VariableManager { ) {} async getVariables(): Promise { - const variables = await Promise.all( - this.options.keys.map((key) => this.getVariable(key)), - ); - return variables; + return Promise.all(this.options.keys.map((key) => this.getVariable(key))); } async getVariable(key: string): Promise { From 854ed1917bb1e4947c8557c9775a6bf6a5a206bd Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sat, 29 Mar 2025 19:03:37 -0400 Subject: [PATCH 05/22] clean up --- ...-providers.tsx => db-variable-providers.ts} | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) rename packages/release-manager/src/{db-variable-providers.tsx => db-variable-providers.ts} (89%) diff --git a/packages/release-manager/src/db-variable-providers.tsx b/packages/release-manager/src/db-variable-providers.ts similarity index 89% rename from packages/release-manager/src/db-variable-providers.tsx rename to packages/release-manager/src/db-variable-providers.ts index f756e94b1..18eafe5c9 100644 --- a/packages/release-manager/src/db-variable-providers.tsx +++ b/packages/release-manager/src/db-variable-providers.ts @@ -49,7 +49,6 @@ export class DatabaseResourceVariableProvider implements VariableProvider { export type DatabaseDeploymentVariableOptions = { resourceId: string; deploymentId: string; - keys: string[]; db?: Tx; }; @@ -124,3 +123,20 @@ export class DatabaseDeploymentVariableProvider implements VariableProvider { return null; } } + +export type DatabaseSystemVariableSetOptions = { + systemId: string; + db?: Tx; +}; + +export class DatabaseSystemVariableSetProvider implements VariableProvider { + private db: Tx; + + constructor(private options: DatabaseSystemVariableSetOptions) { + this.db = options.db ?? db; + } + + getVariable(_: string): MaybeVariable { + return null; + } +} From 9ff6105d56b93019772dab97f62439b73e7998bf Mon Sep 17 00:00:00 2001 From: Aditya Choudhari <48932219+adityachoudhari26@users.noreply.github.com> Date: Sat, 29 Mar 2025 17:27:25 -0700 Subject: [PATCH 06/22] Variable-set-provider (#428) --- .../src/db-variable-providers.ts | 46 +++++++++++++++++-- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/packages/release-manager/src/db-variable-providers.ts b/packages/release-manager/src/db-variable-providers.ts index 18eafe5c9..50eaccc57 100644 --- a/packages/release-manager/src/db-variable-providers.ts +++ b/packages/release-manager/src/db-variable-providers.ts @@ -1,12 +1,16 @@ import type { Tx } from "@ctrlplane/db"; +import type { VariableSetValue } from "@ctrlplane/db/schema"; -import { and, eq, takeFirstOrNull } from "@ctrlplane/db"; +import { and, asc, eq, takeFirstOrNull } from "@ctrlplane/db"; import { db } from "@ctrlplane/db/client"; import { deploymentVariable, + deploymentVariableValue, resource, resourceMatchesMetadata, resourceVariable, + variableSetEnvironment, + variableSetValue, } from "@ctrlplane/db/schema"; import type { MaybeVariable, Variable, VariableProvider } from "./types"; @@ -77,7 +81,7 @@ export class DatabaseDeploymentVariableProvider implements VariableProvider { where: eq(deploymentVariable.deploymentId, this.options.deploymentId), with: { defaultValue: true, - values: true, + values: { orderBy: [asc(deploymentVariableValue.value)] }, }, }); } @@ -125,18 +129,50 @@ export class DatabaseDeploymentVariableProvider implements VariableProvider { } export type DatabaseSystemVariableSetOptions = { - systemId: string; + // systemId: string; + environmentId: string; db?: Tx; }; export class DatabaseSystemVariableSetProvider implements VariableProvider { private db: Tx; + private variables: Promise | null = null; constructor(private options: DatabaseSystemVariableSetOptions) { this.db = options.db ?? db; } - getVariable(_: string): MaybeVariable { - return null; + private loadVariables() { + return this.db + .select() + .from(variableSetValue) + .innerJoin( + variableSetEnvironment, + eq( + variableSetValue.variableSetId, + variableSetEnvironment.variableSetId, + ), + ) + .where( + eq(variableSetEnvironment.environmentId, this.options.environmentId), + ) + .orderBy(asc(variableSetValue.value)) + .then((rows) => rows.map((r) => r.variable_set_value)); + } + + private getVariables() { + return (this.variables ??= this.loadVariables()); + } + + async getVariable(key: string): Promise { + const variables = await this.getVariables(); + const variable = variables.find((v) => v.key === key) ?? null; + if (variable == null) return null; + return { + id: variable.id, + key, + value: variable.value, + sensitive: false, + }; } } From 633d1f2b5097dec1a0fb7b42bf12160e774f49ba Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sat, 29 Mar 2025 23:11:17 -0400 Subject: [PATCH 07/22] add base release manager --- packages/release-manager/package.json | 1 + packages/release-manager/src/releases.ts | 107 +++++++++++++++++++++++ packages/release-manager/src/types.ts | 1 + pnpm-lock.yaml | 12 +-- 4 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 packages/release-manager/src/releases.ts diff --git a/packages/release-manager/package.json b/packages/release-manager/package.json index b39446483..d508fcbc4 100644 --- a/packages/release-manager/package.json +++ b/packages/release-manager/package.json @@ -22,6 +22,7 @@ "dependencies": { "@ctrlplane/db": "workspace:*", "@ctrlplane/validators": "workspace:*", + "lodash": "catalog:", "zod": "catalog:" }, "devDependencies": { diff --git a/packages/release-manager/src/releases.ts b/packages/release-manager/src/releases.ts new file mode 100644 index 000000000..22d198e33 --- /dev/null +++ b/packages/release-manager/src/releases.ts @@ -0,0 +1,107 @@ +import type { Tx } from "@ctrlplane/db"; +import * as _ from "lodash"; + +import { and, desc, eq, takeFirst } from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import * as schema from "@ctrlplane/db/schema"; + +import type { MaybeVariable, Release, Variable } from "./types"; + +type ReleaseManagerOptions = { + environmentId: string; + deploymentId: string; + resourceId: string; +}; + +export type ReleaseManager = { + getLatestRelease(): Promise; + createRelease( + versionId: string, + variables: MaybeVariable[], + ): Promise; + ensureRelease( + versionId: string, + variables: MaybeVariable[], + ): Promise; +}; + +export abstract class BaseReleaseManager implements ReleaseManager { + constructor(protected options: ReleaseManagerOptions) {} + + abstract getLatestRelease(): Promise; + abstract createRelease( + versionId: string, + variables: MaybeVariable[], + ): Promise; + + async ensureRelease( + versionId: string, + variables: Variable[], + ): Promise { + const latestRelease = await this.getLatestRelease(); + + const latestR = { + versionId: latestRelease?.versionId, + variables: Object.fromEntries( + latestRelease?.variables.map((v) => [v.key, v.value]) ?? [], + ), + }; + + const newR = { + versionId, + variables: Object.fromEntries(variables.map((v) => [v.key, v.value])), + }; + + return latestRelease != null && _.isEqual(latestR, newR) + ? latestRelease + : this.createRelease(versionId, variables); + } +} + +export class DatabaseReleaseManager extends BaseReleaseManager { + private db: Tx; + constructor(protected options: ReleaseManagerOptions & { db?: Tx }) { + super(options); + this.db = options.db ?? db; + } + + async getLatestRelease(): Promise { + return this.db.query.release + .findFirst({ + where: and( + eq(schema.release.resourceId, this.options.resourceId), + eq(schema.release.deploymentId, this.options.deploymentId), + eq(schema.release.environmentId, this.options.environmentId), + ), + with: { + variables: true, + }, + orderBy: desc(schema.release.createdAt), + }) + .then((r) => r ?? null); + } + + async createRelease( + versionId: string, + variables: Variable[], + ): Promise { + const release: Release = { + resourceId: this.options.resourceId, + deploymentId: this.options.deploymentId, + environmentId: this.options.environmentId, + versionId, + variables, + }; + + const dbRelease = await this.db + .insert(schema.release) + .values(release) + .returning() + .then(takeFirst); + + return { + ...release, + ...dbRelease, + }; + } +} diff --git a/packages/release-manager/src/types.ts b/packages/release-manager/src/types.ts index 32f961b14..70e2ead81 100644 --- a/packages/release-manager/src/types.ts +++ b/packages/release-manager/src/types.ts @@ -8,6 +8,7 @@ export type Variable = { export type Release = { resourceId: string; deploymentId: string; + environmentId: string; versionId: string; variables: Variable[]; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45dc02a6d..a40f2303c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1235,15 +1235,9 @@ importers: '@ctrlplane/validators': specifier: workspace:* version: link:../validators - '@date-fns/tz': - specifier: ^1.2.0 - version: 1.2.0 - date-fns: - specifier: ^4.1.0 - version: 4.1.0 - rrule: - specifier: ^2.8.1 - version: 2.8.1 + lodash: + specifier: 'catalog:' + version: 4.17.21 zod: specifier: 'catalog:' version: 3.24.2 From e85b5240b183e92709d5a63669fc8b6cc65693bb Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sat, 29 Mar 2025 23:48:13 -0400 Subject: [PATCH 08/22] clean up --- packages/release-manager/src/index.ts | 2 + packages/release-manager/src/manager.ts | 69 +++++++++++++++++++++++ packages/release-manager/src/releases.ts | 22 ++++---- packages/release-manager/src/variables.ts | 38 ++++++++++++- 4 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 packages/release-manager/src/manager.ts diff --git a/packages/release-manager/src/index.ts b/packages/release-manager/src/index.ts index a0c4ebf25..0e489f1db 100644 --- a/packages/release-manager/src/index.ts +++ b/packages/release-manager/src/index.ts @@ -1 +1,3 @@ export * from "./types.js"; +export * from "./variables.js"; +export * from "./manager.js"; diff --git a/packages/release-manager/src/manager.ts b/packages/release-manager/src/manager.ts new file mode 100644 index 000000000..815b7963e --- /dev/null +++ b/packages/release-manager/src/manager.ts @@ -0,0 +1,69 @@ +import type { Tx } from "@ctrlplane/db"; + +import { buildConflictUpdateColumns, takeFirstOrNull } from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import * as schema from "@ctrlplane/db/schema"; + +import type { ReleaseCreator } from "./releases.js"; +import { DatabaseReleaseCreator } from "./releases.js"; +import { VariableManager } from "./variables.js"; + +export type ReleaseManagerOptions = { + environmentId: string; + deploymentId: string; + resourceId: string; + + db?: Tx; +}; + +export class ReleaseManager { + private readonly releaseCreator: ReleaseCreator; + private variableManager: VariableManager | null = null; + + private db: Tx; + + constructor(private readonly options: ReleaseManagerOptions) { + this.db = options.db ?? db; + this.releaseCreator = new DatabaseReleaseCreator(options); + } + + async getCurrentVariables() { + if (this.variableManager === null) + this.variableManager = await VariableManager.database(this.options); + + return this.variableManager.getVariables(); + } + + async ensureRelease(versionId: string, opts?: { setAsDesired?: boolean }) { + const variables = await this.getCurrentVariables(); + const release = await this.releaseCreator.ensureRelease( + versionId, + variables, + ); + if (opts?.setAsDesired) await this.setDesiredRelease(release.id); + return release; + } + + async setDesiredRelease(desiredReleaseId: string) { + return this.db + .insert(schema.resourceRelease) + .values({ + environmentId: this.options.environmentId, + deploymentId: this.options.deploymentId, + resourceId: this.options.resourceId, + desiredReleaseId, + }) + .onConflictDoUpdate({ + target: [ + schema.resourceRelease.environmentId, + schema.resourceRelease.deploymentId, + schema.resourceRelease.resourceId, + ], + set: buildConflictUpdateColumns(schema.resourceRelease, [ + "desiredReleaseId", + ]), + }) + .returning() + .then(takeFirstOrNull); + } +} diff --git a/packages/release-manager/src/releases.ts b/packages/release-manager/src/releases.ts index 22d198e33..ab1c71743 100644 --- a/packages/release-manager/src/releases.ts +++ b/packages/release-manager/src/releases.ts @@ -13,31 +13,33 @@ type ReleaseManagerOptions = { resourceId: string; }; -export type ReleaseManager = { +type ReleaseWithId = Release & { id: string }; + +export type ReleaseCreator = { getLatestRelease(): Promise; createRelease( versionId: string, variables: MaybeVariable[], - ): Promise; + ): Promise; ensureRelease( versionId: string, variables: MaybeVariable[], - ): Promise; + ): Promise; }; -export abstract class BaseReleaseManager implements ReleaseManager { +export abstract class BaseReleaseCreator implements ReleaseCreator { constructor(protected options: ReleaseManagerOptions) {} - abstract getLatestRelease(): Promise; + abstract getLatestRelease(): Promise; abstract createRelease( versionId: string, variables: MaybeVariable[], - ): Promise; + ): Promise; async ensureRelease( versionId: string, variables: Variable[], - ): Promise { + ): Promise { const latestRelease = await this.getLatestRelease(); const latestR = { @@ -58,14 +60,14 @@ export abstract class BaseReleaseManager implements ReleaseManager { } } -export class DatabaseReleaseManager extends BaseReleaseManager { +export class DatabaseReleaseCreator extends BaseReleaseCreator { private db: Tx; constructor(protected options: ReleaseManagerOptions & { db?: Tx }) { super(options); this.db = options.db ?? db; } - async getLatestRelease(): Promise { + async getLatestRelease(): Promise { return this.db.query.release .findFirst({ where: and( @@ -84,7 +86,7 @@ export class DatabaseReleaseManager extends BaseReleaseManager { async createRelease( versionId: string, variables: Variable[], - ): Promise { + ): Promise { const release: Release = { resourceId: this.options.resourceId, deploymentId: this.options.deploymentId, diff --git a/packages/release-manager/src/variables.ts b/packages/release-manager/src/variables.ts index fb7982abc..406d31f66 100644 --- a/packages/release-manager/src/variables.ts +++ b/packages/release-manager/src/variables.ts @@ -1,15 +1,49 @@ +import type { Tx } from "@ctrlplane/db"; + +import { eq } from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import * as schema from "@ctrlplane/db/schema"; + import type { MaybeVariable, VariableProvider } from "./types"; +import { + DatabaseDeploymentVariableProvider, + DatabaseResourceVariableProvider, + DatabaseSystemVariableSetProvider, +} from "./db-variable-providers.js"; -type ReleaseManagerOptions = { +type VariableManagerOptions = { deploymentId: string; resourceId: string; + environmentId: string; keys: string[]; }; +type DatabaseVariableManagerOptions = Omit & { + db?: Tx; +}; + export class VariableManager { + static async database(options: DatabaseVariableManagerOptions) { + const { deploymentId } = options; + const tx = options.db ?? db; + const keys = await tx + .select({ key: schema.deploymentVariable.key }) + .from(schema.deploymentVariable) + .where(eq(schema.deploymentVariable.deploymentId, deploymentId)) + .then((results) => results.map((r) => r.key)); + + const providers = [ + new DatabaseResourceVariableProvider(options), + new DatabaseDeploymentVariableProvider(options), + new DatabaseSystemVariableSetProvider(options), + ]; + + return new VariableManager({ ...options, keys }, providers); + } + constructor( - private options: ReleaseManagerOptions, + private options: VariableManagerOptions, private variableProviders: VariableProvider[], ) {} From 3abedf703c047435b9577965766522d8f47a0688 Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sat, 29 Mar 2025 23:50:33 -0400 Subject: [PATCH 09/22] cleanup --- packages/release-manager/src/index.ts | 3 + packages/release-manager/src/manager.ts | 53 +++------ .../providers/variable-provider-factories.ts | 54 +++++++++ packages/release-manager/src/releases.ts | 106 ++++++------------ .../src/repositories/release-repository.ts | 62 ++++++++++ packages/release-manager/src/types.ts | 23 ++++ packages/release-manager/src/variables.ts | 60 ++++------ 7 files changed, 218 insertions(+), 143 deletions(-) create mode 100644 packages/release-manager/src/providers/variable-provider-factories.ts create mode 100644 packages/release-manager/src/repositories/release-repository.ts diff --git a/packages/release-manager/src/index.ts b/packages/release-manager/src/index.ts index 0e489f1db..a3ad0451b 100644 --- a/packages/release-manager/src/index.ts +++ b/packages/release-manager/src/index.ts @@ -1,3 +1,6 @@ export * from "./types.js"; export * from "./variables.js"; export * from "./manager.js"; +export * from "./releases.js"; +export * from "./repositories/release-repository.js"; +export * from "./providers/variable-provider-factories.js"; \ No newline at end of file diff --git a/packages/release-manager/src/manager.ts b/packages/release-manager/src/manager.ts index 815b7963e..ef727addd 100644 --- a/packages/release-manager/src/manager.ts +++ b/packages/release-manager/src/manager.ts @@ -1,35 +1,34 @@ import type { Tx } from "@ctrlplane/db"; - -import { buildConflictUpdateColumns, takeFirstOrNull } from "@ctrlplane/db"; import { db } from "@ctrlplane/db/client"; -import * as schema from "@ctrlplane/db/schema"; -import type { ReleaseCreator } from "./releases.js"; -import { DatabaseReleaseCreator } from "./releases.js"; +import { BaseReleaseCreator } from "./releases.js"; +import { DatabaseReleaseRepository } from "./repositories/release-repository.js"; +import type { ReleaseQueryOptions } from "./types.js"; import { VariableManager } from "./variables.js"; -export type ReleaseManagerOptions = { - environmentId: string; - deploymentId: string; - resourceId: string; - +export type ReleaseManagerOptions = ReleaseQueryOptions & { db?: Tx; }; export class ReleaseManager { - private readonly releaseCreator: ReleaseCreator; + private readonly releaseCreator: BaseReleaseCreator; private variableManager: VariableManager | null = null; - + private repository: DatabaseReleaseRepository; private db: Tx; constructor(private readonly options: ReleaseManagerOptions) { this.db = options.db ?? db; - this.releaseCreator = new DatabaseReleaseCreator(options); + this.repository = new DatabaseReleaseRepository(this.db); + this.releaseCreator = new BaseReleaseCreator(options) + .setRepository(this.repository); } async getCurrentVariables() { if (this.variableManager === null) - this.variableManager = await VariableManager.database(this.options); + this.variableManager = await VariableManager.database({ + ...this.options, + db: this.db, + }); return this.variableManager.getVariables(); } @@ -45,25 +44,9 @@ export class ReleaseManager { } async setDesiredRelease(desiredReleaseId: string) { - return this.db - .insert(schema.resourceRelease) - .values({ - environmentId: this.options.environmentId, - deploymentId: this.options.deploymentId, - resourceId: this.options.resourceId, - desiredReleaseId, - }) - .onConflictDoUpdate({ - target: [ - schema.resourceRelease.environmentId, - schema.resourceRelease.deploymentId, - schema.resourceRelease.resourceId, - ], - set: buildConflictUpdateColumns(schema.resourceRelease, [ - "desiredReleaseId", - ]), - }) - .returning() - .then(takeFirstOrNull); + return this.repository.setDesiredRelease({ + ...this.options, + desiredReleaseId, + }); } -} +} \ No newline at end of file diff --git a/packages/release-manager/src/providers/variable-provider-factories.ts b/packages/release-manager/src/providers/variable-provider-factories.ts new file mode 100644 index 000000000..41ca2132a --- /dev/null +++ b/packages/release-manager/src/providers/variable-provider-factories.ts @@ -0,0 +1,54 @@ +import type { Tx } from "@ctrlplane/db"; +import { eq } from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import * as schema from "@ctrlplane/db/schema"; + +import { + DatabaseDeploymentVariableProvider, + DatabaseResourceVariableProvider, + DatabaseSystemVariableSetProvider +} from "../db-variable-providers.js"; +import type { VariableProviderFactory, VariableProviderOptions } from "../types.js"; + +export class ResourceVariableProviderFactory implements VariableProviderFactory { + create(options: VariableProviderOptions) { + return new DatabaseResourceVariableProvider(options); + } +} + +export class DeploymentVariableProviderFactory implements VariableProviderFactory { + create(options: VariableProviderOptions) { + return new DatabaseDeploymentVariableProvider(options); + } +} + +export class SystemVariableSetProviderFactory implements VariableProviderFactory { + create(options: VariableProviderOptions) { + return new DatabaseSystemVariableSetProvider(options); + } +} + +export class DefaultVariableProviderRegistry { + private providers: VariableProviderFactory[] = [ + new ResourceVariableProviderFactory(), + new DeploymentVariableProviderFactory(), + new SystemVariableSetProviderFactory(), + ]; + + register(factory: VariableProviderFactory) { + this.providers.push(factory); + } + + getFactories() { + return [...this.providers]; + } +} + +export async function getDeploymentVariableKeys(options: { deploymentId: string, db?: Tx }): Promise { + const tx = options.db ?? db; + return tx + .select({ key: schema.deploymentVariable.key }) + .from(schema.deploymentVariable) + .where(eq(schema.deploymentVariable.deploymentId, options.deploymentId)) + .then((results) => results.map((r) => r.key)); +} \ No newline at end of file diff --git a/packages/release-manager/src/releases.ts b/packages/release-manager/src/releases.ts index ab1c71743..b714461bf 100644 --- a/packages/release-manager/src/releases.ts +++ b/packages/release-manager/src/releases.ts @@ -1,22 +1,12 @@ -import type { Tx } from "@ctrlplane/db"; import * as _ from "lodash"; -import { and, desc, eq, takeFirst } from "@ctrlplane/db"; -import { db } from "@ctrlplane/db/client"; -import * as schema from "@ctrlplane/db/schema"; - -import type { MaybeVariable, Release, Variable } from "./types"; - -type ReleaseManagerOptions = { - environmentId: string; - deploymentId: string; - resourceId: string; -}; +import type { MaybeVariable, Release, ReleaseQueryOptions, ReleaseRepository } from "./types.js"; +import { DatabaseReleaseRepository } from "./repositories/release-repository.js"; type ReleaseWithId = Release & { id: string }; export type ReleaseCreator = { - getLatestRelease(): Promise; + getLatestRelease(): Promise; createRelease( versionId: string, variables: MaybeVariable[], @@ -27,20 +17,40 @@ export type ReleaseCreator = { ): Promise; }; -export abstract class BaseReleaseCreator implements ReleaseCreator { - constructor(protected options: ReleaseManagerOptions) {} +export class BaseReleaseCreator implements ReleaseCreator { + constructor(protected options: ReleaseQueryOptions) {} - abstract getLatestRelease(): Promise; - abstract createRelease( - versionId: string, - variables: MaybeVariable[], - ): Promise; + protected repository: ReleaseRepository = new DatabaseReleaseRepository(); + + setRepository(repository: ReleaseRepository) { + this.repository = repository; + return this; + } + + async getLatestRelease() { + return this.repository.getLatestRelease(this.options); + } + + async createRelease(versionId: string, variables: MaybeVariable[]): Promise { + const nonNullVariables = variables.filter((v): v is NonNullable => v !== null); + + const release: Release = { + resourceId: this.options.resourceId, + deploymentId: this.options.deploymentId, + environmentId: this.options.environmentId, + versionId, + variables: nonNullVariables, + }; + + return this.repository.createRelease(release); + } async ensureRelease( versionId: string, - variables: Variable[], + variables: MaybeVariable[], ): Promise { const latestRelease = await this.getLatestRelease(); + const nonNullVariables = variables.filter((v): v is NonNullable => v !== null); const latestR = { versionId: latestRelease?.versionId, @@ -51,59 +61,11 @@ export abstract class BaseReleaseCreator implements ReleaseCreator { const newR = { versionId, - variables: Object.fromEntries(variables.map((v) => [v.key, v.value])), + variables: Object.fromEntries(nonNullVariables.map((v) => [v.key, v.value])), }; return latestRelease != null && _.isEqual(latestR, newR) ? latestRelease - : this.createRelease(versionId, variables); - } -} - -export class DatabaseReleaseCreator extends BaseReleaseCreator { - private db: Tx; - constructor(protected options: ReleaseManagerOptions & { db?: Tx }) { - super(options); - this.db = options.db ?? db; - } - - async getLatestRelease(): Promise { - return this.db.query.release - .findFirst({ - where: and( - eq(schema.release.resourceId, this.options.resourceId), - eq(schema.release.deploymentId, this.options.deploymentId), - eq(schema.release.environmentId, this.options.environmentId), - ), - with: { - variables: true, - }, - orderBy: desc(schema.release.createdAt), - }) - .then((r) => r ?? null); - } - - async createRelease( - versionId: string, - variables: Variable[], - ): Promise { - const release: Release = { - resourceId: this.options.resourceId, - deploymentId: this.options.deploymentId, - environmentId: this.options.environmentId, - versionId, - variables, - }; - - const dbRelease = await this.db - .insert(schema.release) - .values(release) - .returning() - .then(takeFirst); - - return { - ...release, - ...dbRelease, - }; + : this.createRelease(versionId, nonNullVariables); } -} +} \ No newline at end of file diff --git a/packages/release-manager/src/repositories/release-repository.ts b/packages/release-manager/src/repositories/release-repository.ts new file mode 100644 index 000000000..f96a771c8 --- /dev/null +++ b/packages/release-manager/src/repositories/release-repository.ts @@ -0,0 +1,62 @@ +import type { Tx } from "@ctrlplane/db"; +import { and, buildConflictUpdateColumns, desc, eq, takeFirst, takeFirstOrNull } from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import * as schema from "@ctrlplane/db/schema"; + +import type { Release, ReleaseQueryOptions, ReleaseRepository } from "../types.js"; + +export class DatabaseReleaseRepository implements ReleaseRepository { + constructor(private readonly db: Tx = db) {} + + async getLatestRelease(options: ReleaseQueryOptions) { + return this.db.query.release + .findFirst({ + where: and( + eq(schema.release.resourceId, options.resourceId), + eq(schema.release.deploymentId, options.deploymentId), + eq(schema.release.environmentId, options.environmentId), + ), + with: { + variables: true, + }, + orderBy: desc(schema.release.createdAt), + }) + .then((r) => r ?? null); + } + + async createRelease(release: Release) { + const dbRelease = await this.db + .insert(schema.release) + .values(release) + .returning() + .then(takeFirst); + + return { + ...release, + ...dbRelease, + }; + } + + async setDesiredRelease(options: ReleaseQueryOptions & { desiredReleaseId: string }) { + return this.db + .insert(schema.resourceRelease) + .values({ + environmentId: options.environmentId, + deploymentId: options.deploymentId, + resourceId: options.resourceId, + desiredReleaseId: options.desiredReleaseId, + }) + .onConflictDoUpdate({ + target: [ + schema.resourceRelease.environmentId, + schema.resourceRelease.deploymentId, + schema.resourceRelease.resourceId, + ], + set: buildConflictUpdateColumns(schema.resourceRelease, [ + "desiredReleaseId", + ]), + }) + .returning() + .then(takeFirstOrNull); + } +} \ No newline at end of file diff --git a/packages/release-manager/src/types.ts b/packages/release-manager/src/types.ts index 70e2ead81..abfce086d 100644 --- a/packages/release-manager/src/types.ts +++ b/packages/release-manager/src/types.ts @@ -19,3 +19,26 @@ export type MaybeVariable = Variable | null; export type VariableProvider = { getVariable(key: string): MaybePromise; }; + +export type VariableProviderFactory = { + create(options: VariableProviderOptions): VariableProvider; +}; + +export type VariableProviderOptions = { + resourceId: string; + deploymentId: string; + environmentId: string; + db?: any; +}; + +export interface ReleaseRepository { + getLatestRelease(options: ReleaseQueryOptions): Promise; + createRelease(release: Release): Promise; + setDesiredRelease(options: ReleaseQueryOptions & { desiredReleaseId: string }): Promise; +} + +export type ReleaseQueryOptions = { + environmentId: string; + deploymentId: string; + resourceId: string; +}; \ No newline at end of file diff --git a/packages/release-manager/src/variables.ts b/packages/release-manager/src/variables.ts index 406d31f66..41fbab499 100644 --- a/packages/release-manager/src/variables.ts +++ b/packages/release-manager/src/variables.ts @@ -1,43 +1,22 @@ -import type { Tx } from "@ctrlplane/db"; - -import { eq } from "@ctrlplane/db"; -import { db } from "@ctrlplane/db/client"; -import * as schema from "@ctrlplane/db/schema"; - -import type { MaybeVariable, VariableProvider } from "./types"; -import { - DatabaseDeploymentVariableProvider, - DatabaseResourceVariableProvider, - DatabaseSystemVariableSetProvider, -} from "./db-variable-providers.js"; - -type VariableManagerOptions = { - deploymentId: string; - resourceId: string; - environmentId: string; +import type { MaybeVariable, VariableProvider, VariableProviderOptions } from "./types.js"; +import { + DefaultVariableProviderRegistry, + getDeploymentVariableKeys +} from "./providers/variable-provider-factories.js"; +type VariableManagerOptions = VariableProviderOptions & { keys: string[]; }; -type DatabaseVariableManagerOptions = Omit & { - db?: Tx; -}; - export class VariableManager { - static async database(options: DatabaseVariableManagerOptions) { - const { deploymentId } = options; - const tx = options.db ?? db; - const keys = await tx - .select({ key: schema.deploymentVariable.key }) - .from(schema.deploymentVariable) - .where(eq(schema.deploymentVariable.deploymentId, deploymentId)) - .then((results) => results.map((r) => r.key)); - - const providers = [ - new DatabaseResourceVariableProvider(options), - new DatabaseDeploymentVariableProvider(options), - new DatabaseSystemVariableSetProvider(options), - ]; + static async database(options: VariableProviderOptions) { + const keys = await getDeploymentVariableKeys({ + deploymentId: options.deploymentId, + db: options.db + }); + + const registry = new DefaultVariableProviderRegistry(); + const providers = registry.getFactories().map(factory => factory.create(options)); return new VariableManager({ ...options, keys }, providers); } @@ -47,6 +26,15 @@ export class VariableManager { private variableProviders: VariableProvider[], ) {} + getProviders() { + return [...this.variableProviders]; + } + + addProvider(provider: VariableProvider) { + this.variableProviders.push(provider); + return this; + } + async getVariables(): Promise { return Promise.all(this.options.keys.map((key) => this.getVariable(key))); } @@ -58,4 +46,4 @@ export class VariableManager { } return null; } -} +} \ No newline at end of file From 2641360b9ebdeb3711c3d74da5e8be124e88d7b7 Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sat, 29 Mar 2025 23:56:06 -0400 Subject: [PATCH 10/22] cleanup --- .../src/db-variable-providers.ts | 13 +- packages/release-manager/src/manager.ts | 4 +- .../providers/variable-provider-factories.ts | 4 +- packages/release-manager/src/releases.ts | 8 +- .../src/repositories/release-repository.ts | 6 +- packages/release-manager/src/types.ts | 26 +- .../test/release-manager.test.ts | 243 ------------ .../test/variable-resolution.test.ts | 368 ------------------ 8 files changed, 25 insertions(+), 647 deletions(-) delete mode 100644 packages/release-manager/test/release-manager.test.ts delete mode 100644 packages/release-manager/test/variable-resolution.test.ts diff --git a/packages/release-manager/src/db-variable-providers.ts b/packages/release-manager/src/db-variable-providers.ts index 50eaccc57..27d074d0c 100644 --- a/packages/release-manager/src/db-variable-providers.ts +++ b/packages/release-manager/src/db-variable-providers.ts @@ -13,10 +13,9 @@ import { variableSetValue, } from "@ctrlplane/db/schema"; -import type { MaybeVariable, Variable, VariableProvider } from "./types"; +import type { MaybeVariable, ReleaseIdentifier, Variable, VariableProvider } from "./types"; -export type DatabaseResourceVariableOptions = { - resourceId: string; +export type DatabaseResourceVariableOptions = Pick & { db?: Tx; }; @@ -50,9 +49,7 @@ export class DatabaseResourceVariableProvider implements VariableProvider { } } -export type DatabaseDeploymentVariableOptions = { - resourceId: string; - deploymentId: string; +export type DatabaseDeploymentVariableOptions = Pick & { db?: Tx; }; @@ -128,9 +125,7 @@ export class DatabaseDeploymentVariableProvider implements VariableProvider { } } -export type DatabaseSystemVariableSetOptions = { - // systemId: string; - environmentId: string; +export type DatabaseSystemVariableSetOptions = Pick & { db?: Tx; }; diff --git a/packages/release-manager/src/manager.ts b/packages/release-manager/src/manager.ts index ef727addd..ba00512f4 100644 --- a/packages/release-manager/src/manager.ts +++ b/packages/release-manager/src/manager.ts @@ -3,10 +3,10 @@ import { db } from "@ctrlplane/db/client"; import { BaseReleaseCreator } from "./releases.js"; import { DatabaseReleaseRepository } from "./repositories/release-repository.js"; -import type { ReleaseQueryOptions } from "./types.js"; +import type { ReleaseIdentifier } from "./types.js"; import { VariableManager } from "./variables.js"; -export type ReleaseManagerOptions = ReleaseQueryOptions & { +export type ReleaseManagerOptions = ReleaseIdentifier & { db?: Tx; }; diff --git a/packages/release-manager/src/providers/variable-provider-factories.ts b/packages/release-manager/src/providers/variable-provider-factories.ts index 41ca2132a..b0a839d23 100644 --- a/packages/release-manager/src/providers/variable-provider-factories.ts +++ b/packages/release-manager/src/providers/variable-provider-factories.ts @@ -8,7 +8,7 @@ import { DatabaseResourceVariableProvider, DatabaseSystemVariableSetProvider } from "../db-variable-providers.js"; -import type { VariableProviderFactory, VariableProviderOptions } from "../types.js"; +import type { ReleaseIdentifier, VariableProviderFactory, VariableProviderOptions } from "../types.js"; export class ResourceVariableProviderFactory implements VariableProviderFactory { create(options: VariableProviderOptions) { @@ -44,7 +44,7 @@ export class DefaultVariableProviderRegistry { } } -export async function getDeploymentVariableKeys(options: { deploymentId: string, db?: Tx }): Promise { +export async function getDeploymentVariableKeys(options: Pick & { db?: Tx }): Promise { const tx = options.db ?? db; return tx .select({ key: schema.deploymentVariable.key }) diff --git a/packages/release-manager/src/releases.ts b/packages/release-manager/src/releases.ts index b714461bf..f11066fac 100644 --- a/packages/release-manager/src/releases.ts +++ b/packages/release-manager/src/releases.ts @@ -1,6 +1,6 @@ import * as _ from "lodash"; -import type { MaybeVariable, Release, ReleaseQueryOptions, ReleaseRepository } from "./types.js"; +import type { MaybeVariable, Release, ReleaseIdentifier, ReleaseRepository } from "./types.js"; import { DatabaseReleaseRepository } from "./repositories/release-repository.js"; type ReleaseWithId = Release & { id: string }; @@ -18,7 +18,7 @@ export type ReleaseCreator = { }; export class BaseReleaseCreator implements ReleaseCreator { - constructor(protected options: ReleaseQueryOptions) {} + constructor(protected options: ReleaseIdentifier) {} protected repository: ReleaseRepository = new DatabaseReleaseRepository(); @@ -35,9 +35,7 @@ export class BaseReleaseCreator implements ReleaseCreator { const nonNullVariables = variables.filter((v): v is NonNullable => v !== null); const release: Release = { - resourceId: this.options.resourceId, - deploymentId: this.options.deploymentId, - environmentId: this.options.environmentId, + ...this.options, versionId, variables: nonNullVariables, }; diff --git a/packages/release-manager/src/repositories/release-repository.ts b/packages/release-manager/src/repositories/release-repository.ts index f96a771c8..3f90f61b7 100644 --- a/packages/release-manager/src/repositories/release-repository.ts +++ b/packages/release-manager/src/repositories/release-repository.ts @@ -3,12 +3,12 @@ import { and, buildConflictUpdateColumns, desc, eq, takeFirst, takeFirstOrNull } import { db } from "@ctrlplane/db/client"; import * as schema from "@ctrlplane/db/schema"; -import type { Release, ReleaseQueryOptions, ReleaseRepository } from "../types.js"; +import type { Release, ReleaseIdentifier, ReleaseRepository } from "../types.js"; export class DatabaseReleaseRepository implements ReleaseRepository { constructor(private readonly db: Tx = db) {} - async getLatestRelease(options: ReleaseQueryOptions) { + async getLatestRelease(options: ReleaseIdentifier) { return this.db.query.release .findFirst({ where: and( @@ -37,7 +37,7 @@ export class DatabaseReleaseRepository implements ReleaseRepository { }; } - async setDesiredRelease(options: ReleaseQueryOptions & { desiredReleaseId: string }) { + async setDesiredRelease(options: ReleaseIdentifier & { desiredReleaseId: string }) { return this.db .insert(schema.resourceRelease) .values({ diff --git a/packages/release-manager/src/types.ts b/packages/release-manager/src/types.ts index abfce086d..3eb59e235 100644 --- a/packages/release-manager/src/types.ts +++ b/packages/release-manager/src/types.ts @@ -1,3 +1,9 @@ +export type ReleaseIdentifier = { + environmentId: string; + deploymentId: string; + resourceId: string; +}; + export type Variable = { id: string; key: string; @@ -5,10 +11,7 @@ export type Variable = { sensitive: boolean; }; -export type Release = { - resourceId: string; - deploymentId: string; - environmentId: string; +export type Release = ReleaseIdentifier & { versionId: string; variables: Variable[]; }; @@ -24,21 +27,14 @@ export type VariableProviderFactory = { create(options: VariableProviderOptions): VariableProvider; }; -export type VariableProviderOptions = { - resourceId: string; - deploymentId: string; - environmentId: string; +export type VariableProviderOptions = ReleaseIdentifier & { db?: any; }; export interface ReleaseRepository { - getLatestRelease(options: ReleaseQueryOptions): Promise; + getLatestRelease(options: ReleaseIdentifier): Promise; createRelease(release: Release): Promise; - setDesiredRelease(options: ReleaseQueryOptions & { desiredReleaseId: string }): Promise; + setDesiredRelease(options: ReleaseIdentifier & { desiredReleaseId: string }): Promise; } -export type ReleaseQueryOptions = { - environmentId: string; - deploymentId: string; - resourceId: string; -}; \ No newline at end of file +export type ReleaseQueryOptions = ReleaseIdentifier; \ No newline at end of file diff --git a/packages/release-manager/test/release-manager.test.ts b/packages/release-manager/test/release-manager.test.ts deleted file mode 100644 index 18d417bac..000000000 --- a/packages/release-manager/test/release-manager.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { InMemoryReleaseStorage, ReleaseManager } from "../src"; -import { - LogVariableChangeAction, - LogVersionChangeAction, - VariableChangedCondition, - VersionChangedCondition, -} from "../src"; -import { RuleEngine } from "../src"; - -// Mock for generateId -const generateId = () => `id-${Math.floor(Math.random() * 1000)}`; - -describe("ReleaseManager", () => { - test("should create a release for a variable change", async () => { - // Setup - const storage = new InMemoryReleaseStorage(); - const releaseManager = new ReleaseManager({ storage, generateId }); - - // Create a variable and release - const variable = { - id: "var-1", - name: "test-variable", - value: "test-value", - updatedAt: new Date(), - }; - - // Set the variables in storage - storage.setVariables([variable]); - - // Create a release - const release = await releaseManager.createReleaseForVariable(variable); - - // Assertions - expect(release).not.toBeNull(); - expect(release?.triggerType).toBe("variable"); - expect(release?.triggerId).toBe(variable.id); - - // Verify idempotency - creating again should return the same release - const sameRelease = await releaseManager.createReleaseForVariable(variable); - expect(sameRelease?.id).toBe(release?.id); - }); - - test("should create a release for a version change", async () => { - // Setup - const storage = new InMemoryReleaseStorage(); - const releaseManager = new ReleaseManager({ storage, generateId }); - - // Create a version and release - const version = { - id: "ver-1", - version: "1.0.0", - updatedAt: new Date(), - }; - - // Set the versions in storage - storage.setVersions([version]); - - // Create a release - const release = await releaseManager.createReleaseForVersion(version); - - // Assertions - expect(release).not.toBeNull(); - expect(release?.triggerType).toBe("version"); - expect(release?.triggerId).toBe(version.id); - - // Verify idempotency - creating again should return the same release - const sameRelease = await releaseManager.createReleaseForVersion(version); - expect(sameRelease?.id).toBe(release?.id); - }); -}); - -describe("RuleEngine", () => { - test("should process release through rules", async () => { - // Setup - const storage = new InMemoryReleaseStorage(); - const releaseManager = new ReleaseManager({ storage, generateId }); - - // Create a version and release - const version = { - id: "ver-1", - version: "2.0.0", - updatedAt: new Date(), - }; - - // Set up version in storage - storage.setVersions([version]); - - // Create a mock action for testing - const mockAction = { execute: vi.fn() }; - - // Create a rule engine - const ruleEngine = new RuleEngine({ - rules: [ - { - id: "rule-1", - name: "Test Version Rule", - condition: new VersionChangedCondition(), - action: mockAction, - }, - ], - }); - - // Create a release - const release = await releaseManager.createReleaseForVersion(version); - if (!release) throw new Error("Failed to create release"); - - // Process the release - await ruleEngine.processRelease(release, undefined, version); - - // Assertions - expect(mockAction.execute).toHaveBeenCalledTimes(1); - expect(mockAction.execute).toHaveBeenCalledWith({ - release, - version, - context: {}, - }); - }); - - test("should only execute actions for matching conditions", async () => { - // Setup - const storage = new InMemoryReleaseStorage(); - const releaseManager = new ReleaseManager({ storage, generateId }); - - // Create a variable - const variable = { - id: "var-1", - name: "test-variable", - value: "test-value", - updatedAt: new Date(), - }; - - // Set the variable in storage - storage.setVariables([variable]); - - // Create mock actions for testing - const mockVariableAction = { execute: vi.fn() }; - const mockVersionAction = { execute: vi.fn() }; - - // Create a rule engine with both rules - const ruleEngine = new RuleEngine({ - rules: [ - { - id: "rule-1", - name: "Variable Rule", - condition: new VariableChangedCondition(), - action: mockVariableAction, - }, - { - id: "rule-2", - name: "Version Rule", - condition: new VersionChangedCondition(), - action: mockVersionAction, - }, - ], - }); - - // Create a variable release - const release = await releaseManager.createReleaseForVariable(variable); - if (!release) throw new Error("Failed to create release"); - - // Process the release - await ruleEngine.processRelease(release, variable); - - // Assertions - only the variable action should have been called - expect(mockVariableAction.execute).toHaveBeenCalledTimes(1); - expect(mockVersionAction.execute).not.toHaveBeenCalled(); - }); -}); - -describe("Integration test", () => { - test("should implement a complete release workflow", async () => { - // Setup storage - const storage = new InMemoryReleaseStorage(); - - // Setup release manager - const releaseManager = new ReleaseManager({ storage, generateId }); - - // Set up variables and versions - const variable = { - id: "config-1", - name: "API_URL", - value: "https://api.example.com", - updatedAt: new Date(), - }; - - const version = { - id: "app-version", - version: "1.2.0", - updatedAt: new Date(), - }; - - // Initialize storage - storage.setVariables([variable]); - storage.setVersions([version]); - - // Create action spies - const variableLogSpy = vi.spyOn(console, "log"); - const versionLogSpy = vi.spyOn(console, "log"); - - // Setup rule engine - const ruleEngine = new RuleEngine({ - rules: [ - { - id: "variable-log-rule", - name: "Log Variable Changes", - condition: new VariableChangedCondition(), - action: new LogVariableChangeAction(), - }, - { - id: "version-log-rule", - name: "Log Version Changes", - condition: new VersionChangedCondition(), - action: new LogVersionChangeAction(), - }, - ], - }); - - // Create variable release - const varRelease = await releaseManager.createReleaseForVariable(variable); - expect(varRelease).not.toBeNull(); - - // Process variable release through rules - await ruleEngine.processRelease(varRelease!, variable); - expect(variableLogSpy).toHaveBeenCalledWith( - expect.stringContaining("Variable changed: API_URL") - ); - - // Create version release - const verRelease = await releaseManager.createReleaseForVersion(version); - expect(verRelease).not.toBeNull(); - - // Process version release through rules - await ruleEngine.processRelease(verRelease!, undefined, version); - expect(versionLogSpy).toHaveBeenCalledWith( - expect.stringContaining("Version changed: app-version = 1.2.0") - ); - - // Verify releases were saved in storage - const allReleases = await releaseManager.getReleases(); - expect(allReleases.length).toBe(2); - }); -}); diff --git a/packages/release-manager/test/variable-resolution.test.ts b/packages/release-manager/test/variable-resolution.test.ts deleted file mode 100644 index 85cdf826e..000000000 --- a/packages/release-manager/test/variable-resolution.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -import { describe, expect, test, vi } from "vitest"; -import { InMemoryReleaseStorage, ReleaseManager } from "../src"; -import { - ContextSpecificCondition, - LogVariableChangeAction, - VariableChangedCondition, -} from "../src"; -import { RuleEngine } from "../src"; - -// Mock for generateId -const generateId = () => `id-${Math.floor(Math.random() * 1000)}`; - -describe("Variable Resolution", () => { - test("should resolve variables in the correct priority order", async () => { - // Setup - const storage = new InMemoryReleaseStorage(); - const releaseManager = new ReleaseManager({ storage, generateId }); - - // Setup resources, deployments, environments - const environment = { - id: "env-1", - name: "Production", - updatedAt: new Date(), - }; - - const resource = { - id: "res-1", - name: "API Server", - labels: { - type: "backend", - tier: "api", - }, - environmentId: "env-1", - updatedAt: new Date(), - }; - - const deployment = { - id: "deploy-1", - name: "Backend Deployment", - selectors: [ - { key: "type", value: "backend" }, - ], - updatedAt: new Date(), - }; - - // Setup variables at different levels - const standardVariable = { - id: "var-1", - type: "variable" as const, - name: "API_URL", - value: "https://api.example.com", - updatedAt: new Date(), - }; - - const deploymentVariable = { - id: "var-2", - type: "deploymentVariable" as const, - name: "API_URL", - value: "https://api.staging.example.com", - selectors: [ - { key: "type", value: "backend" }, - ], - deploymentId: "deploy-1", - updatedAt: new Date(), - }; - - const resourceVariable = { - id: "var-3", - type: "resourceVariable" as const, - name: "API_URL", - value: "https://api.prod.example.com", - resourceId: "res-1", - environmentId: "env-1", - updatedAt: new Date(), - }; - - // Also create a variable that only exists at standard level - const standardOnlyVariable = { - id: "var-4", - type: "variable" as const, - name: "LOG_LEVEL", - value: "info", - updatedAt: new Date(), - }; - - // Initialize storage - storage.setEnvironments([environment]); - storage.setResources([resource]); - storage.setDeployments([deployment]); - storage.setVariables([standardVariable, standardOnlyVariable]); - storage.setDeploymentVariables([deploymentVariable]); - storage.setResourceVariables([resourceVariable]); - - // Create a context for resolution - const context = { - resourceId: "res-1", - environmentId: "env-1", - deploymentId: "deploy-1", - }; - - // Test resource variable (highest priority) - const resolvedApiUrl = await releaseManager.getVariable("API_URL", context); - expect(resolvedApiUrl).not.toBeNull(); - expect(resolvedApiUrl?.value).toBe("https://api.prod.example.com"); - expect(resolvedApiUrl?.type).toBe("resourceVariable"); - - // Test standard variable (fallback when no higher priority exists) - const resolvedLogLevel = await releaseManager.getVariable("LOG_LEVEL", context); - expect(resolvedLogLevel).not.toBeNull(); - expect(resolvedLogLevel?.value).toBe("info"); - expect(resolvedLogLevel?.type).toBe("variable"); - - // Test all variables for the context - const allVariables = await releaseManager.getVariablesForContext(context); - expect(allVariables.length).toBe(2); // API_URL and LOG_LEVEL - - // Verify we get the highest priority for each name - const apiUrlVar = allVariables.find(v => v.name === "API_URL"); - expect(apiUrlVar).not.toBeNull(); - expect(apiUrlVar?.type).toBe("resourceVariable"); - expect(apiUrlVar?.value).toBe("https://api.prod.example.com"); - - const logLevelVar = allVariables.find(v => v.name === "LOG_LEVEL"); - expect(logLevelVar).not.toBeNull(); - expect(logLevelVar?.type).toBe("variable"); - expect(logLevelVar?.value).toBe("info"); - - // Test with a context that has no resource variable - const newContext = { - resourceId: "res-2", // Different resource - environmentId: "env-1", - deploymentId: "deploy-1", - }; - - // Should fall back to deployment variable - const fallbackApiUrl = await releaseManager.getVariable("API_URL", newContext); - expect(fallbackApiUrl).toBeNull(); // Since res-2 doesn't exist, and resource labels can't be checked - - // Create a resource with matching labels for deployment variable - const newResource = { - id: "res-2", - name: "Secondary API", - labels: { - type: "backend", // Matches the deployment selector - tier: "secondary", - }, - environmentId: "env-1", - updatedAt: new Date(), - }; - - storage.setResources([...storage.getResources(), newResource]); - - // Now should resolve to deployment variable - const deploymentApiUrl = await releaseManager.getVariable("API_URL", { - ...newContext, - resource: newResource, - }); - - expect(deploymentApiUrl).not.toBeNull(); - expect(deploymentApiUrl?.type).toBe("deploymentVariable"); - expect(deploymentApiUrl?.value).toBe("https://api.staging.example.com"); - }); - - test("should create releases with context-specific variables", async () => { - // Setup - const storage = new InMemoryReleaseStorage(); - const releaseManager = new ReleaseManager({ storage, generateId }); - - // Setup resource and variables - const resource = { - id: "res-1", - name: "API Server", - labels: { - type: "backend", - }, - environmentId: "env-1", - updatedAt: new Date(), - }; - - const resourceVariable = { - id: "var-1", - type: "resourceVariable" as const, - name: "API_URL", - value: "https://api.prod.example.com", - resourceId: "res-1", - environmentId: "env-1", - updatedAt: new Date(), - }; - - // Initialize storage - storage.setResources([resource]); - storage.setResourceVariables([resourceVariable]); - - // Create a release for the variable in a specific context - const context = { - resourceId: "res-1", - environmentId: "env-1", - resource: resource, - }; - - const release = await releaseManager.createReleaseForVariable("API_URL", context); - - // Check release metadata - expect(release).not.toBeNull(); - expect(release?.triggerType).toBe("variable"); - expect(release?.triggerId).toBe("API_URL"); - expect(release?.resourceId).toBe("res-1"); - expect(release?.environmentId).toBe("env-1"); - expect(release?.metadata?.variableType).toBe("resourceVariable"); - expect(release?.metadata?.variableName).toBe("API_URL"); - expect(release?.metadata?.variableValue).toBe("https://api.prod.example.com"); - - // Update the variable value - const updatedVariable = { - ...resourceVariable, - value: "https://api.v2.prod.example.com", - updatedAt: new Date(Date.now() + 1000), // Ensure it's newer - }; - - storage.setResourceVariables([updatedVariable]); - - // Create another release - should be a new one due to the value change - const newRelease = await releaseManager.createReleaseForVariable("API_URL", context); - - expect(newRelease).not.toBeNull(); - expect(newRelease?.id).not.toBe(release?.id); - expect(newRelease?.metadata?.variableValue).toBe("https://api.v2.prod.example.com"); - - // Get releases for this context - const contextReleases = await releaseManager.getReleases(context); - expect(contextReleases.length).toBe(2); - - // Should be sorted by date (newest first) - expect(contextReleases[0].id).toBe(newRelease?.id); - }); - - test("should process releases with context-specific rules", async () => { - // Setup - const storage = new InMemoryReleaseStorage(); - const releaseManager = new ReleaseManager({ storage, generateId }); - - // Setup resources and variables - const prodEnv = { - id: "env-prod", - name: "Production", - updatedAt: new Date(), - }; - - const stagingEnv = { - id: "env-staging", - name: "Staging", - updatedAt: new Date(), - }; - - const prodResource = { - id: "res-prod", - name: "Production API", - labels: { environment: "production" }, - environmentId: "env-prod", - updatedAt: new Date(), - }; - - const stagingResource = { - id: "res-staging", - name: "Staging API", - labels: { environment: "staging" }, - environmentId: "env-staging", - updatedAt: new Date(), - }; - - const prodVariable = { - id: "var-prod", - type: "resourceVariable" as const, - name: "FEATURE_FLAG", - value: true, - resourceId: "res-prod", - environmentId: "env-prod", - updatedAt: new Date(), - }; - - const stagingVariable = { - id: "var-staging", - type: "resourceVariable" as const, - name: "FEATURE_FLAG", - value: true, - resourceId: "res-staging", - environmentId: "env-staging", - updatedAt: new Date(), - }; - - // Initialize storage - storage.setEnvironments([prodEnv, stagingEnv]); - storage.setResources([prodResource, stagingResource]); - storage.setResourceVariables([prodVariable, stagingVariable]); - - // Setup rule engine with context-specific conditions - const prodLogSpy = vi.fn(); - const stagingLogSpy = vi.fn(); - - // Production-specific rule - const prodRule = { - id: "rule-prod", - name: "Production Feature Flag Rule", - condition: new ContextSpecificCondition("res-prod", "env-prod"), - action: { - execute: async () => { - prodLogSpy(); - console.log("Production feature flag enabled"); - }, - }, - }; - - // Staging-specific rule - const stagingRule = { - id: "rule-staging", - name: "Staging Feature Flag Rule", - condition: new ContextSpecificCondition("res-staging", "env-staging"), - action: { - execute: async () => { - stagingLogSpy(); - console.log("Staging feature flag enabled"); - }, - }, - }; - - const ruleEngine = new RuleEngine({ - rules: [prodRule, stagingRule], - }); - - // Create releases for both environments - const prodContext = { - resourceId: "res-prod", - environmentId: "env-prod", - resource: prodResource, - environment: prodEnv, - }; - - const stagingContext = { - resourceId: "res-staging", - environmentId: "env-staging", - resource: stagingResource, - environment: stagingEnv, - }; - - const prodRelease = await releaseManager.createReleaseForVariable("FEATURE_FLAG", prodContext); - const stagingRelease = await releaseManager.createReleaseForVariable("FEATURE_FLAG", stagingContext); - - expect(prodRelease).not.toBeNull(); - expect(stagingRelease).not.toBeNull(); - - // Process the production release - await ruleEngine.processRelease(prodRelease!, prodVariable, undefined, prodContext); - - // Only the production rule should have been executed - expect(prodLogSpy).toHaveBeenCalledTimes(1); - expect(stagingLogSpy).not.toHaveBeenCalled(); - - // Reset mocks - vi.resetAllMocks(); - - // Process the staging release - await ruleEngine.processRelease(stagingRelease!, stagingVariable, undefined, stagingContext); - - // Only the staging rule should have been executed - expect(stagingLogSpy).toHaveBeenCalledTimes(1); - expect(prodLogSpy).not.toHaveBeenCalled(); - }); -}); \ No newline at end of file From b764f7cd9de7fc8095de2286122b055e4cb315f6 Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sun, 30 Mar 2025 00:03:29 -0400 Subject: [PATCH 11/22] cleanup --- .../providers/variable-provider-factories.ts | 54 ------------------- .../src/repositories/release-repository.ts | 26 ++++++--- packages/release-manager/src/variables.ts | 48 ++++++++++++----- 3 files changed, 55 insertions(+), 73 deletions(-) delete mode 100644 packages/release-manager/src/providers/variable-provider-factories.ts diff --git a/packages/release-manager/src/providers/variable-provider-factories.ts b/packages/release-manager/src/providers/variable-provider-factories.ts deleted file mode 100644 index b0a839d23..000000000 --- a/packages/release-manager/src/providers/variable-provider-factories.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { Tx } from "@ctrlplane/db"; -import { eq } from "@ctrlplane/db"; -import { db } from "@ctrlplane/db/client"; -import * as schema from "@ctrlplane/db/schema"; - -import { - DatabaseDeploymentVariableProvider, - DatabaseResourceVariableProvider, - DatabaseSystemVariableSetProvider -} from "../db-variable-providers.js"; -import type { ReleaseIdentifier, VariableProviderFactory, VariableProviderOptions } from "../types.js"; - -export class ResourceVariableProviderFactory implements VariableProviderFactory { - create(options: VariableProviderOptions) { - return new DatabaseResourceVariableProvider(options); - } -} - -export class DeploymentVariableProviderFactory implements VariableProviderFactory { - create(options: VariableProviderOptions) { - return new DatabaseDeploymentVariableProvider(options); - } -} - -export class SystemVariableSetProviderFactory implements VariableProviderFactory { - create(options: VariableProviderOptions) { - return new DatabaseSystemVariableSetProvider(options); - } -} - -export class DefaultVariableProviderRegistry { - private providers: VariableProviderFactory[] = [ - new ResourceVariableProviderFactory(), - new DeploymentVariableProviderFactory(), - new SystemVariableSetProviderFactory(), - ]; - - register(factory: VariableProviderFactory) { - this.providers.push(factory); - } - - getFactories() { - return [...this.providers]; - } -} - -export async function getDeploymentVariableKeys(options: Pick & { db?: Tx }): Promise { - const tx = options.db ?? db; - return tx - .select({ key: schema.deploymentVariable.key }) - .from(schema.deploymentVariable) - .where(eq(schema.deploymentVariable.deploymentId, options.deploymentId)) - .then((results) => results.map((r) => r.key)); -} \ No newline at end of file diff --git a/packages/release-manager/src/repositories/release-repository.ts b/packages/release-manager/src/repositories/release-repository.ts index 3f90f61b7..c5867a2bb 100644 --- a/packages/release-manager/src/repositories/release-repository.ts +++ b/packages/release-manager/src/repositories/release-repository.ts @@ -1,12 +1,24 @@ import type { Tx } from "@ctrlplane/db"; -import { and, buildConflictUpdateColumns, desc, eq, takeFirst, takeFirstOrNull } from "@ctrlplane/db"; -import { db } from "@ctrlplane/db/client"; + +import { + and, + buildConflictUpdateColumns, + desc, + eq, + takeFirst, + takeFirstOrNull, +} from "@ctrlplane/db"; +import { db as dbClient } from "@ctrlplane/db/client"; import * as schema from "@ctrlplane/db/schema"; -import type { Release, ReleaseIdentifier, ReleaseRepository } from "../types.js"; +import type { + Release, + ReleaseIdentifier, + ReleaseRepository, +} from "../types.js"; export class DatabaseReleaseRepository implements ReleaseRepository { - constructor(private readonly db: Tx = db) {} + constructor(private readonly db: Tx = dbClient) {} async getLatestRelease(options: ReleaseIdentifier) { return this.db.query.release @@ -37,7 +49,9 @@ export class DatabaseReleaseRepository implements ReleaseRepository { }; } - async setDesiredRelease(options: ReleaseIdentifier & { desiredReleaseId: string }) { + async setDesiredRelease( + options: ReleaseIdentifier & { desiredReleaseId: string }, + ) { return this.db .insert(schema.resourceRelease) .values({ @@ -59,4 +73,4 @@ export class DatabaseReleaseRepository implements ReleaseRepository { .returning() .then(takeFirstOrNull); } -} \ No newline at end of file +} diff --git a/packages/release-manager/src/variables.ts b/packages/release-manager/src/variables.ts index 41fbab499..d74c6523f 100644 --- a/packages/release-manager/src/variables.ts +++ b/packages/release-manager/src/variables.ts @@ -1,8 +1,31 @@ -import type { MaybeVariable, VariableProvider, VariableProviderOptions } from "./types.js"; -import { - DefaultVariableProviderRegistry, - getDeploymentVariableKeys -} from "./providers/variable-provider-factories.js"; +import type { Tx } from "@ctrlplane/db"; + +import { eq } from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import * as schema from "@ctrlplane/db/schema"; + +import type { + MaybeVariable, + VariableProvider, + VariableProviderOptions, +} from "./types.js"; +import { + DatabaseDeploymentVariableProvider, + DatabaseResourceVariableProvider, + DatabaseSystemVariableSetProvider, +} from "./db-variable-providers.js"; + +const getDeploymentVariableKeys = async (options: { + deploymentId: string; + db?: Tx; +}): Promise => { + const tx = options.db ?? db; + return tx + .select({ key: schema.deploymentVariable.key }) + .from(schema.deploymentVariable) + .where(eq(schema.deploymentVariable.deploymentId, options.deploymentId)) + .then((results) => results.map((r) => r.key)); +}; type VariableManagerOptions = VariableProviderOptions & { keys: string[]; @@ -10,14 +33,13 @@ type VariableManagerOptions = VariableProviderOptions & { export class VariableManager { static async database(options: VariableProviderOptions) { - const keys = await getDeploymentVariableKeys({ - deploymentId: options.deploymentId, - db: options.db - }); - - const registry = new DefaultVariableProviderRegistry(); - const providers = registry.getFactories().map(factory => factory.create(options)); + const providers = [ + new DatabaseSystemVariableSetProvider(options), + new DatabaseResourceVariableProvider(options), + new DatabaseDeploymentVariableProvider(options), + ]; + const keys = await getDeploymentVariableKeys(options); return new VariableManager({ ...options, keys }, providers); } @@ -46,4 +68,4 @@ export class VariableManager { } return null; } -} \ No newline at end of file +} From 2d579e694146fce97a4962d43fe65cb85949a89d Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sun, 30 Mar 2025 00:15:52 -0400 Subject: [PATCH 12/22] clean up --- packages/release-manager/README.md | 58 ++++++++++--------- .../src/db-variable-providers.ts | 24 ++++++-- packages/release-manager/src/index.ts | 1 - packages/release-manager/src/manager.ts | 12 ++-- packages/release-manager/src/releases.ts | 28 ++++++--- packages/release-manager/src/types.ts | 10 +++- 6 files changed, 86 insertions(+), 47 deletions(-) diff --git a/packages/release-manager/README.md b/packages/release-manager/README.md index e93f4dfd4..3f1b2f2f5 100644 --- a/packages/release-manager/README.md +++ b/packages/release-manager/README.md @@ -35,9 +35,13 @@ When resolving a variable value, the framework checks each level in order and re ### Creating Context-Specific Releases ```typescript -import { ReleaseManager, InMemoryReleaseStorage } from "@ctrlplane/release-manager"; import { randomUUID } from "crypto"; +import { + InMemoryReleaseStorage, + ReleaseManager, +} from "@ctrlplane/release-manager"; + // Setup storage and release manager const storage = new InMemoryReleaseStorage(); const releaseManager = new ReleaseManager({ @@ -63,7 +67,7 @@ const resourceVariable = { name: "API_URL", value: "https://api.prod.example.com", resourceId: "app-server-1", - environmentId: "production", + environmentId: "production", updatedAt: new Date(), }; @@ -74,11 +78,14 @@ storage.setResourceVariables([resourceVariable]); const context = { resourceId: "app-server-1", environmentId: "production", - resource: resource + resource: resource, }; // Create a release for the variable in this specific context -const release = await releaseManager.createReleaseForVariable("API_URL", context); +const release = await releaseManager.createReleaseForVariable( + "API_URL", + context, +); console.log(`Release created: ${release.id}`); ``` @@ -92,7 +99,7 @@ storage.setVariables([ type: "variable", name: "DEBUG", value: false, - } + }, ]); storage.setDeploymentVariables([ @@ -102,8 +109,8 @@ storage.setDeploymentVariables([ name: "DEBUG", value: true, deploymentId: "backend-deploy", - selectors: [{ key: "type", value: "app" }] - } + selectors: [{ key: "type", value: "app" }], + }, ]); // Resolve a variable in a specific context @@ -129,7 +136,10 @@ const version = { storage.setVersions([version]); // Create a release for the version change in a specific context -const versionRelease = await releaseManager.createReleaseForVersion(version, context); +const versionRelease = await releaseManager.createReleaseForVersion( + version, + context, +); console.log(`Version release created: ${versionRelease.id}`); ``` @@ -137,18 +147,20 @@ console.log(`Version release created: ${versionRelease.id}`); ```typescript import { - RuleEngine, ContextSpecificCondition, - VersionChangedCondition, + RuleEngine, SemverCondition, TriggerDeploymentAction, + VersionChangedCondition, } from "@ctrlplane/release-manager"; // Create a deployment service (mock) const deploymentService = { triggerDeployment: async (props) => { - console.log(`Deploying ${props.version} to ${props.resourceId} in ${props.environmentId}`); - } + console.log( + `Deploying ${props.version} to ${props.resourceId} in ${props.environmentId}`, + ); + }, }; // Create environment-specific rules @@ -161,15 +173,14 @@ const ruleEngine = new RuleEngine({ condition: { async evaluate(props) { const isProd = new ContextSpecificCondition( - undefined, "production" - ).evaluate(props); - - const isMajorVersion = new SemverCondition( - "^2.0.0" + undefined, + "production", ).evaluate(props); - + + const isMajorVersion = new SemverCondition("^2.0.0").evaluate(props); + return (await isProd) && (await isMajorVersion); - } + }, }, action: new TriggerDeploymentAction(deploymentService), }, @@ -177,12 +188,7 @@ const ruleEngine = new RuleEngine({ }); // Process the version release through rules -await ruleEngine.processRelease( - versionRelease, - undefined, - version, - context -); +await ruleEngine.processRelease(versionRelease, undefined, version, context); ``` ### Database Integration @@ -191,7 +197,7 @@ await ruleEngine.processRelease( import { DatabaseReleaseStorage } from "@ctrlplane/release-manager"; // Assuming you have a database client from @ctrlplane/db -const dbClient = createDbClient(); +const dbClient = createDbClient(); // Create a database-backed storage const dbStorage = new DatabaseReleaseStorage(dbClient); diff --git a/packages/release-manager/src/db-variable-providers.ts b/packages/release-manager/src/db-variable-providers.ts index 27d074d0c..5fe62eaf3 100644 --- a/packages/release-manager/src/db-variable-providers.ts +++ b/packages/release-manager/src/db-variable-providers.ts @@ -13,9 +13,17 @@ import { variableSetValue, } from "@ctrlplane/db/schema"; -import type { MaybeVariable, ReleaseIdentifier, Variable, VariableProvider } from "./types"; - -export type DatabaseResourceVariableOptions = Pick & { +import type { + MaybeVariable, + ReleaseIdentifier, + Variable, + VariableProvider, +} from "./types"; + +export type DatabaseResourceVariableOptions = Pick< + ReleaseIdentifier, + "resourceId" +> & { db?: Tx; }; @@ -49,7 +57,10 @@ export class DatabaseResourceVariableProvider implements VariableProvider { } } -export type DatabaseDeploymentVariableOptions = Pick & { +export type DatabaseDeploymentVariableOptions = Pick< + ReleaseIdentifier, + "resourceId" | "deploymentId" +> & { db?: Tx; }; @@ -125,7 +136,10 @@ export class DatabaseDeploymentVariableProvider implements VariableProvider { } } -export type DatabaseSystemVariableSetOptions = Pick & { +export type DatabaseSystemVariableSetOptions = Pick< + ReleaseIdentifier, + "environmentId" +> & { db?: Tx; }; diff --git a/packages/release-manager/src/index.ts b/packages/release-manager/src/index.ts index a3ad0451b..4d1c2a629 100644 --- a/packages/release-manager/src/index.ts +++ b/packages/release-manager/src/index.ts @@ -3,4 +3,3 @@ export * from "./variables.js"; export * from "./manager.js"; export * from "./releases.js"; export * from "./repositories/release-repository.js"; -export * from "./providers/variable-provider-factories.js"; \ No newline at end of file diff --git a/packages/release-manager/src/manager.ts b/packages/release-manager/src/manager.ts index ba00512f4..18f9dd4e1 100644 --- a/packages/release-manager/src/manager.ts +++ b/packages/release-manager/src/manager.ts @@ -1,9 +1,10 @@ import type { Tx } from "@ctrlplane/db"; + import { db } from "@ctrlplane/db/client"; +import type { ReleaseIdentifier } from "./types.js"; import { BaseReleaseCreator } from "./releases.js"; import { DatabaseReleaseRepository } from "./repositories/release-repository.js"; -import type { ReleaseIdentifier } from "./types.js"; import { VariableManager } from "./variables.js"; export type ReleaseManagerOptions = ReleaseIdentifier & { @@ -14,13 +15,14 @@ export class ReleaseManager { private readonly releaseCreator: BaseReleaseCreator; private variableManager: VariableManager | null = null; private repository: DatabaseReleaseRepository; - private db: Tx; + private readonly db: Tx; constructor(private readonly options: ReleaseManagerOptions) { this.db = options.db ?? db; this.repository = new DatabaseReleaseRepository(this.db); - this.releaseCreator = new BaseReleaseCreator(options) - .setRepository(this.repository); + this.releaseCreator = new BaseReleaseCreator(options).setRepository( + this.repository, + ); } async getCurrentVariables() { @@ -49,4 +51,4 @@ export class ReleaseManager { desiredReleaseId, }); } -} \ No newline at end of file +} diff --git a/packages/release-manager/src/releases.ts b/packages/release-manager/src/releases.ts index f11066fac..2d534d7d1 100644 --- a/packages/release-manager/src/releases.ts +++ b/packages/release-manager/src/releases.ts @@ -1,6 +1,11 @@ import * as _ from "lodash"; -import type { MaybeVariable, Release, ReleaseIdentifier, ReleaseRepository } from "./types.js"; +import type { + MaybeVariable, + Release, + ReleaseIdentifier, + ReleaseRepository, +} from "./types.js"; import { DatabaseReleaseRepository } from "./repositories/release-repository.js"; type ReleaseWithId = Release & { id: string }; @@ -31,9 +36,14 @@ export class BaseReleaseCreator implements ReleaseCreator { return this.repository.getLatestRelease(this.options); } - async createRelease(versionId: string, variables: MaybeVariable[]): Promise { - const nonNullVariables = variables.filter((v): v is NonNullable => v !== null); - + async createRelease( + versionId: string, + variables: MaybeVariable[], + ): Promise { + const nonNullVariables = variables.filter( + (v): v is NonNullable => v !== null, + ); + const release: Release = { ...this.options, versionId, @@ -48,7 +58,9 @@ export class BaseReleaseCreator implements ReleaseCreator { variables: MaybeVariable[], ): Promise { const latestRelease = await this.getLatestRelease(); - const nonNullVariables = variables.filter((v): v is NonNullable => v !== null); + const nonNullVariables = variables.filter( + (v): v is NonNullable => v !== null, + ); const latestR = { versionId: latestRelease?.versionId, @@ -59,11 +71,13 @@ export class BaseReleaseCreator implements ReleaseCreator { const newR = { versionId, - variables: Object.fromEntries(nonNullVariables.map((v) => [v.key, v.value])), + variables: Object.fromEntries( + nonNullVariables.map((v) => [v.key, v.value]), + ), }; return latestRelease != null && _.isEqual(latestR, newR) ? latestRelease : this.createRelease(versionId, nonNullVariables); } -} \ No newline at end of file +} diff --git a/packages/release-manager/src/types.ts b/packages/release-manager/src/types.ts index 3eb59e235..5a381c918 100644 --- a/packages/release-manager/src/types.ts +++ b/packages/release-manager/src/types.ts @@ -32,9 +32,13 @@ export type VariableProviderOptions = ReleaseIdentifier & { }; export interface ReleaseRepository { - getLatestRelease(options: ReleaseIdentifier): Promise; + getLatestRelease( + options: ReleaseIdentifier, + ): Promise<(Release & { id: string }) | null>; createRelease(release: Release): Promise; - setDesiredRelease(options: ReleaseIdentifier & { desiredReleaseId: string }): Promise; + setDesiredRelease( + options: ReleaseIdentifier & { desiredReleaseId: string }, + ): Promise; } -export type ReleaseQueryOptions = ReleaseIdentifier; \ No newline at end of file +export type ReleaseQueryOptions = ReleaseIdentifier; From c9f1c154462499350e2050361b738eb238d8e85f Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sun, 30 Mar 2025 00:51:35 -0400 Subject: [PATCH 13/22] releasers --- packages/release-manager/src/index.ts | 1 + packages/release-manager/src/manager.ts | 4 +- .../src/release-new-variable-change.ts | 0 .../release-manager/src/release-new-verion.ts | 67 +++++++++++++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 packages/release-manager/src/release-new-variable-change.ts create mode 100644 packages/release-manager/src/release-new-verion.ts diff --git a/packages/release-manager/src/index.ts b/packages/release-manager/src/index.ts index 4d1c2a629..dbc21eb7a 100644 --- a/packages/release-manager/src/index.ts +++ b/packages/release-manager/src/index.ts @@ -3,3 +3,4 @@ export * from "./variables.js"; export * from "./manager.js"; export * from "./releases.js"; export * from "./repositories/release-repository.js"; +export * from "./release-new-variable-change.js"; diff --git a/packages/release-manager/src/manager.ts b/packages/release-manager/src/manager.ts index 18f9dd4e1..1d6cfbd8f 100644 --- a/packages/release-manager/src/manager.ts +++ b/packages/release-manager/src/manager.ts @@ -1,4 +1,5 @@ import type { Tx } from "@ctrlplane/db"; +import _ from "lodash"; import { db } from "@ctrlplane/db/client"; @@ -13,9 +14,10 @@ export type ReleaseManagerOptions = ReleaseIdentifier & { export class ReleaseManager { private readonly releaseCreator: BaseReleaseCreator; + private readonly db: Tx; + private variableManager: VariableManager | null = null; private repository: DatabaseReleaseRepository; - private readonly db: Tx; constructor(private readonly options: ReleaseManagerOptions) { this.db = options.db ?? db; diff --git a/packages/release-manager/src/release-new-variable-change.ts b/packages/release-manager/src/release-new-variable-change.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/release-manager/src/release-new-verion.ts b/packages/release-manager/src/release-new-verion.ts new file mode 100644 index 000000000..51a77a741 --- /dev/null +++ b/packages/release-manager/src/release-new-verion.ts @@ -0,0 +1,67 @@ +import type { Tx } from "@ctrlplane/db"; +import _ from "lodash"; + +import { and, eq, takeFirst } from "@ctrlplane/db"; +import * as schema from "@ctrlplane/db/schema"; + +import { ReleaseManager } from "./manager.js"; + +const getSystemResources = async (tx: Tx, systemId: string) => { + const system = await tx.query.system.findFirst({ + where: eq(schema.system.id, systemId), + with: { environments: true }, + }); + + if (system == null) throw new Error("System not found"); + + const { environments } = system; + + const resources = await _(environments) + .map(async (env) => { + const res = await tx + .select() + .from(schema.resource) + .where( + and( + eq(schema.resource.workspaceId, system.workspaceId), + schema.resourceMatchesMetadata(tx, env.resourceSelector), + ), + ); + return res.map((r) => ({ ...r, environment: env })); + }) + .thru((promises) => Promise.all(promises)) + .value() + .then((arrays) => arrays.flat()); + + return resources; +}; + +export const releaseNewVersion = async (tx: Tx, versionId: string) => { + const { + deployment_version: version, + deployment, + system, + } = await tx + .select() + .from(schema.deploymentVersion) + .innerJoin( + schema.deployment, + eq(schema.deploymentVersion.deploymentId, schema.deployment.id), + ) + .innerJoin(schema.system, eq(schema.deployment.systemId, schema.system.id)) + .where(eq(schema.deploymentVersion.id, versionId)) + .then(takeFirst); + + const resources = await getSystemResources(tx, system.id); + const releaseManagers = resources.map( + (r) => + new ReleaseManager({ + db: tx, + deploymentId: deployment.id, + environmentId: r.environment.id, + resourceId: r.id, + }), + ); + + await Promise.all(releaseManagers.map((rm) => rm.ensureRelease(version.id))); +}; From 25c5890bde47fd78983515b94c086b8923b9c1fd Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sun, 30 Mar 2025 01:39:06 -0400 Subject: [PATCH 14/22] return if created or not --- packages/release-manager/src/manager.ts | 4 ++-- packages/release-manager/src/releases.ts | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/release-manager/src/manager.ts b/packages/release-manager/src/manager.ts index 1d6cfbd8f..3fb7f574b 100644 --- a/packages/release-manager/src/manager.ts +++ b/packages/release-manager/src/manager.ts @@ -39,12 +39,12 @@ export class ReleaseManager { async ensureRelease(versionId: string, opts?: { setAsDesired?: boolean }) { const variables = await this.getCurrentVariables(); - const release = await this.releaseCreator.ensureRelease( + const { created, release } = await this.releaseCreator.ensureRelease( versionId, variables, ); if (opts?.setAsDesired) await this.setDesiredRelease(release.id); - return release; + return { created, release }; } async setDesiredRelease(desiredReleaseId: string) { diff --git a/packages/release-manager/src/releases.ts b/packages/release-manager/src/releases.ts index 2d534d7d1..91622131f 100644 --- a/packages/release-manager/src/releases.ts +++ b/packages/release-manager/src/releases.ts @@ -19,7 +19,7 @@ export type ReleaseCreator = { ensureRelease( versionId: string, variables: MaybeVariable[], - ): Promise; + ): Promise<{ created: boolean; release: ReleaseWithId }>; }; export class BaseReleaseCreator implements ReleaseCreator { @@ -56,7 +56,7 @@ export class BaseReleaseCreator implements ReleaseCreator { async ensureRelease( versionId: string, variables: MaybeVariable[], - ): Promise { + ): Promise<{ created: boolean; release: ReleaseWithId }> { const latestRelease = await this.getLatestRelease(); const nonNullVariables = variables.filter( (v): v is NonNullable => v !== null, @@ -77,7 +77,10 @@ export class BaseReleaseCreator implements ReleaseCreator { }; return latestRelease != null && _.isEqual(latestR, newR) - ? latestRelease - : this.createRelease(versionId, nonNullVariables); + ? { created: false, release: latestRelease } + : { + created: true, + release: await this.createRelease(versionId, nonNullVariables), + }; } } From d883379cfad7b00da38a5dddea1ced834e6916f8 Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sun, 30 Mar 2025 23:14:01 -0400 Subject: [PATCH 15/22] ceanup --- packages/release-manager/src/index.ts | 2 +- packages/release-manager/src/manager.ts | 2 +- packages/release-manager/src/releases.ts | 9 ++--- .../release-manager/src/repositories/types.ts | 9 +++++ packages/release-manager/src/types.ts | 34 ++----------------- .../{ => variables}/db-variable-providers.ts | 8 ++--- .../release-manager/src/variables/types.ts | 23 +++++++++++++ .../src/{ => variables}/variables.ts | 0 8 files changed, 42 insertions(+), 45 deletions(-) create mode 100644 packages/release-manager/src/repositories/types.ts rename packages/release-manager/src/{ => variables}/db-variable-providers.ts (97%) create mode 100644 packages/release-manager/src/variables/types.ts rename packages/release-manager/src/{ => variables}/variables.ts (100%) diff --git a/packages/release-manager/src/index.ts b/packages/release-manager/src/index.ts index dbc21eb7a..8096f56d9 100644 --- a/packages/release-manager/src/index.ts +++ b/packages/release-manager/src/index.ts @@ -1,5 +1,5 @@ export * from "./types.js"; -export * from "./variables.js"; +export * from "./variables/variables.js"; export * from "./manager.js"; export * from "./releases.js"; export * from "./repositories/release-repository.js"; diff --git a/packages/release-manager/src/manager.ts b/packages/release-manager/src/manager.ts index 3fb7f574b..935d125b3 100644 --- a/packages/release-manager/src/manager.ts +++ b/packages/release-manager/src/manager.ts @@ -6,7 +6,7 @@ import { db } from "@ctrlplane/db/client"; import type { ReleaseIdentifier } from "./types.js"; import { BaseReleaseCreator } from "./releases.js"; import { DatabaseReleaseRepository } from "./repositories/release-repository.js"; -import { VariableManager } from "./variables.js"; +import { VariableManager } from "./variables/variables.js"; export type ReleaseManagerOptions = ReleaseIdentifier & { db?: Tx; diff --git a/packages/release-manager/src/releases.ts b/packages/release-manager/src/releases.ts index 91622131f..79456cd11 100644 --- a/packages/release-manager/src/releases.ts +++ b/packages/release-manager/src/releases.ts @@ -1,11 +1,8 @@ import * as _ from "lodash"; -import type { - MaybeVariable, - Release, - ReleaseIdentifier, - ReleaseRepository, -} from "./types.js"; +import type { ReleaseRepository } from "./repositories/types.js"; +import type { Release, ReleaseIdentifier } from "./types.js"; +import type { MaybeVariable } from "./variables/types.js"; import { DatabaseReleaseRepository } from "./repositories/release-repository.js"; type ReleaseWithId = Release & { id: string }; diff --git a/packages/release-manager/src/repositories/types.ts b/packages/release-manager/src/repositories/types.ts new file mode 100644 index 000000000..883a3b5d7 --- /dev/null +++ b/packages/release-manager/src/repositories/types.ts @@ -0,0 +1,9 @@ +import type { Release, ReleaseIdentifier, ReleaseWithId } from "../types.js"; + +export interface ReleaseRepository { + getLatestRelease(options: ReleaseIdentifier): Promise; + createRelease(release: Release): Promise; + setDesiredRelease( + options: ReleaseIdentifier & { desiredReleaseId: string }, + ): Promise; +} diff --git a/packages/release-manager/src/types.ts b/packages/release-manager/src/types.ts index 5a381c918..c6e7adfbb 100644 --- a/packages/release-manager/src/types.ts +++ b/packages/release-manager/src/types.ts @@ -1,44 +1,16 @@ +import type { Variable } from "./variables/types.js"; + export type ReleaseIdentifier = { environmentId: string; deploymentId: string; resourceId: string; }; -export type Variable = { - id: string; - key: string; - value: T; - sensitive: boolean; -}; - export type Release = ReleaseIdentifier & { versionId: string; variables: Variable[]; }; -export type MaybePromise = T | Promise; -export type MaybeVariable = Variable | null; - -export type VariableProvider = { - getVariable(key: string): MaybePromise; -}; - -export type VariableProviderFactory = { - create(options: VariableProviderOptions): VariableProvider; -}; - -export type VariableProviderOptions = ReleaseIdentifier & { - db?: any; -}; - -export interface ReleaseRepository { - getLatestRelease( - options: ReleaseIdentifier, - ): Promise<(Release & { id: string }) | null>; - createRelease(release: Release): Promise; - setDesiredRelease( - options: ReleaseIdentifier & { desiredReleaseId: string }, - ): Promise; -} +export type ReleaseWithId = Release & { id: string }; export type ReleaseQueryOptions = ReleaseIdentifier; diff --git a/packages/release-manager/src/db-variable-providers.ts b/packages/release-manager/src/variables/db-variable-providers.ts similarity index 97% rename from packages/release-manager/src/db-variable-providers.ts rename to packages/release-manager/src/variables/db-variable-providers.ts index 5fe62eaf3..5440962f5 100644 --- a/packages/release-manager/src/db-variable-providers.ts +++ b/packages/release-manager/src/variables/db-variable-providers.ts @@ -13,12 +13,8 @@ import { variableSetValue, } from "@ctrlplane/db/schema"; -import type { - MaybeVariable, - ReleaseIdentifier, - Variable, - VariableProvider, -} from "./types"; +import type { ReleaseIdentifier } from "../types.js"; +import type { MaybeVariable, Variable, VariableProvider } from "./types.js"; export type DatabaseResourceVariableOptions = Pick< ReleaseIdentifier, diff --git a/packages/release-manager/src/variables/types.ts b/packages/release-manager/src/variables/types.ts new file mode 100644 index 000000000..71487618a --- /dev/null +++ b/packages/release-manager/src/variables/types.ts @@ -0,0 +1,23 @@ +import type { ReleaseIdentifier } from "src/types"; + +export type Variable = { + id: string; + key: string; + value: T; + sensitive: boolean; +}; + +export type MaybePromise = T | Promise; +export type MaybeVariable = Variable | null; + +export type VariableProvider = { + getVariable(key: string): MaybePromise; +}; + +export type VariableProviderFactory = { + create(options: VariableProviderOptions): VariableProvider; +}; + +export type VariableProviderOptions = ReleaseIdentifier & { + db?: any; +}; diff --git a/packages/release-manager/src/variables.ts b/packages/release-manager/src/variables/variables.ts similarity index 100% rename from packages/release-manager/src/variables.ts rename to packages/release-manager/src/variables/variables.ts From d8d81d3f203c5970b2581ad2c5e925edab0e5f65 Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sun, 30 Mar 2025 23:40:35 -0400 Subject: [PATCH 16/22] cleanup release manager --- packages/db/src/schema/release.ts | 43 ++++++ packages/release-manager/src/index.ts | 2 - packages/release-manager/src/manager.ts | 91 +++++++++---- .../src/release-new-variable-change.ts | 0 .../release-manager/src/release-new-verion.ts | 123 ++++++++++-------- packages/release-manager/src/releases.ts | 83 ------------ .../src/repositories/release-repository.ts | 80 ++++++++++-- .../release-manager/src/repositories/types.ts | 41 +++++- .../src/variables/variables.ts | 2 +- 9 files changed, 282 insertions(+), 183 deletions(-) delete mode 100644 packages/release-manager/src/release-new-variable-change.ts delete mode 100644 packages/release-manager/src/releases.ts diff --git a/packages/db/src/schema/release.ts b/packages/db/src/schema/release.ts index bbac57ce1..c55c41d31 100644 --- a/packages/db/src/schema/release.ts +++ b/packages/db/src/schema/release.ts @@ -1,3 +1,4 @@ +import { relations } from "drizzle-orm"; import { boolean, json, @@ -61,3 +62,45 @@ export const releaseJob = pgTable("release_job", { .notNull() .defaultNow(), }); + +export const releaseRelations = relations(release, ({ one, many }) => ({ + version: one(deploymentVersion, { + fields: [release.versionId], + references: [deploymentVersion.id], + }), + resource: one(resource, { + fields: [release.resourceId], + references: [resource.id], + }), + deployment: one(deployment, { + fields: [release.deploymentId], + references: [deployment.id], + }), + environment: one(environment, { + fields: [release.environmentId], + references: [environment.id], + }), + variables: many(releaseVariable), + jobs: many(releaseJob), +})); + +export const releaseVariableRelations = relations( + releaseVariable, + ({ one }) => ({ + release: one(release, { + fields: [releaseVariable.releaseId], + references: [release.id], + }), + }), +); + +export const releaseJobRelations = relations(releaseJob, ({ one }) => ({ + release: one(release, { + fields: [releaseJob.releaseId], + references: [release.id], + }), + job: one(job, { + fields: [releaseJob.jobId], + references: [job.id], + }), +})); diff --git a/packages/release-manager/src/index.ts b/packages/release-manager/src/index.ts index 8096f56d9..449dad5c9 100644 --- a/packages/release-manager/src/index.ts +++ b/packages/release-manager/src/index.ts @@ -1,6 +1,4 @@ export * from "./types.js"; export * from "./variables/variables.js"; export * from "./manager.js"; -export * from "./releases.js"; export * from "./repositories/release-repository.js"; -export * from "./release-new-variable-change.js"; diff --git a/packages/release-manager/src/manager.ts b/packages/release-manager/src/manager.ts index 935d125b3..8b50b369d 100644 --- a/packages/release-manager/src/manager.ts +++ b/packages/release-manager/src/manager.ts @@ -1,54 +1,95 @@ import type { Tx } from "@ctrlplane/db"; -import _ from "lodash"; import { db } from "@ctrlplane/db/client"; +import type { ReleaseRepository } from "./repositories/types.js"; import type { ReleaseIdentifier } from "./types.js"; -import { BaseReleaseCreator } from "./releases.js"; import { DatabaseReleaseRepository } from "./repositories/release-repository.js"; import { VariableManager } from "./variables/variables.js"; -export type ReleaseManagerOptions = ReleaseIdentifier & { +/** + * Options for configuring a ReleaseManager instance + */ +type ReleaseManagerOptions = ReleaseIdentifier & { + /** Repository for managing releases */ + repository: ReleaseRepository; + /** Manager for handling variables */ + variableManager: VariableManager; +}; + +/** + * Options for creating a database-backed ReleaseManager + */ +export type DatabaseReleaseManagerOptions = ReleaseIdentifier & { + /** Optional database transaction */ db?: Tx; }; +/** + * Manages the lifecycle of releases including creation, updates and desired + * state + */ export class ReleaseManager { - private readonly releaseCreator: BaseReleaseCreator; - private readonly db: Tx; + /** + * Creates a new ReleaseManager instance backed by the database + * @param options Configuration options including database connection + * @returns A configured ReleaseManager instance + */ + static async usingDatabase(options: DatabaseReleaseManagerOptions) { + const variableManager = await VariableManager.database(options); + const repository = new DatabaseReleaseRepository(options.db ?? db); + const manager = new ReleaseManager({ + ...options, + variableManager, + repository, + }); + return manager; + } - private variableManager: VariableManager | null = null; - private repository: DatabaseReleaseRepository; + private constructor(private readonly options: ReleaseManagerOptions) {} - constructor(private readonly options: ReleaseManagerOptions) { - this.db = options.db ?? db; - this.repository = new DatabaseReleaseRepository(this.db); - this.releaseCreator = new BaseReleaseCreator(options).setRepository( - this.repository, - ); + /** + * Gets the repository used by this manager + */ + get repository() { + return this.options.repository; } - async getCurrentVariables() { - if (this.variableManager === null) - this.variableManager = await VariableManager.database({ - ...this.options, - db: this.db, - }); - - return this.variableManager.getVariables(); + /** + * Gets the variable manager used by this manager + */ + get variableManager() { + return this.options.variableManager; } - async ensureRelease(versionId: string, opts?: { setAsDesired?: boolean }) { - const variables = await this.getCurrentVariables(); - const { created, release } = await this.releaseCreator.ensureRelease( + /** + * Upserts a release for the given version + * @param versionId The ID of the version to ensure + * @param opts Optional settings for the ensure operation + * @param opts.setAsDesired Whether to set this as the desired release + * @returns Object containing whether a new release was created and the + * release details + */ + async upsertRelease(versionId: string, opts?: { setAsDesired?: boolean }) { + const variables = await this.variableManager.getVariables(); + + // Use the repository directly to ensure the release + const { created, release } = await this.repository.upsert( + this.options, versionId, variables, ); + if (opts?.setAsDesired) await this.setDesiredRelease(release.id); return { created, release }; } + /** + * Sets the desired release for this resource + * @param desiredReleaseId The ID of the release to set as desired + */ async setDesiredRelease(desiredReleaseId: string) { - return this.repository.setDesiredRelease({ + await this.repository.setDesired({ ...this.options, desiredReleaseId, }); diff --git a/packages/release-manager/src/release-new-variable-change.ts b/packages/release-manager/src/release-new-variable-change.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/release-manager/src/release-new-verion.ts b/packages/release-manager/src/release-new-verion.ts index 51a77a741..09058327b 100644 --- a/packages/release-manager/src/release-new-verion.ts +++ b/packages/release-manager/src/release-new-verion.ts @@ -1,67 +1,76 @@ -import type { Tx } from "@ctrlplane/db"; -import _ from "lodash"; +// import type { Tx } from "@ctrlplane/db"; +// import _ from "lodash"; -import { and, eq, takeFirst } from "@ctrlplane/db"; -import * as schema from "@ctrlplane/db/schema"; +// import { and, eq, takeFirst } from "@ctrlplane/db"; +// import * as schema from "@ctrlplane/db/schema"; -import { ReleaseManager } from "./manager.js"; +// import { ReleaseManager } from "./manager.js"; -const getSystemResources = async (tx: Tx, systemId: string) => { - const system = await tx.query.system.findFirst({ - where: eq(schema.system.id, systemId), - with: { environments: true }, - }); +// /** +// * Retrieves system resources for a given system +// */ +// const getSystemResources = async (tx: Tx, systemId: string) => { +// const system = await tx.query.system.findFirst({ +// where: eq(schema.system.id, systemId), +// with: { environments: true }, +// }); - if (system == null) throw new Error("System not found"); +// if (system == null) throw new Error("System not found"); - const { environments } = system; +// const { environments } = system; - const resources = await _(environments) - .map(async (env) => { - const res = await tx - .select() - .from(schema.resource) - .where( - and( - eq(schema.resource.workspaceId, system.workspaceId), - schema.resourceMatchesMetadata(tx, env.resourceSelector), - ), - ); - return res.map((r) => ({ ...r, environment: env })); - }) - .thru((promises) => Promise.all(promises)) - .value() - .then((arrays) => arrays.flat()); +// // Simplify the chained operations with standard Promise.all +// const resources = await Promise.all( +// environments.map(async (env) => { +// const res = await tx +// .select() +// .from(schema.resource) +// .where( +// and( +// eq(schema.resource.workspaceId, system.workspaceId), +// schema.resourceMatchesMetadata(tx, env.resourceSelector), +// ), +// ); +// return res.map((r) => ({ ...r, environment: env })); +// }), +// ).then((arrays) => arrays.flat()); - return resources; -}; +// return resources; +// }; -export const releaseNewVersion = async (tx: Tx, versionId: string) => { - const { - deployment_version: version, - deployment, - system, - } = await tx - .select() - .from(schema.deploymentVersion) - .innerJoin( - schema.deployment, - eq(schema.deploymentVersion.deploymentId, schema.deployment.id), - ) - .innerJoin(schema.system, eq(schema.deployment.systemId, schema.system.id)) - .where(eq(schema.deploymentVersion.id, versionId)) - .then(takeFirst); +// /** +// * Releases a new version for a deployment across all resources +// */ +// export const releaseNewVersion = async (tx: Tx, versionId: string) => { +// // Get deployment, version and system in a single query +// const { +// deployment_version: version, +// deployment, +// system, +// } = await tx +// .select() +// .from(schema.deploymentVersion) +// .innerJoin( +// schema.deployment, +// eq(schema.deploymentVersion.deploymentId, schema.deployment.id), +// ) +// .innerJoin(schema.system, eq(schema.deployment.systemId, schema.system.id)) +// .where(eq(schema.deploymentVersion.id, versionId)) +// .then(takeFirst); - const resources = await getSystemResources(tx, system.id); - const releaseManagers = resources.map( - (r) => - new ReleaseManager({ - db: tx, - deploymentId: deployment.id, - environmentId: r.environment.id, - resourceId: r.id, - }), - ); +// // Get all resources for this system +// const resources = await getSystemResources(tx, system.id); - await Promise.all(releaseManagers.map((rm) => rm.ensureRelease(version.id))); -}; +// // Create a release manager for each resource and ensure the release +// const releaseManagers = resources.map( +// (r) => +// new ReleaseManager({ +// db: tx, +// deploymentId: deployment.id, +// environmentId: r.environment.id, +// resourceId: r.id, +// }), +// ); + +// await Promise.all(releaseManagers.map((rm) => rm.ensureRelease(version.id))); +// }; diff --git a/packages/release-manager/src/releases.ts b/packages/release-manager/src/releases.ts deleted file mode 100644 index 79456cd11..000000000 --- a/packages/release-manager/src/releases.ts +++ /dev/null @@ -1,83 +0,0 @@ -import * as _ from "lodash"; - -import type { ReleaseRepository } from "./repositories/types.js"; -import type { Release, ReleaseIdentifier } from "./types.js"; -import type { MaybeVariable } from "./variables/types.js"; -import { DatabaseReleaseRepository } from "./repositories/release-repository.js"; - -type ReleaseWithId = Release & { id: string }; - -export type ReleaseCreator = { - getLatestRelease(): Promise; - createRelease( - versionId: string, - variables: MaybeVariable[], - ): Promise; - ensureRelease( - versionId: string, - variables: MaybeVariable[], - ): Promise<{ created: boolean; release: ReleaseWithId }>; -}; - -export class BaseReleaseCreator implements ReleaseCreator { - constructor(protected options: ReleaseIdentifier) {} - - protected repository: ReleaseRepository = new DatabaseReleaseRepository(); - - setRepository(repository: ReleaseRepository) { - this.repository = repository; - return this; - } - - async getLatestRelease() { - return this.repository.getLatestRelease(this.options); - } - - async createRelease( - versionId: string, - variables: MaybeVariable[], - ): Promise { - const nonNullVariables = variables.filter( - (v): v is NonNullable => v !== null, - ); - - const release: Release = { - ...this.options, - versionId, - variables: nonNullVariables, - }; - - return this.repository.createRelease(release); - } - - async ensureRelease( - versionId: string, - variables: MaybeVariable[], - ): Promise<{ created: boolean; release: ReleaseWithId }> { - const latestRelease = await this.getLatestRelease(); - const nonNullVariables = variables.filter( - (v): v is NonNullable => v !== null, - ); - - const latestR = { - versionId: latestRelease?.versionId, - variables: Object.fromEntries( - latestRelease?.variables.map((v) => [v.key, v.value]) ?? [], - ), - }; - - const newR = { - versionId, - variables: Object.fromEntries( - nonNullVariables.map((v) => [v.key, v.value]), - ), - }; - - return latestRelease != null && _.isEqual(latestR, newR) - ? { created: false, release: latestRelease } - : { - created: true, - release: await this.createRelease(versionId, nonNullVariables), - }; - } -} diff --git a/packages/release-manager/src/repositories/release-repository.ts b/packages/release-manager/src/repositories/release-repository.ts index c5867a2bb..7609f747e 100644 --- a/packages/release-manager/src/repositories/release-repository.ts +++ b/packages/release-manager/src/repositories/release-repository.ts @@ -1,4 +1,5 @@ import type { Tx } from "@ctrlplane/db"; +import _ from "lodash"; import { and, @@ -11,16 +12,21 @@ import { import { db as dbClient } from "@ctrlplane/db/client"; import * as schema from "@ctrlplane/db/schema"; -import type { - Release, - ReleaseIdentifier, - ReleaseRepository, -} from "../types.js"; +import type { Release, ReleaseIdentifier, ReleaseWithId } from "../types.js"; +import type { MaybeVariable } from "../variables/types.js"; +import type { ReleaseRepository } from "./types.js"; +/** + * Enhanced repository that combines database operations with business logic + * for managing releases + */ export class DatabaseReleaseRepository implements ReleaseRepository { constructor(private readonly db: Tx = dbClient) {} - async getLatestRelease(options: ReleaseIdentifier) { + /** + * Get the latest release for a specific resource, deployment, and environment + */ + async getLatest(options: ReleaseIdentifier) { return this.db.query.release .findFirst({ where: and( @@ -36,7 +42,10 @@ export class DatabaseReleaseRepository implements ReleaseRepository { .then((r) => r ?? null); } - async createRelease(release: Release) { + /** + * Create a new release with the given details + */ + async create(release: Release) { const dbRelease = await this.db .insert(schema.release) .values(release) @@ -49,10 +58,59 @@ export class DatabaseReleaseRepository implements ReleaseRepository { }; } - async setDesiredRelease( - options: ReleaseIdentifier & { desiredReleaseId: string }, - ) { - return this.db + /** + * Create a new release with variables for a specific version + */ + async createForVersion( + options: ReleaseIdentifier, + versionId: string, + variables: MaybeVariable[], + ): Promise { + const release: Release = { + ...options, + versionId, + variables: _.compact(variables), + }; + + return this.create(release); + } + + async upsert( + options: ReleaseIdentifier, + versionId: string, + variables: MaybeVariable[], + ): Promise<{ created: boolean; release: ReleaseWithId }> { + const latestRelease = await this.getLatest(options); + + // Convert releases to comparable objects + const latestR = { + versionId: latestRelease?.versionId, + variables: _(latestRelease?.variables ?? []) + .map((v) => [v.key, v.value]) + .fromPairs() + .value(), + }; + + const newR = { + versionId, + variables: _(variables) + .compact() + .map((v) => [v.key, v.value]) + .fromPairs() + .value(), + }; + + const isSame = latestRelease != null && _.isEqual(latestR, newR); + return isSame + ? { created: false, release: latestRelease } + : { + created: true, + release: await this.createForVersion(options, versionId, variables), + }; + } + + async setDesired(options: ReleaseIdentifier & { desiredReleaseId: string }) { + await this.db .insert(schema.resourceRelease) .values({ environmentId: options.environmentId, diff --git a/packages/release-manager/src/repositories/types.ts b/packages/release-manager/src/repositories/types.ts index 883a3b5d7..98ed6e433 100644 --- a/packages/release-manager/src/repositories/types.ts +++ b/packages/release-manager/src/repositories/types.ts @@ -1,9 +1,42 @@ import type { Release, ReleaseIdentifier, ReleaseWithId } from "../types.js"; +import type { MaybeVariable } from "../variables/types.js"; export interface ReleaseRepository { - getLatestRelease(options: ReleaseIdentifier): Promise; - createRelease(release: Release): Promise; - setDesiredRelease( + /** + * Get the latest release for a specific resource, deployment, and environment + */ + getLatest( + options: ReleaseIdentifier, + ): Promise<(ReleaseWithId & { variables: MaybeVariable[] }) | null>; + + /** + * Create a new release with the given details + */ + create(release: Release): Promise; + + /** + * Create a new release with variables for a specific version + */ + createForVersion( + options: ReleaseIdentifier, + versionId: string, + variables: MaybeVariable[], + ): Promise; + + /** + * Ensure a release exists for the given version and variables + * Creates a new release only if necessary + */ + upsert( + options: ReleaseIdentifier, + versionId: string, + variables: MaybeVariable[], + ): Promise<{ created: boolean; release: ReleaseWithId }>; + + /** + * Set a specific release as the desired release + */ + setDesired( options: ReleaseIdentifier & { desiredReleaseId: string }, - ): Promise; + ): Promise; } diff --git a/packages/release-manager/src/variables/variables.ts b/packages/release-manager/src/variables/variables.ts index d74c6523f..2fd3118db 100644 --- a/packages/release-manager/src/variables/variables.ts +++ b/packages/release-manager/src/variables/variables.ts @@ -43,7 +43,7 @@ export class VariableManager { return new VariableManager({ ...options, keys }, providers); } - constructor( + private constructor( private options: VariableManagerOptions, private variableProviders: VariableProvider[], ) {} From ed60d075f0ad0e57d8138e96f5aff44b8e5bb84a Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sun, 30 Mar 2025 23:43:18 -0400 Subject: [PATCH 17/22] update readme --- packages/release-manager/README.md | 269 ++++++++++------------------- 1 file changed, 92 insertions(+), 177 deletions(-) diff --git a/packages/release-manager/README.md b/packages/release-manager/README.md index 3f1b2f2f5..8c3e0b092 100644 --- a/packages/release-manager/README.md +++ b/packages/release-manager/README.md @@ -1,219 +1,134 @@ # @ctrlplane/release-manager -A flexible and extensible framework for managing releases triggered by variable changes or version updates, with support for context-specific releases and variable resolution hierarchy. +A streamlined solution for managing context-specific software releases with +variable resolution and version tracking. -## Features +## Overview -- **Idempotent Release Creation**: Creates releases only when variables or versions actually change -- **Context-Specific Releases**: Create releases for specific resources, environments, and deployments -- **Variable Resolution Hierarchy**: Resolve variables with a priority order: resource variables > deployment variables > standard variables -- **Selector-Based Matching**: Use selectors to match deployments to resources -- **Flexible Storage**: Abstract storage interface with ready implementations for both in-memory (testing) and database storage -- **Rule Engine**: Process releases through customizable rules with conditions and actions -- **Strongly Typed**: Full TypeScript support with Zod validation schemas -- **Testable**: Built with testing in mind +The release-manager package provides functionality to manage software releases +across different resources and environments. It handles variable resolution from +multiple sources and ensures releases are only created when necessary (when +variables or versions change). -## Installation +## Key Components -```bash -# From within the monorepo -pnpm add @ctrlplane/release-manager@workspace:* -``` +### ReleaseManager + +The main entry point for the package. It coordinates variable retrieval and +release creation. -## Usage +```typescript +const manager = new ReleaseManager({ + deploymentId: "deployment-123", + environmentId: "production", + resourceId: "resource-456", + db: dbTransaction, // Optional transaction object +}); -### Variable Types and Resolution Hierarchy +// Create a release for a specific version +const { created, release } = await manager.ensureRelease("v1.0.0"); -The framework supports three types of variables with a priority hierarchy: +// Set a release as the desired release +await manager.setDesiredRelease(release.id); +``` -1. **Resource Variables** (highest priority): Specific to a resource and optionally an environment -2. **Deployment Variables** (medium priority): Associated with a deployment and matched to resources via selectors -3. **Standard Variables** (lowest priority): Global variables with no specific context +### Variable System -When resolving a variable value, the framework checks each level in order and returns the highest priority value available. +Handles the retrieval and resolution of variables from multiple sources with a +clear priority order: -### Creating Context-Specific Releases +1. **Resource Variables**: Specific to a resource +2. **Deployment Variables**: Associated with deployments, matched to resources + via selectors +3. **System Variable Sets**: Global variables for environments ```typescript -import { randomUUID } from "crypto"; +// Get all variables for the current context +const variables = await manager.getCurrentVariables(); +``` -import { - InMemoryReleaseStorage, - ReleaseManager, -} from "@ctrlplane/release-manager"; +### Repository Layer -// Setup storage and release manager -const storage = new InMemoryReleaseStorage(); -const releaseManager = new ReleaseManager({ - storage, - generateId: () => randomUUID(), -}); +Manages database interactions for releases with a clean interface: -// Set up a resource -const resource = { - id: "app-server-1", - name: "Application Server", - labels: { type: "app", tier: "backend" }, - environmentId: "production", -}; - -// Set resource in storage -storage.setResources([resource]); - -// Create a resource-specific variable -const resourceVariable = { - id: "var-1", - type: "resourceVariable", - name: "API_URL", - value: "https://api.prod.example.com", - resourceId: "app-server-1", - environmentId: "production", - updatedAt: new Date(), -}; - -// Store the variable -storage.setResourceVariables([resourceVariable]); +```typescript +// The ReleaseManager uses these methods internally +const repository = new DatabaseReleaseRepository(db); -// Create a context for the release -const context = { - resourceId: "app-server-1", +// Create a release directly +const release = await repository.create({ + deploymentId: "deployment-123", environmentId: "production", - resource: resource, -}; + resourceId: "resource-456", + versionId: "v1.0.0", + variables: [...resolvedVariables], +}); -// Create a release for the variable in this specific context -const release = await releaseManager.createReleaseForVariable( - "API_URL", - context, +// Ensure a release exists (create only if needed) +const { created, release } = await repository.ensure( + { deploymentId, environmentId, resourceId }, + versionId, + variables ); -console.log(`Release created: ${release.id}`); ``` -### Working with Variable Resolution +## How It Works -```typescript -// Set up various variable types -storage.setVariables([ - { - id: "var-global", - type: "variable", - name: "DEBUG", - value: false, - }, -]); - -storage.setDeploymentVariables([ - { - id: "var-deploy", - type: "deploymentVariable", - name: "DEBUG", - value: true, - deploymentId: "backend-deploy", - selectors: [{ key: "type", value: "app" }], - }, -]); - -// Resolve a variable in a specific context -const debugValue = await releaseManager.getVariable("DEBUG", context); -console.log(`Debug value: ${debugValue.value}`); // true (from deployment variable) - -// Get all resolved variables for a context -const allVars = await releaseManager.getVariablesForContext(context); -console.log(`Total variables: ${allVars.length}`); -``` +1. **Variable Resolution**: When a release is requested, the system fetches + variables from all available providers (resource, deployment, and system). -### Creating Version Releases in Context +2. **Release Creation Logic**: The system checks if a release with the exact + same version and variables already exists: -```typescript -// Create a version -const version = { - id: "app-version", - version: "2.0.0", - updatedAt: new Date(), -}; - -// Store the version -storage.setVersions([version]); - -// Create a release for the version change in a specific context -const versionRelease = await releaseManager.createReleaseForVersion( - version, - context, -); -console.log(`Version release created: ${versionRelease.id}`); -``` + - If it exists, it returns the existing release + - If not, it creates a new release with the current variables -### Context-Specific Rules +3. **Version Release Flow**: When a new version is released to a deployment: + - The system identifies all applicable resources + - For each resource, it creates a release with the resolved variables + - Optionally marks the release as the "desired" release for the resource -```typescript -import { - ContextSpecificCondition, - RuleEngine, - SemverCondition, - TriggerDeploymentAction, - VersionChangedCondition, -} from "@ctrlplane/release-manager"; - -// Create a deployment service (mock) -const deploymentService = { - triggerDeployment: async (props) => { - console.log( - `Deploying ${props.version} to ${props.resourceId} in ${props.environmentId}`, - ); - }, -}; - -// Create environment-specific rules -const ruleEngine = new RuleEngine({ - rules: [ - { - id: "prod-deploy-rule", - name: "Production Deployment Rule", - // Only trigger for production environment and major version changes - condition: { - async evaluate(props) { - const isProd = new ContextSpecificCondition( - undefined, - "production", - ).evaluate(props); - - const isMajorVersion = new SemverCondition("^2.0.0").evaluate(props); - - return (await isProd) && (await isMajorVersion); - }, - }, - action: new TriggerDeploymentAction(deploymentService), - }, - ], -}); +## Testing -// Process the version release through rules -await ruleEngine.processRelease(versionRelease, undefined, version, context); +```bash +# Run tests +pnpm test + +# Check types +pnpm typecheck ``` -### Database Integration +## Integration Example ```typescript -import { DatabaseReleaseStorage } from "@ctrlplane/release-manager"; - -// Assuming you have a database client from @ctrlplane/db -const dbClient = createDbClient(); - -// Create a database-backed storage -const dbStorage = new DatabaseReleaseStorage(dbClient); +import { db } from "@ctrlplane/db/client"; +import { ReleaseManager } from "@ctrlplane/release-manager"; -// Use the storage with release manager +// Create a release manager for a specific context const releaseManager = new ReleaseManager({ - storage: dbStorage, - generateId: () => randomUUID(), + deploymentId: "my-app", + environmentId: "production", + resourceId: "web-server-1", +}); + +// Create or get an existing release for version "2.0.0" +// All variables will be automatically resolved +const { created, release } = await releaseManager.ensureRelease("2.0.0", { + setAsDesired: true, // Mark as the desired release }); + +console.log(`Release ${created ? "created" : "already exists"}: ${release.id}`); +console.log(`Variables resolved: ${release.variables.length}`); ``` -## Testing +## Release New Version Flow -```bash -# Run the test suite -pnpm test +```typescript +import { db } from "@ctrlplane/db/client"; +import { releaseNewVersion } from "@ctrlplane/release-manager"; + +// Create releases for all resources matching a deployment version +await releaseNewVersion(db, "version-123"); ``` ## License From bbeeb50ef15666735f254794e536905b8ee8ab47 Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sun, 30 Mar 2025 23:45:01 -0400 Subject: [PATCH 18/22] fix to upsert --- packages/release-manager/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/release-manager/README.md b/packages/release-manager/README.md index 8c3e0b092..9ed6ae69d 100644 --- a/packages/release-manager/README.md +++ b/packages/release-manager/README.md @@ -26,7 +26,7 @@ const manager = new ReleaseManager({ }); // Create a release for a specific version -const { created, release } = await manager.ensureRelease("v1.0.0"); +const { created, release } = await manager.upsertRelease("v1.0.0"); // Set a release as the desired release await manager.setDesiredRelease(release.id); @@ -65,7 +65,7 @@ const release = await repository.create({ }); // Ensure a release exists (create only if needed) -const { created, release } = await repository.ensure( +const { created, release } = await repository.upsert( { deploymentId, environmentId, resourceId }, versionId, variables @@ -113,7 +113,7 @@ const releaseManager = new ReleaseManager({ // Create or get an existing release for version "2.0.0" // All variables will be automatically resolved -const { created, release } = await releaseManager.ensureRelease("2.0.0", { +const { created, release } = await releaseManager.upsertRelease("2.0.0", { setAsDesired: true, // Mark as the desired release }); From 6483ea0211a25e775b24e49d39ca5be1b9f8f46c Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sun, 30 Mar 2025 23:45:36 -0400 Subject: [PATCH 19/22] update license --- packages/release-manager/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/release-manager/README.md b/packages/release-manager/README.md index 9ed6ae69d..6c660bddb 100644 --- a/packages/release-manager/README.md +++ b/packages/release-manager/README.md @@ -133,4 +133,4 @@ await releaseNewVersion(db, "version-123"); ## License -MIT +Parent Repository: [ctrlplanedev/ctrlplane](https://github.com/ctrlplane/ctrlplane) From 23fdedffaa2c5075d7241c9a4164b3d24d78b60d Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sun, 30 Mar 2025 23:45:46 -0400 Subject: [PATCH 20/22] remove license --- packages/release-manager/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/release-manager/package.json b/packages/release-manager/package.json index d508fcbc4..d22b0e6d2 100644 --- a/packages/release-manager/package.json +++ b/packages/release-manager/package.json @@ -9,7 +9,7 @@ "default": "./dist/index.js" } }, - "license": "MIT", + "license": "", "scripts": { "build": "tsc", "dev": "tsc --watch", From ecbb6df99093c4a513e359e7429aa2fa07be57b5 Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Sun, 30 Mar 2025 23:47:06 -0400 Subject: [PATCH 21/22] use correct release manager in readme --- packages/release-manager/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/release-manager/README.md b/packages/release-manager/README.md index 6c660bddb..febe729f5 100644 --- a/packages/release-manager/README.md +++ b/packages/release-manager/README.md @@ -18,7 +18,8 @@ The main entry point for the package. It coordinates variable retrieval and release creation. ```typescript -const manager = new ReleaseManager({ +// Use the static factory method for creating a manager +const manager = await ReleaseManager.usingDatabase({ deploymentId: "deployment-123", environmentId: "production", resourceId: "resource-456", @@ -105,7 +106,7 @@ import { db } from "@ctrlplane/db/client"; import { ReleaseManager } from "@ctrlplane/release-manager"; // Create a release manager for a specific context -const releaseManager = new ReleaseManager({ +const releaseManager = await ReleaseManager.usingDatabase({ deploymentId: "my-app", environmentId: "production", resourceId: "web-server-1", From 17980dc98739d694165685f8540ca10e40bbeceb Mon Sep 17 00:00:00 2001 From: Justin Brooks Date: Mon, 31 Mar 2025 00:08:30 -0400 Subject: [PATCH 22/22] init jobs --- apps/event-worker/Dockerfile | 2 + apps/event-worker/package.json | 1 + apps/event-worker/src/index.ts | 14 ++++-- .../src/releases/evaluate/index.ts | 30 +++++++----- apps/event-worker/src/releases/mutex.ts | 30 ++++++++++++ .../src/releases/new-version/index.ts | 26 ++++++++++ .../releases/new-version/system-resources.ts | 37 ++++++++++++++ .../src/releases/variable-change/index.ts | 0 packages/db/src/schema/deployment-version.ts | 48 +++++++++++++++++++ packages/validators/src/events/index.ts | 4 ++ pnpm-lock.yaml | 20 ++++++++ 11 files changed, 197 insertions(+), 15 deletions(-) create mode 100644 apps/event-worker/src/releases/mutex.ts create mode 100644 apps/event-worker/src/releases/new-version/index.ts create mode 100644 apps/event-worker/src/releases/new-version/system-resources.ts create mode 100644 apps/event-worker/src/releases/variable-change/index.ts diff --git a/apps/event-worker/Dockerfile b/apps/event-worker/Dockerfile index 0b7a9c9fb..0f7f43b81 100644 --- a/apps/event-worker/Dockerfile +++ b/apps/event-worker/Dockerfile @@ -32,6 +32,8 @@ COPY packages/db/package.json ./packages/db/package.json COPY packages/validators/package.json ./packages/validators/package.json COPY packages/logger/package.json ./packages/logger/package.json COPY packages/job-dispatch/package.json ./packages/job-dispatch/package.json +COPY packages/release-manager/package.json ./packages/release-manager/package.json +COPY packages/rule-engine/package.json ./packages/rule-engine/package.json COPY packages/secrets/package.json ./packages/secrets/package.json COPY apps/event-worker/package.json ./apps/event-worker/package.json diff --git a/apps/event-worker/package.json b/apps/event-worker/package.json index d8b39a671..9be882694 100644 --- a/apps/event-worker/package.json +++ b/apps/event-worker/package.json @@ -37,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/index.ts b/apps/event-worker/src/index.ts index ee1249c16..4db9fc238 100644 --- a/apps/event-worker/src/index.ts +++ b/apps/event-worker/src/index.ts @@ -2,17 +2,23 @@ import { logger } from "@ctrlplane/logger"; import { createDispatchExecutionJobWorker } from "./job-dispatch/index.js"; import { redis } from "./redis.js"; +import { createReleaseNewVersionWorker } from "./releases/new-version/index.js"; import { createResourceScanWorker } from "./resource-scan/index.js"; const resourceScanWorker = createResourceScanWorker(); const dispatchExecutionJobWorker = createDispatchExecutionJobWorker(); +const releaseNewVersionWorker = createReleaseNewVersionWorker(); const shutdown = () => { logger.warn("Exiting..."); - resourceScanWorker.close(); - dispatchExecutionJobWorker.close(); - redis.quit(); - process.exit(0); + Promise.all([ + resourceScanWorker.close(), + dispatchExecutionJobWorker.close(), + releaseNewVersionWorker.close(), + ]).then(async () => { + await redis.quit(); + process.exit(0); + }); }; process.on("SIGTERM", shutdown); diff --git a/apps/event-worker/src/releases/evaluate/index.ts b/apps/event-worker/src/releases/evaluate/index.ts index 5c1a9dc2e..8c894bbf4 100644 --- a/apps/event-worker/src/releases/evaluate/index.ts +++ b/apps/event-worker/src/releases/evaluate/index.ts @@ -7,22 +7,30 @@ import { evaluate } from "@ctrlplane/rule-engine"; import { createCtx, getApplicablePolicies } from "@ctrlplane/rule-engine/db"; import { Channel } from "@ctrlplane/validators/events"; +import { ReleaseRepositoryMutex } from "../mutex.js"; + export const createReleaseEvaluateWorker = () => new Worker(Channel.ReleaseEvaluate, async (job) => { job.log( `Evaluating release for deployment ${job.data.deploymentId} and resource ${job.data.resourceId}`, ); - const ctx = await createCtx(db, job.data); - if (ctx == null) { - job.log( - `Resource ${job.data.resourceId} not found for deployment ${job.data.deploymentId} and environment ${job.data.environmentId}`, - ); - return; - } + const mutex = await ReleaseRepositoryMutex.lock(job.data); - const { workspaceId } = ctx.resource; - const policy = await getApplicablePolicies(db, workspaceId, job.data); - const result = await evaluate(policy, [], ctx); - console.log(result); + try { + const ctx = await createCtx(db, job.data); + if (ctx == null) { + job.log( + `Resource ${job.data.resourceId} not found for deployment ${job.data.deploymentId} and environment ${job.data.environmentId}`, + ); + return; + } + + const { workspaceId } = ctx.resource; + const policy = await getApplicablePolicies(db, workspaceId, job.data); + const result = await evaluate(policy, [], ctx); + console.log(result); + } finally { + await mutex.unlock(); + } }); diff --git a/apps/event-worker/src/releases/mutex.ts b/apps/event-worker/src/releases/mutex.ts new file mode 100644 index 000000000..89c9fd867 --- /dev/null +++ b/apps/event-worker/src/releases/mutex.ts @@ -0,0 +1,30 @@ +import type { ReleaseRepository } from "@ctrlplane/rule-engine"; +import type { Mutex as RedisMutex } from "redis-semaphore"; +import { Mutex as RedisSemaphoreMutex } from "redis-semaphore"; + +import { redis } from "../redis.js"; + +export class ReleaseRepositoryMutex { + static async lock(repo: ReleaseRepository) { + const mutex = new ReleaseRepositoryMutex(repo); + await mutex.lock(); + return mutex; + } + + private mutex: RedisMutex; + + constructor(repo: ReleaseRepository) { + const key = `release-repository-mutex-${repo.deploymentId}-${repo.resourceId}-${repo.environmentId}`; + this.mutex = new RedisSemaphoreMutex(redis, key, {}); + } + + async lock(): Promise { + if (this.mutex.isAcquired) throw new Error("Mutex is already locked"); + await this.mutex.acquire(); + } + + async unlock(): Promise { + if (!this.mutex.isAcquired) throw new Error("Mutex is not locked"); + await this.mutex.release(); + } +} diff --git a/apps/event-worker/src/releases/new-version/index.ts b/apps/event-worker/src/releases/new-version/index.ts new file mode 100644 index 000000000..a7e4d0063 --- /dev/null +++ b/apps/event-worker/src/releases/new-version/index.ts @@ -0,0 +1,26 @@ +import type { ReleaseNewVersionEvent } from "@ctrlplane/validators/events"; +import { Worker } from "bullmq"; +import _ from "lodash"; + +import { eq } from "@ctrlplane/db"; +import { db } from "@ctrlplane/db/client"; +import * as schema from "@ctrlplane/db/schema"; +import { Channel } from "@ctrlplane/validators/events"; + +import { getSystemResources } from "./system-resources.js"; + +export const createReleaseNewVersionWorker = () => + new Worker(Channel.ReleaseNewVersion, async (job) => { + const version = await db.query.deploymentVersion.findFirst({ + where: eq(schema.deploymentVersion.id, job.data.versionId), + with: { deployment: true }, + }); + + if (version == null) throw new Error("Version not found"); + + const { deployment } = version; + const { systemId } = deployment; + + const impactedResources = await getSystemResources(db, systemId); + console.log(impactedResources.length); + }); diff --git a/apps/event-worker/src/releases/new-version/system-resources.ts b/apps/event-worker/src/releases/new-version/system-resources.ts new file mode 100644 index 000000000..1f21e454e --- /dev/null +++ b/apps/event-worker/src/releases/new-version/system-resources.ts @@ -0,0 +1,37 @@ +import type { Tx } from "@ctrlplane/db"; +import _ from "lodash"; + +import { and, eq } from "@ctrlplane/db"; +import * as schema from "@ctrlplane/db/schema"; + +/** + * Retrieves all resources for a given system by using environment selectors + */ +export const getSystemResources = async (tx: Tx, systemId: string) => { + const system = await tx.query.system.findFirst({ + where: eq(schema.system.id, systemId), + with: { environments: true }, + }); + + if (system == null) throw new Error("System not found"); + + const { environments } = system; + + // Simplify the chained operations with standard Promise.all + const resources = await Promise.all( + environments.map(async (env) => { + const res = await tx + .select() + .from(schema.resource) + .where( + and( + eq(schema.resource.workspaceId, system.workspaceId), + schema.resourceMatchesMetadata(tx, env.resourceSelector), + ), + ); + return res.map((r) => ({ ...r, environment: env })); + }), + ).then((arrays) => arrays.flat()); + + return resources; +}; diff --git a/apps/event-worker/src/releases/variable-change/index.ts b/apps/event-worker/src/releases/variable-change/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/db/src/schema/deployment-version.ts b/packages/db/src/schema/deployment-version.ts index 24d11fd09..c4caba148 100644 --- a/packages/db/src/schema/deployment-version.ts +++ b/packages/db/src/schema/deployment-version.ts @@ -17,6 +17,7 @@ import { not, notExists, or, + relations, sql, } from "drizzle-orm"; import { @@ -286,3 +287,50 @@ export function deploymentVersionMatchesCondition( ? undefined : buildCondition(tx, condition); } + +export const deploymentVersionRelations = relations( + deploymentVersion, + ({ one, many }) => ({ + deployment: one(deployment, { + fields: [deploymentVersion.deploymentId], + references: [deployment.id], + }), + metadata: many(deploymentVersionMetadata), + dependencies: many(versionDependency), + channels: many(deploymentVersionChannel), + }), +); + +export const deploymentVersionChannelRelations = relations( + deploymentVersionChannel, + ({ one }) => ({ + deployment: one(deployment, { + fields: [deploymentVersionChannel.deploymentId], + references: [deployment.id], + }), + }), +); + +export const versionDependencyRelations = relations( + versionDependency, + ({ one }) => ({ + version: one(deploymentVersion, { + fields: [versionDependency.versionId], + references: [deploymentVersion.id], + }), + deployment: one(deployment, { + fields: [versionDependency.deploymentId], + references: [deployment.id], + }), + }), +); + +export const deploymentVersionMetadataRelations = relations( + deploymentVersionMetadata, + ({ one }) => ({ + version: one(deploymentVersion, { + fields: [deploymentVersionMetadata.versionId], + references: [deploymentVersion.id], + }), + }), +); diff --git a/packages/validators/src/events/index.ts b/packages/validators/src/events/index.ts index 57a46449b..d234e19e0 100644 --- a/packages/validators/src/events/index.ts +++ b/packages/validators/src/events/index.ts @@ -7,6 +7,7 @@ export enum Channel { DispatchJob = "dispatch-job", ResourceScan = "resource-scan", ReleaseEvaluate = "release-evaluate", + ReleaseNewVersion = "release-new-version", } export const resourceScanEvent = z.object({ resourceProviderId: z.string() }); @@ -26,3 +27,6 @@ export const releaseEvaluateEvent = z.object({ resourceId: z.string(), }); export type ReleaseEvaluateEvent = z.infer; + +export const releaseNewVersionEvent = z.object({ versionId: z.string() }); +export type ReleaseNewVersionEvent = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 138e4ccdc..9241a0e4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,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 @@ -10087,6 +10090,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==} @@ -21606,6 +21618,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)