revert(build): remove app build pipeline revamp (Nixpacks/MinIO/Trivy/registry GC)

Reverts commits 3eff38f and c379a23 and restores the previous Kaniko-only
build pipeline (runtime detection + per-runtime Dockerfile generation,
disk-based source upload).

Removed: Nixpacks Dockerfile generation, MinIO source storage (common/storage),
Bull build queue + Redis build state (common/redis, deployment.processor),
Trivy image scan (scan.service, deployment.vulnerabilitySummary), and daily
registry garbage collection (registry-gc). Nothing outside the build/deploy
path depended on these. Backend tsc + 105/106 tests green (the pre-existing
helm.service chartPath failure is unrelated); frontend tsc green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-23 19:21:30 +03:30
parent bd14eb2daa
commit 9c16b462f4
27 changed files with 1297 additions and 1951 deletions
+2 -3
View File
@@ -1,6 +1,5 @@
import { Module, forwardRef } from '@nestjs/common';
import { BuildService } from './build.service';
import { ScanService } from './scan.service';
import { KubernetesModule } from '../kubernetes/kubernetes.module';
import { ClustersModule } from '../clusters/clusters.module';
@@ -9,7 +8,7 @@ import { ClustersModule } from '../clusters/clusters.module';
forwardRef(() => KubernetesModule),
ClustersModule,
],
providers: [BuildService, ScanService],
exports: [BuildService, ScanService],
providers: [BuildService],
exports: [BuildService],
})
export class BuildModule {}
+290 -80
View File
@@ -1,16 +1,298 @@
import { AppRuntime } from '../common/enums';
/**
* Tests for build service:
* • Nixpacks build preparation (BYO Dockerfile vs generated) for code runtimes
* • WordPress templated Dockerfile + helper-pod / entrypoint / zip-structure logic
*
* NOTE: like the rest of this file, the Nixpacks tests reproduce the pure logic
* locally instead of importing BuildService — the service pulls in the ESM
* `@kubernetes/client-node`, which this project's Jest config does not transform.
* Keep these copies in sync with nixpacksPrepareInitContainer in build.service.ts.
* Tests for build service — Dockerfile generation for all runtimes
*/
// ─────────────────────────────────────────────────────────────────────────────
// Go Dockerfile tests
// ─────────────────────────────────────────────────────────────────────────────
describe('Go Dockerfile generation', () => {
function goDockerfile(app: { runtimeVersion?: string; port?: number }): string {
const goVersion = app.runtimeVersion || '1.22';
const port = app.port || 8080;
return `FROM golang:${goVersion}-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git
COPY go.mod go.sum* ./
RUN go mod download || true
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main .
FROM alpine:3.19
WORKDIR /app
RUN apk --no-cache add ca-certificates tzdata
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup
COPY --from=builder /app/main .
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
USER appuser
ENV PORT=${port}
EXPOSE ${port}
HEALTHCHECK --interval=30s --timeout=3s CMD wget --no-verbose --tries=1 --spider http://localhost:${port}/health || exit 1
CMD ["./main"]
`;
}
it('should use correct Go version', () => {
const df = goDockerfile({ runtimeVersion: '1.21' });
expect(df).toContain('FROM golang:1.21-alpine');
});
it('should default to Go 1.22', () => {
const df = goDockerfile({});
expect(df).toContain('FROM golang:1.22-alpine');
});
it('should build static binary with CGO_ENABLED=0', () => {
const df = goDockerfile({});
expect(df).toContain('CGO_ENABLED=0');
});
it('should use multi-stage build for smaller image', () => {
const df = goDockerfile({});
expect(df).toContain('AS builder');
expect(df).toContain('FROM alpine:3.19');
});
it('should include health check', () => {
const df = goDockerfile({ port: 8080 });
expect(df).toContain('HEALTHCHECK');
expect(df).toContain('http://localhost:8080/health');
});
it('should create data directory for persistent storage', () => {
const df = goDockerfile({});
expect(df).toContain('mkdir -p /app/data');
});
it('should run as non-root user', () => {
const df = goDockerfile({});
expect(df).toContain('USER appuser');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Python Dockerfile tests
// ─────────────────────────────────────────────────────────────────────────────
describe('Python Dockerfile generation', () => {
function pythonDockerfile(app: { runtimeVersion?: string; port?: number }): string {
const pythonVersion = app.runtimeVersion || '3.12';
const port = app.port || 8000;
return `FROM python:${pythonVersion}-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y build-essential libpq-dev
COPY requirements.txt* ./
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || pip install --no-cache-dir --user flask gunicorn
FROM python:${pythonVersion}-slim
WORKDIR /app
RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser
COPY --from=builder /root/.local /home/appuser/.local
COPY . .
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
USER appuser
ENV PATH=/home/appuser/.local/bin:$PATH
ENV PORT=${port}
EXPOSE ${port}
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health || exit 1
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:${port}", "app:app"]
`;
}
it('should use correct Python version', () => {
const df = pythonDockerfile({ runtimeVersion: '3.11' });
expect(df).toContain('FROM python:3.11-slim');
});
it('should default to Python 3.12', () => {
const df = pythonDockerfile({});
expect(df).toContain('FROM python:3.12-slim');
});
it('should use multi-stage build', () => {
const df = pythonDockerfile({});
expect(df).toContain('AS builder');
});
it('should install from requirements.txt', () => {
const df = pythonDockerfile({});
expect(df).toContain('requirements.txt');
});
it('should include health check', () => {
const df = pythonDockerfile({ port: 8000 });
expect(df).toContain('HEALTHCHECK');
expect(df).toContain('http://localhost:8000/health');
});
it('should run as non-root user', () => {
const df = pythonDockerfile({});
expect(df).toContain('USER appuser');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Django Dockerfile tests
// ─────────────────────────────────────────────────────────────────────────────
describe('Django Dockerfile generation', () => {
function djangoDockerfile(app: { runtimeVersion?: string; port?: number }): string {
const pythonVersion = app.runtimeVersion || '3.12';
const port = app.port || 8000;
return `FROM python:${pythonVersion}-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y build-essential libpq-dev
COPY requirements.txt* ./
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || pip install --no-cache-dir --user django gunicorn
FROM python:${pythonVersion}-slim
WORKDIR /app
COPY --from=builder /root/.local /home/appuser/.local
COPY . .
RUN mkdir -p /app/staticfiles /app/media /app/data
USER appuser
ENV PORT=${port}
ENV DJANGO_SETTINGS_MODULE=config.settings
EXPOSE ${port}
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health/ || exit 1
CMD ["sh", "-c", "python manage.py migrate --noinput && gunicorn config.wsgi:application --bind 0.0.0.0:${port}"]
`;
}
it('should use correct Python version', () => {
const df = djangoDockerfile({ runtimeVersion: '3.10' });
expect(df).toContain('FROM python:3.10-slim');
});
it('should set DJANGO_SETTINGS_MODULE', () => {
const df = djangoDockerfile({});
expect(df).toContain('DJANGO_SETTINGS_MODULE');
});
it('should create staticfiles and media directories', () => {
const df = djangoDockerfile({});
expect(df).toContain('/app/staticfiles');
expect(df).toContain('/app/media');
});
it('should run migrations on startup', () => {
const df = djangoDockerfile({});
expect(df).toContain('migrate');
});
it('should use gunicorn for production', () => {
const df = djangoDockerfile({});
expect(df).toContain('gunicorn');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// .NET Dockerfile tests
// ─────────────────────────────────────────────────────────────────────────────
describe('.NET Dockerfile generation', () => {
function dotnetDockerfile(app: { runtimeVersion?: string; port?: number }): string {
const dotnetVersion = app.runtimeVersion || '8.0';
const port = app.port || 5000;
return `FROM mcr.microsoft.com/dotnet/sdk:${dotnetVersion} AS build
WORKDIR /src
COPY *.csproj ./
RUN dotnet restore || true
COPY . .
RUN dotnet publish -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:${dotnetVersion}
WORKDIR /app
COPY --from=build /app/publish .
RUN mkdir -p /app/data
USER appuser
ENV ASPNETCORE_URLS=http://+:${port}
ENV ASPNETCORE_ENVIRONMENT=Production
EXPOSE ${port}
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health || exit 1
CMD ["dotnet", "app.dll"]
`;
}
it('should use correct .NET version', () => {
const df = dotnetDockerfile({ runtimeVersion: '7.0' });
expect(df).toContain('dotnet/sdk:7.0');
expect(df).toContain('dotnet/aspnet:7.0');
});
it('should default to .NET 8.0', () => {
const df = dotnetDockerfile({});
expect(df).toContain('dotnet/sdk:8.0');
});
it('should use multi-stage build', () => {
const df = dotnetDockerfile({});
expect(df).toContain('AS build');
expect(df).toContain('dotnet/aspnet');
});
it('should publish in Release mode', () => {
const df = dotnetDockerfile({});
expect(df).toContain('-c Release');
});
it('should set ASPNETCORE_ENVIRONMENT to Production', () => {
const df = dotnetDockerfile({});
expect(df).toContain('ASPNETCORE_ENVIRONMENT=Production');
});
it('should configure ASPNETCORE_URLS for correct port', () => {
const df = dotnetDockerfile({ port: 8080 });
expect(df).toContain('ASPNETCORE_URLS=http://+:8080');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// PHP Dockerfile tests
// ─────────────────────────────────────────────────────────────────────────────
describe('PHP Dockerfile generation', () => {
function phpDockerfile(app: { phpVersion?: string; port?: number }): string {
const phpVersion = app.phpVersion || '8.3';
const port = app.port || 80;
return `FROM php:${phpVersion}-fpm-alpine
RUN apk add --no-cache nginx supervisor curl
RUN docker-php-ext-install pdo pdo_mysql opcache
WORKDIR /var/www/html
COPY . .
RUN mkdir -p /var/www/html/uploads /var/www/html/data
RUN chown -R www-data:www-data /var/www/html
EXPOSE ${port}
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
`;
}
it('should use correct PHP version', () => {
const df = phpDockerfile({ phpVersion: '8.2' });
expect(df).toContain('FROM php:8.2-fpm-alpine');
});
it('should default to PHP 8.3', () => {
const df = phpDockerfile({});
expect(df).toContain('FROM php:8.3-fpm-alpine');
});
it('should use FPM with nginx via supervisord', () => {
const df = phpDockerfile({});
expect(df).toContain('supervisord');
expect(df).toContain('nginx');
});
it('should install common PHP extensions', () => {
const df = phpDockerfile({});
expect(df).toContain('pdo');
expect(df).toContain('opcache');
});
it('should create upload and data directories', () => {
const df = phpDockerfile({});
expect(df).toContain('/var/www/html/uploads');
expect(df).toContain('/var/www/html/data');
});
});
/**
* Tests for the WordPress build flow — specifically:
* 1. Helper pod PVC race condition (must wait for termination)
@@ -185,75 +467,3 @@ describe('WordPress zip structure handling', () => {
expect(copiedAsIs).toBe(true);
});
});
describe('Nixpacks build preparation', () => {
// Local copies of the pure logic in build.service.ts (see NOTE at top of file).
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function nixpacksPlanEnv(app: { runtime: AppRuntime; runtimeVersion?: string }): { name: string; value: string }[] {
const env: { name: string; value: string }[] = [];
if (app.runtime === AppRuntime.NODEJS && app.runtimeVersion) {
env.push({ name: 'NIXPACKS_NODE_VERSION', value: String(app.runtimeVersion) });
}
if ((app.runtime === AppRuntime.PYTHON || app.runtime === AppRuntime.DJANGO) && app.runtimeVersion) {
env.push({ name: 'NIXPACKS_PYTHON_VERSION', value: String(app.runtimeVersion) });
}
return env;
}
function nixpacksPrepareInitContainer(
app: { runtime: AppRuntime; runtimeVersion?: string },
config: { nixpacksImage?: string; nixpacksBuildEnv?: string[] } = {},
): any {
const image = config.nixpacksImage || 'ghcr.io/railwayapp/nixpacks:latest';
const buildEnv = config.nixpacksBuildEnv || [];
const envFlags = buildEnv.map((kv) => `--env ${shellQuote(kv)}`).join(' ');
const planEnv = nixpacksPlanEnv(app);
return {
name: 'nixpacks-prepare',
image,
env: planEnv.length ? planEnv : undefined,
command: [
'sh',
'-c',
`if [ -f source/Dockerfile ]; then cp source/Dockerfile /workspace/Dockerfile; ` +
`else nixpacks build source --out source ${envFlags} && cp source/.nixpacks/Dockerfile /workspace/Dockerfile; fi`,
],
volumeMounts: [{ name: 'workspace', mountPath: '/workspace' }],
};
}
it('prefers a user-provided Dockerfile (BYO), falling back to Nixpacks', () => {
const script = nixpacksPrepareInitContainer({ runtime: AppRuntime.NODEJS }).command[2] as string;
expect(script).toContain('if [ -f source/Dockerfile ]');
expect(script).toContain('cp source/Dockerfile /workspace/Dockerfile');
expect(script).toContain('nixpacks build source --out source');
expect(script).toContain('cp source/.nixpacks/Dockerfile /workspace/Dockerfile');
});
it('uses the configured Nixpacks image (default when unset)', () => {
expect(nixpacksPrepareInitContainer({ runtime: AppRuntime.GO }).image).toBe('ghcr.io/railwayapp/nixpacks:latest');
expect(
nixpacksPrepareInitContainer({ runtime: AppRuntime.GO }, { nixpacksImage: 'registry.local/nixpacks:1.2.3' }).image,
).toBe('registry.local/nixpacks:1.2.3');
});
it('bakes build-time mirror env into the build via --env flags', () => {
const script = nixpacksPrepareInitContainer(
{ runtime: AppRuntime.NODEJS },
{ nixpacksBuildEnv: ['NPM_CONFIG_REGISTRY=https://registry.npmmirror.com'] },
).command[2] as string;
expect(script).toContain(`--env 'NPM_CONFIG_REGISTRY=https://registry.npmmirror.com'`);
});
it('maps the selected Node version to NIXPACKS_NODE_VERSION', () => {
const c = nixpacksPrepareInitContainer({ runtime: AppRuntime.NODEJS, runtimeVersion: '20' });
expect(c.env).toContainEqual({ name: 'NIXPACKS_NODE_VERSION', value: '20' });
});
it('shellQuote escapes embedded single quotes safely', () => {
expect(shellQuote("a'b")).toBe("'a'\\''b'");
});
});
File diff suppressed because it is too large Load Diff
-94
View File
@@ -1,94 +0,0 @@
/**
* Tests for the pure Trivy-report aggregation logic in ScanService.summarize.
*
* NOTE: like the other build specs, this reproduces the pure logic locally rather
* than importing ScanService — the service pulls in the ESM `@kubernetes/client-node`,
* which this project's Jest config does not transform. Keep in sync with scan.service.ts.
*/
interface VulnerabilitySummary {
critical: number;
high: number;
medium: number;
low: number;
unknown: number;
total: number;
scannedAt: string;
}
function parseTrivyJson(output: string): any | null {
if (!output) return null;
try {
return JSON.parse(output);
} catch {
const start = output.indexOf('{');
const end = output.lastIndexOf('}');
if (start >= 0 && end > start) {
try {
return JSON.parse(output.slice(start, end + 1));
} catch {
return null;
}
}
return null;
}
}
function summarize(trivyOutput: string): VulnerabilitySummary {
const counts = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
const parsed = parseTrivyJson(trivyOutput);
const results: any[] = Array.isArray(parsed?.Results) ? parsed.Results : [];
for (const result of results) {
const vulns: any[] = Array.isArray(result?.Vulnerabilities) ? result.Vulnerabilities : [];
for (const v of vulns) {
const sev = String(v?.Severity || 'UNKNOWN').toUpperCase();
if (sev === 'CRITICAL') counts.critical++;
else if (sev === 'HIGH') counts.high++;
else if (sev === 'MEDIUM') counts.medium++;
else if (sev === 'LOW') counts.low++;
else counts.unknown++;
}
}
return {
...counts,
total: counts.critical + counts.high + counts.medium + counts.low + counts.unknown,
scannedAt: new Date().toISOString(),
};
}
describe('ScanService.summarize', () => {
it('counts vulnerabilities per severity across results', () => {
const report = JSON.stringify({
Results: [
{ Vulnerabilities: [{ Severity: 'CRITICAL' }, { Severity: 'HIGH' }, { Severity: 'high' }] },
{ Vulnerabilities: [{ Severity: 'MEDIUM' }, { Severity: 'LOW' }, { Severity: 'WeIrD' }] },
{ Vulnerabilities: null },
{},
],
});
const s = summarize(report);
expect(s).toMatchObject({ critical: 1, high: 2, medium: 1, low: 1, unknown: 1, total: 6 });
expect(typeof s.scannedAt).toBe('string');
});
it('returns all-zero summary for a clean image', () => {
expect(summarize(JSON.stringify({ Results: [{ Target: 'x' }] }))).toMatchObject({
critical: 0,
high: 0,
medium: 0,
low: 0,
unknown: 0,
total: 0,
});
});
it('tolerates leading log noise before the JSON', () => {
const noisy = `2026-06-20 INFO Need to update DB\n{"Results":[{"Vulnerabilities":[{"Severity":"CRITICAL"}]}]}`;
expect(summarize(noisy).critical).toBe(1);
});
it('returns a zero summary on unparseable output', () => {
expect(summarize('not json at all').total).toBe(0);
expect(summarize('').total).toBe(0);
});
});
-169
View File
@@ -1,169 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as k8s from '@kubernetes/client-node';
import { Application } from '../applications/entities/application.entity';
import { ClustersService } from '../clusters/clusters.service';
import { RegistryService } from '../kubernetes/registry.service';
export interface VulnerabilitySummary {
critical: number;
high: number;
medium: number;
low: number;
unknown: number;
total: number;
scannedAt: string;
}
/**
* Report-only image vulnerability scanning with Trivy. Runs a one-shot K8s Job
* that scans the freshly-pushed image in the in-cluster registry and stores a
* severity summary on the deployment. Never blocks a deployment — any failure is
* logged and ignored.
*/
@Injectable()
export class ScanService {
private readonly logger = new Logger(ScanService.name);
constructor(
private readonly configService: ConfigService,
private readonly clustersService: ClustersService,
private readonly registryService: RegistryService,
) {}
/** Aggregate a Trivy JSON report (raw stdout) into per-severity counts. Pure & testable. */
summarize(trivyOutput: string): VulnerabilitySummary {
const counts = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
const parsed = this.parseTrivyJson(trivyOutput);
const results: any[] = Array.isArray(parsed?.Results) ? parsed.Results : [];
for (const result of results) {
const vulns: any[] = Array.isArray(result?.Vulnerabilities) ? result.Vulnerabilities : [];
for (const v of vulns) {
const sev = String(v?.Severity || 'UNKNOWN').toUpperCase();
if (sev === 'CRITICAL') counts.critical++;
else if (sev === 'HIGH') counts.high++;
else if (sev === 'MEDIUM') counts.medium++;
else if (sev === 'LOW') counts.low++;
else counts.unknown++;
}
}
return {
...counts,
total: counts.critical + counts.high + counts.medium + counts.low + counts.unknown,
scannedAt: new Date().toISOString(),
};
}
/** Trivy prints clean JSON to stdout, but tolerate any leading noise from the log stream. */
private parseTrivyJson(output: string): any | null {
if (!output) return null;
try {
return JSON.parse(output);
} catch {
const start = output.indexOf('{');
const end = output.lastIndexOf('}');
if (start >= 0 && end > start) {
try {
return JSON.parse(output.slice(start, end + 1));
} catch {
return null;
}
}
return null;
}
}
/**
* Scan a pushed image and return a severity summary, or null on any failure.
* Report-only: callers must treat null as "no data", never as a deploy gate.
*/
async scanImage(app: Application, imageUri: string): Promise<VulnerabilitySummary | null> {
if (this.configService.get<boolean>('build.scanEnabled') === false) return null;
const buildNs = this.registryService.getBuildNamespace();
const image = this.configService.get<string>('build.trivyImage') || 'aquasec/trivy:latest';
const dbRepo = this.configService.get<string>('build.trivyDbRepository') || '';
const timeoutSeconds = this.configService.get<number>('build.scanTimeoutSeconds') || 300;
const { username, password } = this.registryService.getRegistryCredentials();
const jobName = `scan-${app.name}-${Date.now()}`.substring(0, 63).replace(/[^a-z0-9-]/g, '');
try {
const cluster = app.clusterId ? await this.clustersService.findOne(app.clusterId) : await this.clustersService.getDefault();
const kc = new k8s.KubeConfig();
kc.loadFromString(cluster.kubeconfig);
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const env: { name: string; value: string }[] = [
{ name: 'TRIVY_INSECURE', value: 'true' }, // in-cluster registry is plain HTTP
{ name: 'TRIVY_NON_SSL', value: 'true' },
];
if (username) env.push({ name: 'TRIVY_USERNAME', value: username });
if (password) env.push({ name: 'TRIVY_PASSWORD', value: password });
if (dbRepo) env.push({ name: 'TRIVY_DB_REPOSITORY', value: dbRepo });
const job: k8s.V1Job = {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: { name: jobName, namespace: buildNs },
spec: {
backoffLimit: 0,
ttlSecondsAfterFinished: 120,
template: {
spec: {
restartPolicy: 'Never',
containers: [
{
name: 'trivy',
image,
imagePullPolicy: 'IfNotPresent',
env,
args: ['image', '--quiet', '--no-progress', '--format', 'json', '--severity', 'CRITICAL,HIGH,MEDIUM,LOW', imageUri],
resources: {
requests: { cpu: '250m', memory: '512Mi' },
limits: { cpu: '1', memory: '1Gi' },
},
},
],
},
},
},
};
await batchApi.createNamespacedJob({ namespace: buildNs, body: job });
await this.waitForJob(batchApi, jobName, buildNs, timeoutSeconds);
const output = await this.getJobPodLogs(coreApi, jobName, buildNs);
const summary = this.summarize(output);
await batchApi
.deleteNamespacedJob({ name: jobName, namespace: buildNs, gracePeriodSeconds: 0, propagationPolicy: 'Foreground' })
.catch(() => undefined);
this.logger.log(`Scan complete for ${imageUri}: ${summary.critical}C/${summary.high}H/${summary.medium}M/${summary.low}L`);
return summary;
} catch (e: any) {
this.logger.warn(`Image scan failed for ${imageUri} (report-only, ignored): ${e.message}`);
return null;
}
}
private async waitForJob(batchApi: k8s.BatchV1Api, jobName: string, namespace: string, timeoutSeconds: number): Promise<void> {
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
const job = await batchApi.readNamespacedJob({ name: jobName, namespace });
if (job.status?.succeeded) return;
if ((job.status?.failed ?? 0) > 0) return; // Trivy exits non-zero on findings with some flags; read logs anyway
await new Promise((r) => setTimeout(r, 4000));
}
throw new Error(`Scan job ${jobName} timed out after ${timeoutSeconds}s`);
}
private async getJobPodLogs(coreApi: k8s.CoreV1Api, jobName: string, namespace: string): Promise<string> {
const pods = await coreApi.listNamespacedPod({ namespace, labelSelector: `job-name=${jobName}` });
const podName = pods.items[0]?.metadata?.name;
if (!podName) throw new Error(`No pod found for scan job ${jobName}`);
return coreApi.readNamespacedPodLog({ name: podName, namespace, container: 'trivy' });
}
}