diff --git a/apps/backend/src/services/deployment-update.service.test.ts b/apps/backend/src/services/deployment-update.service.test.ts index 1b3ce02..5127135 100644 --- a/apps/backend/src/services/deployment-update.service.test.ts +++ b/apps/backend/src/services/deployment-update.service.test.ts @@ -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; @@ -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 () => { @@ -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); + }); }); diff --git a/apps/backend/src/services/deployment-update.service.ts b/apps/backend/src/services/deployment-update.service.ts index f83db8b..54ca414 100644 --- a/apps/backend/src/services/deployment-update.service.ts +++ b/apps/backend/src/services/deployment-update.service.ts @@ -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; @@ -33,6 +35,7 @@ export interface DeploymentUpdate { errorMessage?: string; createdAt: Date; completedAt?: Date; + canaryPercent?: number; } export type DeploymentUpdateStatus = @@ -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'); @@ -215,6 +218,7 @@ export class DeploymentUpdateService { previous_state: previousState, status: 'pending', created_at: new Date().toISOString(), + canary_percent: 0, }); } @@ -244,6 +248,7 @@ export class DeploymentUpdateService { */ private async executeUpdatePipeline( updateId: string, + deploymentId: string, config: CustomizationConfig, githubPush?: UpdateDeploymentRequest['githubPush'], previousState?: DeploymentState @@ -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; @@ -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 { + 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) */ diff --git a/apps/backend/src/services/rollout-strategy.ts b/apps/backend/src/services/rollout-strategy.ts new file mode 100644 index 0000000..246a76b --- /dev/null +++ b/apps/backend/src/services/rollout-strategy.ts @@ -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 { + const counts: Record = { [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 }; + } +} diff --git a/apps/backend/src/services/vercel.service.ts b/apps/backend/src/services/vercel.service.ts index 808e63c..a966aae 100644 --- a/apps/backend/src/services/vercel.service.ts +++ b/apps/backend/src/services/vercel.service.ts @@ -810,8 +810,19 @@ export class VercelService { } } + /** + * Assign a custom alias to a Vercel deployment. + */ + async assignAlias(deploymentId: string, alias: string): Promise { + await this.request(`/v2/deployments/${deploymentId}/aliases`, { + method: 'POST', + body: JSON.stringify({ alias }), + }); + } + // ── Private helpers ─────────────────────────────────────────────────────── + // ── Deployment log retrieval (Issue #90) ───────────────────────────────── /** diff --git a/packages/sdk/tests/client.test.ts b/packages/sdk/tests/client.test.ts index 0bb4a70..a072faf 100644 --- a/packages/sdk/tests/client.test.ts +++ b/packages/sdk/tests/client.test.ts @@ -329,8 +329,37 @@ describe('Payment methods', () => { client.createCheckout({ priceId: 'p', successUrl: 's', cancelUrl: 'c' }), ).rejects.toBeInstanceOf(CraftApiError); }); + + it('getSubscription throws CraftApiError on 401 when unauthorized', async () => { + // Request: GET /api/payments/subscription + // Response: 401 Unauthorized { error: 'Unauthorized' } + vi.stubGlobal('fetch', mockFetch({ error: 'Unauthorized' }, 401)); + const err = await client.getSubscription().catch(e => e); + expect(err).toBeInstanceOf(CraftApiError); + expect(err.status).toBe(401); + }); + + it('cancelSubscription throws CraftApiError on 500 internal error', async () => { + // Request: POST /api/payments/cancel + // Response: 500 Internal Server Error { error: 'Internal Server Error' } + vi.stubGlobal('fetch', mockFetch({ error: 'Internal Server Error' }, 500)); + const err = await client.cancelSubscription().catch(e => e); + expect(err).toBeInstanceOf(CraftApiError); + expect(err.status).toBe(500); + }); + + it('throws CraftApiError with 401 if setAccessToken not called before protected method', async () => { + // Request: GET /api/payments/subscription (without Authorization header) + // Response: 401 Unauthorized { error: 'Unauthorized' } + const unauthedClient = new CraftClient({ baseUrl: BASE_URL }); + vi.stubGlobal('fetch', mockFetch({ error: 'Unauthorized' }, 401)); + const err = await unauthedClient.getSubscription().catch(e => e); + expect(err).toBeInstanceOf(CraftApiError); + expect(err.status).toBe(401); + }); }); + describe('Deployment methods', () => { let client: CraftClient; @@ -388,6 +417,15 @@ describe('Deployment methods', () => { expect(err).toBeInstanceOf(CraftApiError); expect(err.status).toBe(403); }); + + it('getDeploymentHealth throws CraftApiError on 404 not found', async () => { + // Request: GET /api/deployments/dep-missing/health + // Response: 404 Not Found { error: 'Deployment not found' } + vi.stubGlobal('fetch', mockFetch({ error: 'Deployment not found' }, 404)); + const err = await client.getDeploymentHealth('dep-missing').catch(e => e); + expect(err).toBeInstanceOf(CraftApiError); + expect(err.status).toBe(404); + }); }); describe('CraftApiError', () => {