Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions apps/backend/src/services/deployment-update.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ vi.mock('./github-push.service', () => ({
},
}));

// Mock Vercel Service
vi.mock('./vercel.service', () => {
const VercelServiceMock = vi.fn().mockImplementation(() => ({
assignAlias: vi.fn().mockResolvedValue(undefined),
}));
return { VercelService: VercelServiceMock };
});

describe('DeploymentUpdateService', () => {
let service: DeploymentUpdateService;
let mockSupabase: any;
Expand Down Expand Up @@ -73,6 +81,9 @@ describe('DeploymentUpdateService', () => {

// Reset the global failure flag
(globalThis as any).__DEPLOYMENT_UPDATE_SHOULD_FAIL = false;
(globalThis as any).__CANARY_ERROR_RATE = 0;
(globalThis as any).__CANARY_LATENCY = 100;
(globalThis as any).__MANUAL_ROLLBACK = false;
});

it('should successfully update a deployment', async () => {
Expand Down Expand Up @@ -248,4 +259,86 @@ describe('DeploymentUpdateService', () => {
expect(githubPushService.pushGeneratedCode).toHaveBeenCalled();
expect(result.commitRef).toEqual(mockCommitRef);
});

it('should successfully promote a deployment using rollout strategy', async () => {
mockSupabase.single.mockResolvedValueOnce({ data: mockPreviousState, error: null });
mockSupabase.single.mockResolvedValueOnce({ data: { previous_state: mockPreviousState }, error: null });

const result = await service.updateDeployment({
deploymentId: mockDeploymentId,
userId: mockUserId,
customizationConfig: mockConfig,
});

expect(result.success).toBe(true);
expect(result.rolledBack).toBe(false);

// Verify canary percentages were updated in sequence: 5, 25, 50, 100
const canaryUpdates = mockSupabase.update.mock.calls
.filter((call: any) => call[0].canary_percent !== undefined)
.map((call: any) => call[0].canary_percent);

expect(canaryUpdates).toContain(5);
expect(canaryUpdates).toContain(25);
expect(canaryUpdates).toContain(50);
expect(canaryUpdates).toContain(100);
});

it('should auto-rollback on error rate spike', async () => {
mockSupabase.single.mockResolvedValueOnce({ data: mockPreviousState, error: null });
mockSupabase.single.mockResolvedValueOnce({
data: {
previous_state: {
customizationConfig: mockPreviousState.customization_config,
deploymentUrl: mockPreviousState.deployment_url,
vercelDeploymentId: mockPreviousState.vercel_deployment_id,
status: mockPreviousState.status,
repositoryUrl: null,
},
},
error: null
});

// Set error rate to 10% (above threshold)
(globalThis as any).__CANARY_ERROR_RATE = 0.1;

const result = await service.updateDeployment({
deploymentId: mockDeploymentId,
userId: mockUserId,
customizationConfig: mockConfig,
});

expect(result.success).toBe(false);
expect(result.rolledBack).toBe(true);
expect(result.errorMessage).toMatch(/auto-rollback triggered/i);
});

it('should execute manual rollback when requested by operator', async () => {
mockSupabase.single.mockResolvedValueOnce({ data: mockPreviousState, error: null });
mockSupabase.single.mockResolvedValueOnce({
data: {
previous_state: {
customizationConfig: mockPreviousState.customization_config,
deploymentUrl: mockPreviousState.deployment_url,
vercelDeploymentId: mockPreviousState.vercel_deployment_id,
status: mockPreviousState.status,
repositoryUrl: null,
},
},
error: null
});

// Trigger manual rollback
(globalThis as any).__MANUAL_ROLLBACK = true;

const result = await service.updateDeployment({
deploymentId: mockDeploymentId,
userId: mockUserId,
customizationConfig: mockConfig,
});

expect(result.success).toBe(false);
expect(result.rolledBack).toBe(true);
expect(result.errorMessage).toMatch(/manual rollback triggered/i);
});
});
84 changes: 83 additions & 1 deletion apps/backend/src/services/deployment-update.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
type GitHubPushService,
} from './github-push.service';
import { parseRepoIdentity } from './github-repository-update.service';
import { RolloutEngine, BlueGreenSwitcher } from './rollout-strategy';
import { VercelService } from './vercel.service';

export interface DeploymentUpdate {
id: string;
Expand All @@ -33,6 +35,7 @@ export interface DeploymentUpdate {
errorMessage?: string;
createdAt: Date;
completedAt?: Date;
canaryPercent?: number;
}

export type DeploymentUpdateStatus =
Expand Down Expand Up @@ -133,7 +136,7 @@ export class DeploymentUpdateService {
// - Generate new code
// - Update repository
// - Trigger Vercel redeployment
const pipeline = await this.executeUpdatePipeline(updateId, customizationConfig, githubPush, previousState);
const pipeline = await this.executeUpdatePipeline(updateId, deploymentId, customizationConfig, githubPush, previousState);

if (!pipeline.success) {
throw new Error('Update pipeline failed');
Expand Down Expand Up @@ -215,6 +218,7 @@ export class DeploymentUpdateService {
previous_state: previousState,
status: 'pending',
created_at: new Date().toISOString(),
canary_percent: 0,
});
}

Expand Down Expand Up @@ -244,6 +248,7 @@ export class DeploymentUpdateService {
*/
private async executeUpdatePipeline(
updateId: string,
deploymentId: string,
config: CustomizationConfig,
githubPush?: UpdateDeploymentRequest['githubPush'],
previousState?: DeploymentState
Expand Down Expand Up @@ -292,6 +297,65 @@ export class DeploymentUpdateService {
// Simulate Vercel redeployment
await this.simulateWork();

// ── ROLLOUT LOGIC ──
const stableVersion = {
id: previousState?.vercelDeploymentId || 'blue-id',
errorRate: 0,
p99LatencyMs: 100
};
const candidateVersion = {
id: `vercel-${crypto.randomUUID()}`,
errorRate: (global as any).__CANARY_ERROR_RATE ?? 0,
p99LatencyMs: (global as any).__CANARY_LATENCY ?? 100
};

const engine = new RolloutEngine(stableVersion, candidateVersion);
const switcher = new BlueGreenSwitcher(stableVersion, candidateVersion, 'blue');

// Check for manual rollback flag
if ((global as any).__MANUAL_ROLLBACK === true) {
await this.updateCanaryPercent(updateId, 0);
throw new Error('Manual rollback triggered');
}

// Incremental traffic split: 5% -> 25% -> 50% -> 100%
const steps = [5, 25, 50, 100];
for (const pct of steps) {
engine.setTrafficPercent(pct);
await this.updateCanaryPercent(updateId, pct);

// Check for manual rollback flag mid-flight
if ((global as any).__MANUAL_ROLLBACK === true) {
await this.updateCanaryPercent(updateId, 0);
throw new Error('Manual rollback triggered');
}

const didRollback = engine.evaluateAndMaybeRollback();
if (didRollback) {
await this.updateCanaryPercent(updateId, 0);
throw new Error('Auto-rollback triggered due to error rate or latency spike');
}
}

// Use BlueGreenSwitcher to switch aliases
const vercelService = new VercelService();
const stableAlias = `app-${deploymentId}.vercel.app`;

try {
// Assign alias to candidate (promotion)
await vercelService.assignAlias(candidateVersion.id, stableAlias);
switcher.switchToStandby();
} catch (error) {
// Edge case: Vercel alias update fails mid-switch — revert to previous alias automatically
console.error('Vercel alias update failed mid-switch, reverting to previous alias');
try {
await vercelService.assignAlias(stableVersion.id, stableAlias);
} catch (revertError) {
console.error('Revert to previous alias failed:', revertError);
}
throw new Error(`Vercel alias update failed mid-switch: ${(error as Error).message}`);
}

// For property testing, we use a global flag to simulate failures
// In production, this would be actual pipeline logic
const shouldFail = (global as any).__DEPLOYMENT_UPDATE_SHOULD_FAIL === true;
Expand Down Expand Up @@ -405,6 +469,24 @@ export class DeploymentUpdateService {
.eq('id', updateId);
}

/**
* Update the canary percentage of an update record
*/
private async updateCanaryPercent(
updateId: string,
canaryPercent: number
): Promise<void> {
const supabase = createClient();

await supabase
.from('deployment_updates')
.update({
canary_percent: canaryPercent,
updated_at: new Date().toISOString(),
})
.eq('id', updateId);
}

/**
* Simulate async work (for pipeline simulation)
*/
Expand Down
128 changes: 128 additions & 0 deletions apps/backend/src/services/rollout-strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Deployment Rollout Strategy
*
* Implements canary, blue-green, and percentage-based rollout strategies.
*/

export type DeploymentColor = 'blue' | 'green';
export type RolloutStatus = 'pending' | 'in_progress' | 'promoted' | 'rolled_back';

export interface DeploymentVersion {
id: string;
errorRate: number; // 0–1
p99LatencyMs: number;
}

export interface TrafficRequest {
id: string;
}

export interface TrafficResult {
requestId: string;
servedBy: string; // deployment version id
}

export const ROLLBACK_ERROR_RATE_THRESHOLD = 0.05;
export const ROLLBACK_LATENCY_THRESHOLD_MS = 2_000;

export class RolloutEngine {
private _canaryPercent = 0;
private _status: RolloutStatus = 'pending';
private _requestCounter = 0;

constructor(
private readonly stable: DeploymentVersion,
private readonly candidate: DeploymentVersion,
) {}

get status(): RolloutStatus { return this._status; }
get canaryPercent(): number { return this._canaryPercent; }

/** Set the percentage of traffic routed to the candidate. */
setTrafficPercent(pct: number): void {
if (pct < 0 || pct > 100) throw new RangeError('pct must be 0–100');
this._canaryPercent = pct;
this._status = pct === 0 ? 'pending' : pct === 100 ? 'promoted' : 'in_progress';
}

/** Route a single request; returns which version served it. */
route(req: TrafficRequest): TrafficResult {
this._requestCounter++;
const useCanary = (this._requestCounter % 100) < this._canaryPercent;
const version = useCanary ? this.candidate : this.stable;
return { requestId: req.id, servedBy: version.id };
}

/** Simulate N requests and return counts per version. */
simulateTraffic(n: number): Record<string, number> {
const counts: Record<string, number> = { [this.stable.id]: 0, [this.candidate.id]: 0 };
for (let i = 0; i < n; i++) {
const { servedBy } = this.route({ id: `req-${i}` });
counts[servedBy] = (counts[servedBy] ?? 0) + 1;
}
return counts;
}

/**
* Evaluate candidate health and auto-rollback if thresholds are breached.
* Returns true if rollback was triggered.
*/
evaluateAndMaybeRollback(): boolean {
const shouldRollback =
this.candidate.errorRate >= ROLLBACK_ERROR_RATE_THRESHOLD ||
this.candidate.p99LatencyMs > ROLLBACK_LATENCY_THRESHOLD_MS;

if (shouldRollback) {
this._canaryPercent = 0;
this._status = 'rolled_back';
}
return shouldRollback;
}

promote(): void {
this._canaryPercent = 100;
this._status = 'promoted';
}
}

export class BlueGreenSwitcher {
private _active: DeploymentColor;
private _standby: DeploymentColor;

constructor(
private readonly blue: DeploymentVersion,
private readonly green: DeploymentVersion,
initial: DeploymentColor = 'blue',
) {
this._active = initial;
this._standby = initial === 'blue' ? 'green' : 'blue';
}

get active(): DeploymentColor { return this._active; }
get standby(): DeploymentColor { return this._standby; }

activeVersion(): DeploymentVersion {
return this._active === 'blue' ? this.blue : this.green;
}

standbyVersion(): DeploymentVersion {
return this._standby === 'blue' ? this.blue : this.green;
}

/** Switch traffic to standby if it is healthy; returns success. */
switchToStandby(): boolean {
const candidate = this.standbyVersion();
const healthy =
candidate.errorRate < ROLLBACK_ERROR_RATE_THRESHOLD &&
candidate.p99LatencyMs <= ROLLBACK_LATENCY_THRESHOLD_MS;

if (healthy) {
[this._active, this._standby] = [this._standby, this._active];
}
return healthy;
}

route(req: TrafficRequest): TrafficResult {
return { requestId: req.id, servedBy: this.activeVersion().id };
}
}
11 changes: 11 additions & 0 deletions apps/backend/src/services/vercel.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -810,8 +810,19 @@ export class VercelService {
}
}

/**
* Assign a custom alias to a Vercel deployment.
*/
async assignAlias(deploymentId: string, alias: string): Promise<void> {
await this.request(`/v2/deployments/${deploymentId}/aliases`, {
method: 'POST',
body: JSON.stringify({ alias }),
});
}

// ── Private helpers ───────────────────────────────────────────────────────


// ── Deployment log retrieval (Issue #90) ─────────────────────────────────

/**
Expand Down
Loading