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
38 changes: 38 additions & 0 deletions docs/DEPLOYMENTS.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 9 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -151,6 +156,8 @@ import { QuotaGuard } from "./common/guard/quota.guard";
Subscription,
Transaction,
WebhookEvent,
Deployment,
DeploymentEvent,
],
synchronize: !isProduction,
logging: isProduction ? ["error"] : ["error", "warn", "schema"],
Expand Down Expand Up @@ -191,6 +198,7 @@ import { QuotaGuard } from "./common/guard/quota.guard";
NotificationsModule,
MessagingModule,
PaymentsModule,
DeploymentsModule,
],

controllers: [AppController],
Expand Down Expand Up @@ -229,4 +237,4 @@ export class AppModule implements NestModule, OnModuleInit {
onModuleInit() {
this.verifier.start();
}
}
}
59 changes: 59 additions & 0 deletions src/deployments/deployments.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
14 changes: 14 additions & 0 deletions src/deployments/deployments.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
114 changes: 114 additions & 0 deletions src/deployments/deployments.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Repository<Deployment>>;
let events: jest.Mocked<Repository<DeploymentEvent>>;

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<Repository<Deployment>>;
events = {
create: jest.fn((value) => value as DeploymentEvent),
save: jest.fn(async (value) => value as DeploymentEvent),
find: jest.fn(),
} as unknown as jest.Mocked<Repository<DeploymentEvent>>;
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,
);
});
});
Loading
Loading