diff --git a/docs/DEPLOYMENTS.md b/docs/DEPLOYMENTS.md new file mode 100644 index 00000000..8c59f12e --- /dev/null +++ b/docs/DEPLOYMENTS.md @@ -0,0 +1,38 @@ +# Deployment lifecycle API + +The deployment module gives CI systems a small, idempotent API for recording +build and deployment health. Requests require an operator (or administrator) +role and a bearer token issued by the normal authentication flow. + +## Record a deployment + +```sh +curl -X POST "$API_URL/deployments" \ + -H "Authorization: Bearer $DEPLOYMENT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "externalId": "github-${GITHUB_RUN_ID}", + "environment": "production", + "version": "${GITHUB_SHA::12}", + "commitSha": "'"$GITHUB_SHA"'", + "metadata": {"workflow": "deploy", "runUrl": "'"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"'"} + }' +``` + +`externalId` is unique and makes retries safe: replaying the same CI event +returns the original deployment rather than creating a duplicate. + +## Report status + +Use `PATCH /deployments/:id/status` with `in_progress`, `succeeded`, or +`failed`. A failed event should include a useful `message`; it is stored as +the failure reason and in the immutable event history. Status transitions are +validated, so a completed deployment cannot silently move back to running. + +## Rollbacks and history + +`POST /deployments/:id/rollback` records an operator rollback request. The CI +rollback job should subsequently report `rolled_back` through the status +endpoint. `GET /deployments` supports `environment`, `status`, and `limit` +filters, while `GET /deployments/:id/history` provides the audit trail needed +for incident review and operator notifications. diff --git a/src/app.module.ts b/src/app.module.ts index 26a7aa10..5ccabeb5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -33,6 +33,7 @@ import { RateLimitModule } from "./quota/rate-limit.module"; import { NotificationsModule } from "./notifications/notifications.module"; import { MessagingModule } from "./messaging/messaging.module"; import { PaymentsModule } from "./payments/payments.module"; +import { DeploymentsModule } from "./deployments/deployments.module"; // Auth entities import { Conversation } from "./messaging/entities/conversation.entity"; @@ -81,6 +82,10 @@ import { Subscription } from "./payments/entities/subscription.entity"; import { Transaction } from "./payments/entities/transaction.entity"; import { WebhookEvent } from "./payments/entities/webhook-event.entity"; +// Deployment entities +import { Deployment } from "./deployments/entities/deployment.entity"; +import { DeploymentEvent } from "./deployments/entities/deployment-event.entity"; + // Guards import { ThrottlerUserIpGuard } from "./common/guard/throttler.guard"; import { RolesGuard } from "./common/guard/roles.guard"; @@ -151,6 +156,8 @@ import { QuotaGuard } from "./common/guard/quota.guard"; Subscription, Transaction, WebhookEvent, + Deployment, + DeploymentEvent, ], synchronize: !isProduction, logging: isProduction ? ["error"] : ["error", "warn", "schema"], @@ -191,6 +198,7 @@ import { QuotaGuard } from "./common/guard/quota.guard"; NotificationsModule, MessagingModule, PaymentsModule, + DeploymentsModule, ], controllers: [AppController], @@ -229,4 +237,4 @@ export class AppModule implements NestModule, OnModuleInit { onModuleInit() { this.verifier.start(); } -} \ No newline at end of file +} diff --git a/src/deployments/deployments.controller.ts b/src/deployments/deployments.controller.ts new file mode 100644 index 00000000..320c9fdb --- /dev/null +++ b/src/deployments/deployments.controller.ts @@ -0,0 +1,59 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { RequireRole } from "../common/decorators/roles.decorator"; +import { Role } from "../common/guard/roles.enum"; +import { CreateDeploymentDto } from "./dto/create-deployment.dto"; +import { QueryDeploymentsDto } from "./dto/query-deployments.dto"; +import { UpdateDeploymentStatusDto } from "./dto/update-deployment-status.dto"; +import { DeploymentsService } from "./deployments.service"; + +@ApiTags("deployments") +@ApiBearerAuth() +@RequireRole(Role.OPERATOR) +@Controller("deployments") +export class DeploymentsController { + constructor(private readonly service: DeploymentsService) {} + + @Post() + @ApiOperation({ summary: "Register a CI/CD deployment event" }) + create(@Body() dto: CreateDeploymentDto) { + return this.service.create(dto); + } + + @Patch(":id/status") + @ApiOperation({ summary: "Record a deployment status transition" }) + updateStatus( + @Param("id") id: string, + @Body() dto: UpdateDeploymentStatusDto, + ) { + return this.service.updateStatus(id, dto); + } + + @Post(":id/rollback") + @ApiOperation({ summary: "Request and record a deployment rollback" }) + requestRollback(@Param("id") id: string, @Body() body: { reason?: string }) { + return this.service.requestRollback(id, body?.reason); + } + + @Get() + @ApiOperation({ summary: "List recent deployments and their health" }) + findRecent(@Query() query: QueryDeploymentsDto) { + return this.service.findRecent(query); + } + + @Get(":id/history") + @ApiOperation({ + summary: "View the immutable status history for a deployment", + }) + history(@Param("id") id: string) { + return this.service.history(id); + } +} diff --git a/src/deployments/deployments.module.ts b/src/deployments/deployments.module.ts new file mode 100644 index 00000000..2a278dec --- /dev/null +++ b/src/deployments/deployments.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { DeploymentsController } from "./deployments.controller"; +import { DeploymentsService } from "./deployments.service"; +import { DeploymentEvent } from "./entities/deployment-event.entity"; +import { Deployment } from "./entities/deployment.entity"; + +@Module({ + imports: [TypeOrmModule.forFeature([Deployment, DeploymentEvent])], + controllers: [DeploymentsController], + providers: [DeploymentsService], + exports: [DeploymentsService], +}) +export class DeploymentsModule {} diff --git a/src/deployments/deployments.service.spec.ts b/src/deployments/deployments.service.spec.ts new file mode 100644 index 00000000..3d3e4092 --- /dev/null +++ b/src/deployments/deployments.service.spec.ts @@ -0,0 +1,114 @@ +import { ConflictException, NotFoundException } from "@nestjs/common"; +import { Repository } from "typeorm"; +import { DeploymentsService } from "./deployments.service"; +import { + DeploymentEnvironment, + DeploymentStatus, +} from "./entities/deployment.enums"; +import { Deployment } from "./entities/deployment.entity"; +import { DeploymentEvent } from "./entities/deployment-event.entity"; + +describe("DeploymentsService", () => { + let service: DeploymentsService; + let deployments: jest.Mocked>; + let events: jest.Mocked>; + + beforeEach(() => { + deployments = { + findOne: jest.fn(), + create: jest.fn((value) => value as Deployment), + save: jest.fn( + async (value) => ({ id: "deployment-1", ...value }) as Deployment, + ), + createQueryBuilder: jest.fn(), + } as unknown as jest.Mocked>; + events = { + create: jest.fn((value) => value as DeploymentEvent), + save: jest.fn(async (value) => value as DeploymentEvent), + find: jest.fn(), + } as unknown as jest.Mocked>; + service = new DeploymentsService(deployments, events); + }); + + it("records a deployment and its initial event", async () => { + const result = await service.create({ + externalId: "github-run-42", + environment: DeploymentEnvironment.STAGING, + version: "2026.08.20.1", + commitSha: "abc123", + metadata: { workflow: "deploy" }, + }); + + expect(result).toMatchObject({ + id: "deployment-1", + externalId: "github-run-42", + status: DeploymentStatus.RECEIVED, + }); + expect(events.save).toHaveBeenCalledWith( + expect.objectContaining({ + deploymentId: "deployment-1", + status: DeploymentStatus.RECEIVED, + }), + ); + }); + + it("makes repeated CI submissions idempotent", async () => { + const existing = { + id: "existing", + externalId: "github-run-42", + } as Deployment; + deployments.findOne.mockResolvedValue(existing); + + await expect( + service.create({ + externalId: "github-run-42", + environment: DeploymentEnvironment.PRODUCTION, + version: "new-version", + commitSha: "new-sha", + }), + ).resolves.toBe(existing); + expect(deployments.save).not.toHaveBeenCalled(); + expect(events.save).not.toHaveBeenCalled(); + }); + + it("enforces the lifecycle and records every valid transition", async () => { + deployments.findOne.mockResolvedValue({ + id: "deployment-1", + status: DeploymentStatus.IN_PROGRESS, + metadata: {}, + } as Deployment); + + const result = await service.updateStatus("deployment-1", { + status: DeploymentStatus.FAILED, + message: "health check failed", + }); + + expect(result).toMatchObject({ + status: DeploymentStatus.FAILED, + failureReason: "health check failed", + }); + expect(events.save).toHaveBeenCalledWith( + expect.objectContaining({ + status: DeploymentStatus.FAILED, + message: "health check failed", + }), + ); + }); + + it("rejects invalid transitions and unknown deployments", async () => { + deployments.findOne.mockResolvedValue({ + id: "deployment-1", + status: DeploymentStatus.ROLLED_BACK, + } as Deployment); + await expect( + service.updateStatus("deployment-1", { + status: DeploymentStatus.SUCCEEDED, + }), + ).rejects.toBeInstanceOf(ConflictException); + + deployments.findOne.mockResolvedValue(null); + await expect(service.history("missing")).rejects.toBeInstanceOf( + NotFoundException, + ); + }); +}); diff --git a/src/deployments/deployments.service.ts b/src/deployments/deployments.service.ts new file mode 100644 index 00000000..a53d1e34 --- /dev/null +++ b/src/deployments/deployments.service.ts @@ -0,0 +1,147 @@ +import { + ConflictException, + Injectable, + NotFoundException, + BadRequestException, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { CreateDeploymentDto } from "./dto/create-deployment.dto"; +import { QueryDeploymentsDto } from "./dto/query-deployments.dto"; +import { UpdateDeploymentStatusDto } from "./dto/update-deployment-status.dto"; +import { DeploymentEvent } from "./entities/deployment-event.entity"; +import { + DEPLOYMENT_STATUS_TRANSITIONS, + DeploymentStatus, +} from "./entities/deployment.enums"; +import { Deployment } from "./entities/deployment.entity"; + +@Injectable() +export class DeploymentsService { + constructor( + @InjectRepository(Deployment) + private readonly deployments: Repository, + @InjectRepository(DeploymentEvent) + private readonly events: Repository, + ) {} + + async create(dto: CreateDeploymentDto): Promise { + if (dto.externalId) { + const existing = await this.deployments.findOne({ + where: { externalId: dto.externalId }, + }); + if (existing) return existing; + } + + const status = dto.status ?? DeploymentStatus.RECEIVED; + const deployment = this.deployments.create({ + externalId: dto.externalId ?? null, + environment: dto.environment, + version: dto.version, + commitSha: dto.commitSha, + status, + failureReason: + status === DeploymentStatus.FAILED ? (dto.message ?? null) : null, + rollbackReason: null, + metadata: dto.metadata ?? {}, + completedAt: this.isTerminal(status) ? new Date() : null, + rollbackRequestedAt: null, + }); + const saved = await this.deployments.save(deployment); + await this.recordEvent(saved, status, dto.message, dto.metadata); + return saved; + } + + async updateStatus( + id: string, + dto: UpdateDeploymentStatusDto, + ): Promise { + const deployment = await this.get(id); + if (deployment.status === dto.status) return deployment; + + const allowed = DEPLOYMENT_STATUS_TRANSITIONS[deployment.status] ?? []; + if (!allowed.includes(dto.status)) { + throw new ConflictException( + `Cannot transition deployment from ${deployment.status} to ${dto.status}`, + ); + } + + deployment.status = dto.status; + deployment.failureReason = + dto.status === DeploymentStatus.FAILED + ? (dto.message ?? null) + : deployment.failureReason; + deployment.completedAt = this.isTerminal(dto.status) ? new Date() : null; + if (dto.status === DeploymentStatus.ROLLBACK_REQUESTED) { + deployment.rollbackRequestedAt = new Date(); + deployment.rollbackReason = dto.message ?? null; + } + if (dto.metadata) + deployment.metadata = { ...deployment.metadata, ...dto.metadata }; + + const saved = await this.deployments.save(deployment); + await this.recordEvent(saved, dto.status, dto.message, dto.metadata); + return saved; + } + + async requestRollback(id: string, reason?: string): Promise { + return this.updateStatus(id, { + status: DeploymentStatus.ROLLBACK_REQUESTED, + message: reason, + }); + } + + async findRecent(query: QueryDeploymentsDto) { + const builder = this.deployments.createQueryBuilder("deployment"); + if (query.environment) + builder.andWhere("deployment.environment = :environment", { + environment: query.environment, + }); + if (query.status) + builder.andWhere("deployment.status = :status", { status: query.status }); + const [data, total] = await builder + .orderBy("deployment.createdAt", "DESC") + .take(query.limit) + .getManyAndCount(); + return { data, total, limit: query.limit }; + } + + async history(id: string) { + await this.get(id); + return this.events.find({ + where: { deploymentId: id }, + order: { createdAt: "ASC" }, + }); + } + + private async get(id: string): Promise { + const deployment = await this.deployments.findOne({ where: { id } }); + if (!deployment) + throw new NotFoundException(`Deployment ${id} was not found`); + return deployment; + } + + private async recordEvent( + deployment: Deployment, + status: DeploymentStatus, + message?: string, + metadata?: Record, + ) { + await this.events.save( + this.events.create({ + deploymentId: deployment.id, + status, + message: message ?? null, + metadata: metadata ?? {}, + }), + ); + } + + private isTerminal(status: DeploymentStatus) { + return [ + DeploymentStatus.SUCCEEDED, + DeploymentStatus.FAILED, + DeploymentStatus.ROLLED_BACK, + ].includes(status); + } +} diff --git a/src/deployments/dto/create-deployment.dto.ts b/src/deployments/dto/create-deployment.dto.ts new file mode 100644 index 00000000..b8291e6a --- /dev/null +++ b/src/deployments/dto/create-deployment.dto.ts @@ -0,0 +1,56 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsEnum, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + MaxLength, +} from "class-validator"; +import { + DeploymentEnvironment, + DeploymentStatus, +} from "../entities/deployment.enums"; + +export class CreateDeploymentDto { + @ApiPropertyOptional({ description: "CI provider's idempotency key" }) + @IsOptional() + @IsString() + @MaxLength(128) + externalId?: string; + + @ApiProperty({ enum: DeploymentEnvironment }) + @IsEnum(DeploymentEnvironment) + environment: DeploymentEnvironment; + + @ApiProperty({ example: "2026.08.20.1" }) + @IsString() + @IsNotEmpty() + @MaxLength(128) + version: string; + + @ApiProperty({ example: "a1b2c3d4" }) + @IsString() + @IsNotEmpty() + @MaxLength(64) + commitSha: string; + + @ApiPropertyOptional({ + enum: DeploymentStatus, + default: DeploymentStatus.RECEIVED, + }) + @IsOptional() + @IsEnum(DeploymentStatus) + status?: DeploymentStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(2000) + message?: string; + + @ApiPropertyOptional({ type: Object }) + @IsOptional() + @IsObject() + metadata?: Record; +} diff --git a/src/deployments/dto/query-deployments.dto.ts b/src/deployments/dto/query-deployments.dto.ts new file mode 100644 index 00000000..61243186 --- /dev/null +++ b/src/deployments/dto/query-deployments.dto.ts @@ -0,0 +1,27 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Type } from "class-transformer"; +import { IsEnum, IsInt, IsOptional, IsPositive, Max } from "class-validator"; +import { + DeploymentEnvironment, + DeploymentStatus, +} from "../entities/deployment.enums"; + +export class QueryDeploymentsDto { + @ApiPropertyOptional({ enum: DeploymentEnvironment }) + @IsOptional() + @IsEnum(DeploymentEnvironment) + environment?: DeploymentEnvironment; + + @ApiPropertyOptional({ enum: DeploymentStatus }) + @IsOptional() + @IsEnum(DeploymentStatus) + status?: DeploymentStatus; + + @ApiPropertyOptional({ default: 20, maximum: 100 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @IsPositive() + @Max(100) + limit = 20; +} diff --git a/src/deployments/dto/update-deployment-status.dto.ts b/src/deployments/dto/update-deployment-status.dto.ts new file mode 100644 index 00000000..790b507b --- /dev/null +++ b/src/deployments/dto/update-deployment-status.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + IsEnum, + IsObject, + IsOptional, + IsString, + MaxLength, +} from "class-validator"; +import { DeploymentStatus } from "../entities/deployment.enums"; + +export class UpdateDeploymentStatusDto { + @ApiProperty({ enum: DeploymentStatus }) + @IsEnum(DeploymentStatus) + status: DeploymentStatus; + + @ApiPropertyOptional({ description: "Failure or transition message" }) + @IsOptional() + @IsString() + @MaxLength(2000) + message?: string; + + @ApiPropertyOptional({ type: Object }) + @IsOptional() + @IsObject() + metadata?: Record; +} diff --git a/src/deployments/entities/deployment-event.entity.ts b/src/deployments/entities/deployment-event.entity.ts new file mode 100644 index 00000000..8ee761e7 --- /dev/null +++ b/src/deployments/entities/deployment-event.entity.ts @@ -0,0 +1,30 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from "typeorm"; +import { DeploymentStatus } from "./deployment.enums"; + +@Entity("deployment_events") +@Index(["deploymentId", "createdAt"]) +export class DeploymentEvent { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ type: "uuid" }) + deploymentId: string; + + @Column({ type: "enum", enum: DeploymentStatus }) + status: DeploymentStatus; + + @Column({ type: "text", nullable: true }) + message: string | null; + + @Column({ type: "jsonb", default: {} }) + metadata: Record; + + @CreateDateColumn({ type: "timestamptz" }) + createdAt: Date; +} diff --git a/src/deployments/entities/deployment.entity.ts b/src/deployments/entities/deployment.entity.ts new file mode 100644 index 00000000..ecaf736a --- /dev/null +++ b/src/deployments/entities/deployment.entity.ts @@ -0,0 +1,57 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from "typeorm"; +import { DeploymentEnvironment, DeploymentStatus } from "./deployment.enums"; + +@Entity("deployments") +@Index(["environment", "createdAt"]) +@Index(["status", "createdAt"]) +export class Deployment { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ type: "varchar", length: 128, nullable: true, unique: true }) + externalId: string | null; + + @Column({ type: "enum", enum: DeploymentEnvironment }) + environment: DeploymentEnvironment; + + @Column({ type: "varchar", length: 128 }) + version: string; + + @Column({ type: "varchar", length: 64 }) + commitSha: string; + + @Column({ + type: "enum", + enum: DeploymentStatus, + default: DeploymentStatus.RECEIVED, + }) + status: DeploymentStatus; + + @Column({ type: "text", nullable: true }) + failureReason: string | null; + + @Column({ type: "text", nullable: true }) + rollbackReason: string | null; + + @Column({ type: "jsonb", default: {} }) + metadata: Record; + + @Column({ type: "timestamptz", nullable: true }) + completedAt: Date | null; + + @Column({ type: "timestamptz", nullable: true }) + rollbackRequestedAt: Date | null; + + @CreateDateColumn({ type: "timestamptz" }) + createdAt: Date; + + @UpdateDateColumn({ type: "timestamptz" }) + updatedAt: Date; +} diff --git a/src/deployments/entities/deployment.enums.ts b/src/deployments/entities/deployment.enums.ts new file mode 100644 index 00000000..ad186651 --- /dev/null +++ b/src/deployments/entities/deployment.enums.ts @@ -0,0 +1,39 @@ +export enum DeploymentEnvironment { + DEVELOPMENT = "development", + STAGING = "staging", + PRODUCTION = "production", +} + +export enum DeploymentStatus { + RECEIVED = "received", + IN_PROGRESS = "in_progress", + SUCCEEDED = "succeeded", + FAILED = "failed", + ROLLBACK_REQUESTED = "rollback_requested", + ROLLED_BACK = "rolled_back", +} + +export const TERMINAL_DEPLOYMENT_STATUSES = [ + DeploymentStatus.SUCCEEDED, + DeploymentStatus.FAILED, + DeploymentStatus.ROLLED_BACK, +]; + +export const DEPLOYMENT_STATUS_TRANSITIONS: Record< + DeploymentStatus, + DeploymentStatus[] +> = { + [DeploymentStatus.RECEIVED]: [ + DeploymentStatus.IN_PROGRESS, + DeploymentStatus.SUCCEEDED, + DeploymentStatus.FAILED, + ], + [DeploymentStatus.IN_PROGRESS]: [ + DeploymentStatus.SUCCEEDED, + DeploymentStatus.FAILED, + ], + [DeploymentStatus.SUCCEEDED]: [DeploymentStatus.ROLLBACK_REQUESTED], + [DeploymentStatus.FAILED]: [DeploymentStatus.ROLLBACK_REQUESTED], + [DeploymentStatus.ROLLBACK_REQUESTED]: [DeploymentStatus.ROLLED_BACK], + [DeploymentStatus.ROLLED_BACK]: [], +};