revert(build): remove app build pipeline revamp (Nixpacks/MinIO/Trivy/registry GC)
Reverts commits3eff38fandc379a23and 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:
@@ -3,7 +3,6 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { KubernetesService } from './kubernetes.service';
|
||||
import { HelmService } from './helm.service';
|
||||
import { RegistryService } from './registry.service';
|
||||
import { RegistryGcService } from './registry-gc.service';
|
||||
import { ElasticsearchService } from './elasticsearch.service';
|
||||
import { ElasticsearchController } from './elasticsearch.controller';
|
||||
import { LogsController } from './logs.controller';
|
||||
@@ -14,7 +13,7 @@ import { Deployment } from '../deployments/entities/deployment.entity';
|
||||
@Module({
|
||||
imports: [forwardRef(() => ClustersModule), TypeOrmModule.forFeature([Application, Deployment])],
|
||||
controllers: [ElasticsearchController, LogsController],
|
||||
providers: [KubernetesService, HelmService, RegistryService, RegistryGcService, ElasticsearchService],
|
||||
providers: [KubernetesService, HelmService, RegistryService, ElasticsearchService],
|
||||
exports: [KubernetesService, HelmService, RegistryService, ElasticsearchService],
|
||||
})
|
||||
export class KubernetesModule {}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { selectTagsToDelete } from './registry-gc.util';
|
||||
|
||||
describe('selectTagsToDelete', () => {
|
||||
it('keeps the N newest numeric (Date.now) tags and deletes the rest', () => {
|
||||
const tags = ['1000', '3000', '2000', '5000', '4000'];
|
||||
const toDelete = selectTagsToDelete(tags, 3);
|
||||
// newest 3 = 5000,4000,3000 → delete 2000,1000
|
||||
expect(toDelete.sort()).toEqual(['1000', '2000']);
|
||||
});
|
||||
|
||||
it('deletes nothing when tag count is within the keep limit', () => {
|
||||
expect(selectTagsToDelete(['1000', '2000'], 3)).toEqual([]);
|
||||
expect(selectTagsToDelete([], 3)).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats non-numeric tags as oldest (eligible for deletion first)', () => {
|
||||
const tags = ['latest', '2000', '1000'];
|
||||
// numeric newest kept first: 2000,1000 kept (keep=2) → delete latest
|
||||
expect(selectTagsToDelete(tags, 2)).toEqual(['latest']);
|
||||
});
|
||||
|
||||
it('keep=0 deletes every tag', () => {
|
||||
expect(selectTagsToDelete(['1000', '2000'], 0).sort()).toEqual(['1000', '2000']);
|
||||
});
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Writable } from 'stream';
|
||||
import * as k8s from '@kubernetes/client-node';
|
||||
import { Redis } from 'ioredis';
|
||||
import { REDIS_CLIENT } from '../common/redis/redis.module';
|
||||
import { ClustersService } from '../clusters/clusters.service';
|
||||
import { RegistryService } from './registry.service';
|
||||
import { selectTagsToDelete } from './registry-gc.util';
|
||||
|
||||
/**
|
||||
* Periodic registry garbage collection: prunes each app image repo to the N most
|
||||
* recent tags (deleting older manifests) and then reclaims disk by running
|
||||
* `registry garbage-collect` in the registry pod. Runs on the local/default
|
||||
* cluster's in-cluster registry (the one reachable via cluster DNS). A Redis lock
|
||||
* keeps a single replica running it at a time (see [[multi-instance-interval-jobs]]).
|
||||
*/
|
||||
@Injectable()
|
||||
export class RegistryGcService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(RegistryGcService.name);
|
||||
private timer?: NodeJS.Timeout;
|
||||
private static readonly LOCK_KEY = 'registry:gc:lock';
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly clustersService: ClustersService,
|
||||
private readonly registryService: RegistryService,
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (this.configService.get<boolean>('build.registryGcEnabled') === false) return;
|
||||
const interval = this.configService.get<number>('build.registryGcIntervalMs') || 86_400_000;
|
||||
this.timer = setInterval(() => void this.runGc(), interval);
|
||||
// First pass shortly after boot.
|
||||
setTimeout(() => void this.runGc(), 60_000);
|
||||
this.logger.log(`Registry GC scheduled — interval: ${Math.round(interval / 3600000)}h`);
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
}
|
||||
|
||||
/** Run one GC pass guarded by a Redis lock so only one replica executes it. */
|
||||
async runGc(): Promise<void> {
|
||||
const keep = this.configService.get<number>('build.registryKeepVersions') || 3;
|
||||
const locked = await this.redis.set(RegistryGcService.LOCK_KEY, '1', 'EX', 900, 'NX').catch(() => null);
|
||||
if (locked !== 'OK') {
|
||||
this.logger.debug('Registry GC already running on another replica — skipping');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.pruneDefaultClusterRegistry(keep);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Registry GC failed: ${e.message}`);
|
||||
} finally {
|
||||
await this.redis.del(RegistryGcService.LOCK_KEY).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private authHeader(): string {
|
||||
const { username, password } = this.registryService.getRegistryCredentials();
|
||||
return 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
|
||||
}
|
||||
|
||||
private async registryFetch(pathOrUrl: string, init: RequestInit = {}): Promise<Response> {
|
||||
const base = `http://${this.registryService.getRegistryUrl()}`;
|
||||
return fetch(`${base}${pathOrUrl}`, {
|
||||
...init,
|
||||
headers: { Authorization: this.authHeader(), ...(init.headers || {}) },
|
||||
});
|
||||
}
|
||||
|
||||
private async pruneDefaultClusterRegistry(keep: number): Promise<void> {
|
||||
// 1. List repositories
|
||||
const catalogRes = await this.registryFetch('/v2/_catalog?n=10000');
|
||||
if (!catalogRes.ok) throw new Error(`catalog ${catalogRes.status}`);
|
||||
const repositories: string[] = ((await catalogRes.json()) as any)?.repositories || [];
|
||||
|
||||
let deletedTotal = 0;
|
||||
for (const repo of repositories) {
|
||||
// Skip Kaniko cache repos — pruning them just slows the next build.
|
||||
if (repo.endsWith('/cache')) continue;
|
||||
deletedTotal += await this.pruneRepo(repo, keep);
|
||||
}
|
||||
|
||||
if (deletedTotal > 0) {
|
||||
this.logger.log(`Registry GC: deleted ${deletedTotal} old manifest(s); reclaiming disk…`);
|
||||
await this.runGarbageCollect();
|
||||
} else {
|
||||
this.logger.debug('Registry GC: nothing to prune');
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete all but the newest `keep` tags of one repo. Returns count deleted. */
|
||||
private async pruneRepo(repo: string, keep: number): Promise<number> {
|
||||
const tagsRes = await this.registryFetch(`/v2/${repo}/tags/list`);
|
||||
if (!tagsRes.ok) return 0;
|
||||
const tags: string[] = ((await tagsRes.json()) as any)?.tags || [];
|
||||
const toDelete = selectTagsToDelete(tags, keep);
|
||||
if (toDelete.length === 0) return 0;
|
||||
|
||||
const seenDigests = new Set<string>();
|
||||
let deleted = 0;
|
||||
for (const tag of toDelete) {
|
||||
try {
|
||||
const head = await this.registryFetch(`/v2/${repo}/manifests/${tag}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json',
|
||||
},
|
||||
});
|
||||
const digest = head.headers.get('docker-content-digest');
|
||||
if (!digest || seenDigests.has(digest)) continue; // multiple tags can share a digest
|
||||
seenDigests.add(digest);
|
||||
const del = await this.registryFetch(`/v2/${repo}/manifests/${digest}`, { method: 'DELETE' });
|
||||
if (del.ok || del.status === 202) deleted++;
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Failed to delete ${repo}:${tag}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
if (deleted > 0) this.logger.log(`Registry GC: pruned ${deleted} tag(s) from ${repo} (kept ${keep})`);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/** Reclaim disk by running `registry garbage-collect` inside the registry pod. Best-effort. */
|
||||
private async runGarbageCollect(): Promise<void> {
|
||||
try {
|
||||
const buildNs = this.registryService.getBuildNamespace();
|
||||
const cluster = await this.clustersService.getDefault();
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromString(cluster.kubeconfig);
|
||||
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
|
||||
|
||||
const pods = await coreApi.listNamespacedPod({ namespace: buildNs, labelSelector: 'app=registry' });
|
||||
const podName = pods.items[0]?.metadata?.name;
|
||||
if (!podName) {
|
||||
this.logger.warn('Registry GC: no registry pod found for garbage-collect');
|
||||
return;
|
||||
}
|
||||
|
||||
const exec = new k8s.Exec(kc);
|
||||
const sink = new Writable({ write: (_c, _e, cb) => cb() });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
exec
|
||||
.exec(
|
||||
buildNs,
|
||||
podName,
|
||||
'registry',
|
||||
['/bin/registry', 'garbage-collect', '/etc/docker/registry/config.yml'],
|
||||
sink,
|
||||
sink,
|
||||
null,
|
||||
false,
|
||||
(status) => (status.status === 'Failure' ? reject(new Error(status.message)) : resolve()),
|
||||
)
|
||||
.catch(reject);
|
||||
});
|
||||
this.logger.log('Registry GC: garbage-collect completed');
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Registry garbage-collect failed (manifests already deleted): ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* Decide which image tags to delete, keeping the `keep` most recent. Tags are
|
||||
* `Date.now()` strings, so newest = highest numeric value; non-numeric tags sort
|
||||
* last (treated as oldest) and are eligible for deletion once `keep` is met.
|
||||
*
|
||||
* Kept in its own (dependency-free) module so it can be unit-tested without
|
||||
* pulling in the ESM `@kubernetes/client-node` that the GC service imports.
|
||||
*/
|
||||
export function selectTagsToDelete(tags: string[], keep: number): string[] {
|
||||
const sorted = [...tags].sort((a, b) => {
|
||||
const na = Number(a);
|
||||
const nb = Number(b);
|
||||
if (Number.isNaN(na) && Number.isNaN(nb)) return a < b ? 1 : -1;
|
||||
if (Number.isNaN(na)) return 1;
|
||||
if (Number.isNaN(nb)) return -1;
|
||||
return nb - na; // newest first
|
||||
});
|
||||
return sorted.slice(Math.max(0, keep));
|
||||
}
|
||||
Reference in New Issue
Block a user