feat(build): revamp app build pipeline (queue, Nixpacks, MinIO, Trivy, registry GC)
Rework the application build/deploy pipeline for scalability, reproducibility, and security: - Build queue: deploys run through a bounded-concurrency Bull queue (BUILD_CONCURRENCY, default 3) so concurrent user deploys can't flood the cluster with Kaniko jobs. Build state (progress / cancel / session) moves from in-memory Maps to Redis, so cancel + live logs work across backend replicas. - Nixpacks + BYO Dockerfile: code runtimes build via Nixpacks (or the user's own Dockerfile when present); the hand-written per-runtime Dockerfile generators and runtime auto-detection are removed. WordPress keeps its templated path. Build-time mirror env (NIXPACKS_BUILD_ENV) supports the Iran network. - Source upload to MinIO: archives stream to in-cluster MinIO; build pods pull via a presigned URL. Removes the PVC + helper pod + kubectl cp upload path. - Report-only Trivy scan after build; per-severity summary stored on the deployment and shown as a badge in the dashboard. Never gates a deploy. - Registry GC: a Redis-locked daily job keeps the newest N image tags per app (REGISTRY_KEEP_VERSIONS, default 3) and reclaims disk via garbage-collect. - Hardening: git tokens are delivered via a per-build Secret + git credential store instead of being embedded in the clone URL / Job manifest; build timeout is configurable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/** Bull queue name for build+deploy pipelines. */
|
||||
export const DEPLOY_QUEUE = 'app-deploy';
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Process, Processor } from '@nestjs/bull';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bull';
|
||||
import { DeploymentsService, DeploymentJobData } from './deployments.service';
|
||||
import { DEPLOY_QUEUE } from './deployment.constants';
|
||||
|
||||
/**
|
||||
* Processes build+deploy pipelines off the `app-deploy` queue with a bounded
|
||||
* concurrency (BUILD_CONCURRENCY, default 3) so simultaneous user deploys can't
|
||||
* flood the cluster with Kaniko build jobs (2 CPU / 4Gi each).
|
||||
*
|
||||
* Concurrency is read at module-load time from env because Bull's `@Process`
|
||||
* decorator option must be a constant.
|
||||
*/
|
||||
@Processor(DEPLOY_QUEUE)
|
||||
export class DeploymentProcessor {
|
||||
private readonly logger = new Logger(DeploymentProcessor.name);
|
||||
|
||||
constructor(private readonly deploymentsService: DeploymentsService) {}
|
||||
|
||||
@Process({ name: 'run', concurrency: parseInt(process.env.BUILD_CONCURRENCY || '3', 10) })
|
||||
async handleRun(job: Job<DeploymentJobData>): Promise<void> {
|
||||
const { deploymentId } = job.data;
|
||||
this.logger.log(`Processing deployment ${deploymentId} (job ${job.id})`);
|
||||
await this.deploymentsService.processDeploymentJob(job.data);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { DeploymentsService } from './deployments.service';
|
||||
import { DeploymentsController } from './deployments.controller';
|
||||
import { DeploymentProcessor } from './deployment.processor';
|
||||
import { DEPLOY_QUEUE } from './deployment.constants';
|
||||
import { Deployment } from './entities/deployment.entity';
|
||||
import { ApplicationsModule } from '../applications/applications.module';
|
||||
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||
@@ -11,13 +14,14 @@ import { ClustersModule } from '../clusters/clusters.module';
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Deployment]),
|
||||
BullModule.registerQueue({ name: DEPLOY_QUEUE }),
|
||||
forwardRef(() => ApplicationsModule),
|
||||
forwardRef(() => ClustersModule),
|
||||
KubernetesModule,
|
||||
BuildModule,
|
||||
],
|
||||
controllers: [DeploymentsController],
|
||||
providers: [DeploymentsService],
|
||||
providers: [DeploymentsService, DeploymentProcessor],
|
||||
exports: [DeploymentsService],
|
||||
})
|
||||
export class DeploymentsModule {}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger, Inject, forwardRef, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Queue } from 'bull';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as fs from 'fs';
|
||||
import { DEPLOY_QUEUE } from './deployment.constants';
|
||||
import { Deployment } from './entities/deployment.entity';
|
||||
import { ApplicationsService } from '../applications/applications.service';
|
||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||
import { BuildService, BuildProgress, BuildCancelledError } from '../build/build.service';
|
||||
import { ScanService } from '../build/scan.service';
|
||||
import * as crypto from 'crypto';
|
||||
import {
|
||||
AppLifecycleStatus,
|
||||
@@ -15,8 +19,15 @@ import {
|
||||
} from '../common/enums';
|
||||
import { ClustersService } from '../clusters/clusters.service';
|
||||
|
||||
/** Payload enqueued on the `app-deploy` queue for the build+deploy pipeline. */
|
||||
export interface DeploymentJobData {
|
||||
deploymentId: string;
|
||||
applicationId: string;
|
||||
previewSubdomain: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DeploymentsService {
|
||||
export class DeploymentsService implements OnModuleInit {
|
||||
private readonly logger = new Logger(DeploymentsService.name);
|
||||
|
||||
constructor(
|
||||
@@ -27,8 +38,41 @@ export class DeploymentsService {
|
||||
private kubernetesService: KubernetesService,
|
||||
private buildService: BuildService,
|
||||
private clustersService: ClustersService,
|
||||
private scanService: ScanService,
|
||||
@InjectQueue(DEPLOY_QUEUE)
|
||||
private deployQueue: Queue<DeploymentJobData>,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
await this.deploymentsRepository.query(
|
||||
`ALTER TABLE deployments ADD COLUMN IF NOT EXISTS "vulnerabilitySummary" jsonb`,
|
||||
);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Could not ensure deployments.vulnerabilitySummary column: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue worker entrypoint — runs one build+deploy pipeline. Bounded
|
||||
* concurrency lives on the Bull processor, so this just dispatches to the
|
||||
* managed (Helm-only) or app (build+deploy) pipeline. Both pipelines handle
|
||||
* their own errors, so a failure here never triggers a Bull retry.
|
||||
*/
|
||||
async processDeploymentJob(data: DeploymentJobData): Promise<void> {
|
||||
const { deploymentId, applicationId, previewSubdomain } = data;
|
||||
if (await this.isDeploymentCancelled(deploymentId)) {
|
||||
this.logger.log(`Deployment ${deploymentId} already cancelled before pickup — skipping`);
|
||||
return;
|
||||
}
|
||||
const app = await this.applicationsService.findOne(applicationId);
|
||||
if (isManagedProductType(app.productType)) {
|
||||
await this.executeManagedPipeline(deploymentId, app);
|
||||
} else {
|
||||
await this.executePipeline(deploymentId, app, previewSubdomain);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Random 7-digit suffix for the preview host: <userId>-<7-digit>.<baseDomain>.
|
||||
* Generated once per application (see resolvePreviewNumber) and persisted.
|
||||
@@ -77,13 +121,13 @@ export class DeploymentsService {
|
||||
saved.previewSubdomain = previewSubdomain;
|
||||
}
|
||||
|
||||
// Trigger async pipeline (Helm-only for managed services, build+deploy for apps)
|
||||
const run = isManagedProductType(app.productType)
|
||||
? this.executeManagedPipeline(saved.id, app)
|
||||
: this.executePipeline(saved.id, app, previewSubdomain);
|
||||
run.catch((error) => {
|
||||
this.logger.error(`Pipeline failed for deployment ${saved.id}:`, error);
|
||||
});
|
||||
// Enqueue the build+deploy pipeline. The Bull processor runs it with bounded
|
||||
// concurrency so concurrent user deploys can't flood the cluster.
|
||||
await this.deployQueue.add(
|
||||
'run',
|
||||
{ deploymentId: saved.id, applicationId: app.id, previewSubdomain },
|
||||
{ removeOnComplete: true, removeOnFail: true },
|
||||
);
|
||||
|
||||
return saved;
|
||||
}
|
||||
@@ -187,6 +231,19 @@ export class DeploymentsService {
|
||||
// Save build log
|
||||
await this.deploymentsRepository.update(deploymentId, { buildLog: buildResult.buildLog });
|
||||
|
||||
// Report-only vulnerability scan — runs alongside the deploy and is
|
||||
// persisted when it finishes. Never blocks or fails the deployment.
|
||||
void this.scanService
|
||||
.scanImage(app, imageUri)
|
||||
.then((summary) => {
|
||||
if (summary) {
|
||||
return this.deploymentsRepository.update(deploymentId, {
|
||||
vulnerabilitySummary: summary as Record<string, any>,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((e) => this.logger.warn(`Scan persistence failed for ${deploymentId}: ${e.message}`));
|
||||
|
||||
// Step 2: Update app with new image tag
|
||||
await this.applicationsService.updateImageTag(app.id, imageUri);
|
||||
|
||||
@@ -486,7 +543,10 @@ export class DeploymentsService {
|
||||
return this.kubernetesService.getPodLogs(app);
|
||||
}
|
||||
|
||||
async getBuildLogs(applicationId: string, userId: string): Promise<{ buildLog: string | null; status: string; version: string | null; createdAt: Date }> {
|
||||
async getBuildLogs(
|
||||
applicationId: string,
|
||||
userId: string,
|
||||
): Promise<{ buildLog: string | null; status: string; version: string | null; createdAt: Date; vulnerabilitySummary: Record<string, any> | null }> {
|
||||
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||
|
||||
const latest = await this.deploymentsRepository.findOne({
|
||||
@@ -495,7 +555,7 @@ export class DeploymentsService {
|
||||
});
|
||||
|
||||
if (!latest) {
|
||||
return { buildLog: null, status: 'no_deployment', version: null, createdAt: new Date() };
|
||||
return { buildLog: null, status: 'no_deployment', version: null, createdAt: new Date(), vulnerabilitySummary: null };
|
||||
}
|
||||
|
||||
if (this.isManagedOrHelmOnlyApp(app)) {
|
||||
@@ -504,6 +564,7 @@ export class DeploymentsService {
|
||||
status: latest.status,
|
||||
version: latest.version,
|
||||
createdAt: latest.createdAt,
|
||||
vulnerabilitySummary: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -523,6 +584,7 @@ export class DeploymentsService {
|
||||
status: latest.status,
|
||||
version: latest.version,
|
||||
createdAt: latest.createdAt,
|
||||
vulnerabilitySummary: latest.vulnerabilitySummary ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -537,7 +599,7 @@ export class DeploymentsService {
|
||||
|
||||
if (!latest) return null;
|
||||
|
||||
const progress = this.buildService.getProgress(latest.id);
|
||||
const progress = await this.buildService.getProgress(latest.id);
|
||||
if (progress) return progress;
|
||||
|
||||
// No in-memory progress — infer from deployment status
|
||||
@@ -689,10 +751,12 @@ export class DeploymentsService {
|
||||
saved.previewSubdomain = previewSubdomain;
|
||||
}
|
||||
|
||||
// Trigger async build & deploy pipeline (same as initial deploy)
|
||||
this.executePipeline(saved.id, app, previewSubdomain).catch((error) => {
|
||||
this.logger.error(`Redeploy pipeline failed for deployment ${saved.id}:`, error);
|
||||
});
|
||||
// Enqueue the build & deploy pipeline (same queue as initial deploy)
|
||||
await this.deployQueue.add(
|
||||
'run',
|
||||
{ deploymentId: saved.id, applicationId: app.id, previewSubdomain },
|
||||
{ removeOnComplete: true, removeOnFail: true },
|
||||
);
|
||||
|
||||
this.logger.log(`Redeploy triggered for ${app.name} (${app.gitUrl ? 'git: ' + app.gitUrl : 'zip'})`);
|
||||
return saved;
|
||||
|
||||
@@ -33,6 +33,14 @@ export class Deployment {
|
||||
@Column({ type: 'text', nullable: true })
|
||||
deployLog: string;
|
||||
|
||||
/**
|
||||
* Report-only Trivy vulnerability summary for the built image
|
||||
* (e.g. { critical, high, medium, low, unknown, total, scannedAt }).
|
||||
* Non-blocking — never gates a deployment.
|
||||
*/
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
vulnerabilitySummary: Record<string, any> | null;
|
||||
|
||||
/**
|
||||
* Per-deployment preview number (derived deterministically from deployment.id).
|
||||
* Used to build preview ingress host under the main frontend domain.
|
||||
|
||||
Reference in New Issue
Block a user