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:
keyhan
2026-06-20 22:58:58 +03:30
parent 49726f1dfd
commit 3eff38f8d2
27 changed files with 1950 additions and 1295 deletions
+80 -16
View File
@@ -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;