chore(deps): upgrade all dependencies to latest stable
Bring backend and frontend to the latest stable releases (no pre-releases), including major upgrades that required code migration. Both projects pass typecheck and production builds. Backend - NestJS 10 -> 11 (common/core/platform-express/jwt/passport/bull/cli/ schematics/testing), @nestjs/config 3->4, @nestjs/swagger 7->11, @nestjs/typeorm 10->11 - @kubernetes/client-node 0.21 -> 1.4: migrate ~200+ call sites across 6 services to the v1 single-object argument API, unwrapped responses, err.code, setHeaderOptions for patch content-type, applyToHTTPSOptions. Add regression spec k8s-client-v1-migration.spec.ts. - typeorm 0.3 -> 1.0: relations/select string arrays -> object form - uuid 9->14 (drops @types/uuid), multer 1->2, bcrypt 5->6, helmet 7->8, class-validator 0.14->0.15 - TypeScript 5->6, ESLint 8->9, @typescript-eslint 6->8, jest 29->30, @types/node 20->24; tsconfig: strictPropertyInitialization:false, ignoreDeprecations, rootDir, explicit types[] - @nestjs/config 4: jwt.strategy uses getOrThrow; @types/express kept at 4 (Nest 11 runs Express 4) Frontend - React 18->19, Next 14->16 (async params via official codemod), Tailwind 3->4 (@tailwindcss/postcss, @import + @config, inline custom @apply), framer-motion 11->12, zustand 4->5, three 0.169->0.184, @react-three/* majors - TypeScript 5->6 (tsconfig target es5->ES2017), ESLint 8->9, eslint-config-next 14->16 Infra/docs - Dockerfiles node:20-alpine -> node:24-alpine (require-esm for k8s client) - Add UPGRADE.md / UPGRADE.en.md; refresh README tech-stack versions Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+322
-250
@@ -49,8 +49,7 @@ export class BuildService {
|
||||
* Kaniko executor image. Pinned (not `:latest`) so it can be cached on the node
|
||||
* with imagePullPolicy=IfNotPresent — avoids re-pulling the ~250MB image on every build.
|
||||
*/
|
||||
private readonly kanikoImage =
|
||||
process.env.KANIKO_IMAGE || 'gcr.io/kaniko-project/executor:v1.23.2';
|
||||
private readonly kanikoImage = process.env.KANIKO_IMAGE || 'gcr.io/kaniko-project/executor:v1.23.2';
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
@@ -77,7 +76,11 @@ export class BuildService {
|
||||
if (!session) return;
|
||||
session.processes.push(proc);
|
||||
if (session.cancelled) {
|
||||
try { proc.kill('SIGKILL'); } catch { /* ignore */ }
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,11 +88,19 @@ export class BuildService {
|
||||
const session = this.getSession(deploymentId);
|
||||
if (!session) return;
|
||||
if (session.socket) {
|
||||
try { session.socket.destroy(); } catch { /* ignore */ }
|
||||
try {
|
||||
session.socket.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
session.socket = socket;
|
||||
if (session.cancelled) {
|
||||
try { socket.destroy(); } catch { /* ignore */ }
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +117,11 @@ export class BuildService {
|
||||
async cancelBuild(deploymentId: string): Promise<void> {
|
||||
const session = this.activeBuilds.get(deploymentId);
|
||||
if (!session) {
|
||||
this.setProgress(deploymentId, { phase: 'cancelled', percent: 0, message: 'Cancelled by user' });
|
||||
this.setProgress(deploymentId, {
|
||||
phase: 'cancelled',
|
||||
percent: 0,
|
||||
message: 'Cancelled by user',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,10 +129,18 @@ export class BuildService {
|
||||
this.logger.log(`Cancelling build for deployment ${deploymentId}`);
|
||||
|
||||
if (session.socket) {
|
||||
try { session.socket.destroy(); } catch { /* ignore */ }
|
||||
try {
|
||||
session.socket.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
for (const proc of session.processes) {
|
||||
try { proc.kill('SIGKILL'); } catch { /* ignore */ }
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const { coreApi, batchApi, namespace, buildPodName, sourcePvcName, helperPodName } = session;
|
||||
@@ -125,29 +148,56 @@ export class BuildService {
|
||||
const cleanup: Promise<unknown>[] = [];
|
||||
if (helperPodName) {
|
||||
cleanup.push(
|
||||
coreApi.deleteNamespacedPod(helperPodName, namespace, undefined, undefined, 0).catch(() => undefined),
|
||||
coreApi
|
||||
.deleteNamespacedPod({
|
||||
name: helperPodName,
|
||||
namespace,
|
||||
gracePeriodSeconds: 0,
|
||||
})
|
||||
.catch(() => undefined),
|
||||
);
|
||||
}
|
||||
if (buildPodName && batchApi) {
|
||||
cleanup.push(
|
||||
batchApi.deleteNamespacedJob(buildPodName, namespace, undefined, undefined, 0, undefined, 'Foreground').catch(() => undefined),
|
||||
batchApi
|
||||
.deleteNamespacedJob({
|
||||
name: buildPodName,
|
||||
namespace,
|
||||
gracePeriodSeconds: 0,
|
||||
propagationPolicy: 'Foreground',
|
||||
})
|
||||
.catch(() => undefined),
|
||||
);
|
||||
}
|
||||
if (sourcePvcName) {
|
||||
cleanup.push(
|
||||
coreApi.deleteNamespacedPersistentVolumeClaim(sourcePvcName, namespace).catch(() => undefined),
|
||||
coreApi
|
||||
.deleteNamespacedPersistentVolumeClaim({
|
||||
name: sourcePvcName,
|
||||
namespace,
|
||||
})
|
||||
.catch(() => undefined),
|
||||
);
|
||||
}
|
||||
if (buildPodName) {
|
||||
cleanup.push(
|
||||
coreApi.deleteNamespacedConfigMap(`${buildPodName}-dockerfile`, namespace).catch(() => undefined),
|
||||
coreApi
|
||||
.deleteNamespacedConfigMap({
|
||||
name: `${buildPodName}-dockerfile`,
|
||||
namespace,
|
||||
})
|
||||
.catch(() => undefined),
|
||||
);
|
||||
}
|
||||
await Promise.all(cleanup);
|
||||
this.logger.log(`Cleaned up K8s build resources for deployment ${deploymentId}`);
|
||||
}
|
||||
|
||||
this.setProgress(deploymentId, { phase: 'cancelled', percent: 0, message: 'Cancelled by user' });
|
||||
this.setProgress(deploymentId, {
|
||||
phase: 'cancelled',
|
||||
percent: 0,
|
||||
message: 'Cancelled by user',
|
||||
});
|
||||
this.activeBuilds.delete(deploymentId);
|
||||
}
|
||||
|
||||
@@ -156,9 +206,7 @@ export class BuildService {
|
||||
const buildNamespace = this.configService.get<string>('build.namespace') || 'cloudhost-builds';
|
||||
const prefix = `build-${app.name}-`;
|
||||
|
||||
const cluster = app.clusterId
|
||||
? await this.clustersService.findOne(app.clusterId)
|
||||
: await this.clustersService.getDefault();
|
||||
const cluster = app.clusterId ? await this.clustersService.findOne(app.clusterId) : await this.clustersService.getDefault();
|
||||
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromString(cluster.kubeconfig);
|
||||
@@ -168,34 +216,60 @@ export class BuildService {
|
||||
const cleanup: Promise<unknown>[] = [];
|
||||
|
||||
const [pods, pvcs, jobs, configMaps] = await Promise.all([
|
||||
coreApi.listNamespacedPod(buildNamespace),
|
||||
coreApi.listNamespacedPersistentVolumeClaim(buildNamespace),
|
||||
batchApi.listNamespacedJob(buildNamespace),
|
||||
coreApi.listNamespacedConfigMap(buildNamespace),
|
||||
coreApi.listNamespacedPod({ namespace: buildNamespace }),
|
||||
coreApi.listNamespacedPersistentVolumeClaim({
|
||||
namespace: buildNamespace,
|
||||
}),
|
||||
batchApi.listNamespacedJob({ namespace: buildNamespace }),
|
||||
coreApi.listNamespacedConfigMap({ namespace: buildNamespace }),
|
||||
]);
|
||||
|
||||
for (const pod of pods.body.items) {
|
||||
for (const pod of pods.items) {
|
||||
const name = pod.metadata?.name || '';
|
||||
if (name.startsWith(prefix)) {
|
||||
cleanup.push(coreApi.deleteNamespacedPod(name, buildNamespace, undefined, undefined, 0).catch(() => undefined));
|
||||
cleanup.push(
|
||||
coreApi
|
||||
.deleteNamespacedPod({
|
||||
name,
|
||||
namespace: buildNamespace,
|
||||
gracePeriodSeconds: 0,
|
||||
})
|
||||
.catch(() => undefined),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const pvc of pvcs.body.items) {
|
||||
for (const pvc of pvcs.items) {
|
||||
const name = pvc.metadata?.name || '';
|
||||
if (name.startsWith(prefix)) {
|
||||
cleanup.push(coreApi.deleteNamespacedPersistentVolumeClaim(name, buildNamespace).catch(() => undefined));
|
||||
cleanup.push(
|
||||
coreApi
|
||||
.deleteNamespacedPersistentVolumeClaim({
|
||||
name,
|
||||
namespace: buildNamespace,
|
||||
})
|
||||
.catch(() => undefined),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const job of jobs.body.items) {
|
||||
for (const job of jobs.items) {
|
||||
const name = job.metadata?.name || '';
|
||||
if (name.startsWith(prefix)) {
|
||||
cleanup.push(batchApi.deleteNamespacedJob(name, buildNamespace, undefined, undefined, 0, undefined, 'Foreground').catch(() => undefined));
|
||||
cleanup.push(
|
||||
batchApi
|
||||
.deleteNamespacedJob({
|
||||
name,
|
||||
namespace: buildNamespace,
|
||||
gracePeriodSeconds: 0,
|
||||
propagationPolicy: 'Foreground',
|
||||
})
|
||||
.catch(() => undefined),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const cm of configMaps.body.items) {
|
||||
for (const cm of configMaps.items) {
|
||||
const name = cm.metadata?.name || '';
|
||||
if (name.startsWith(prefix)) {
|
||||
cleanup.push(coreApi.deleteNamespacedConfigMap(name, buildNamespace).catch(() => undefined));
|
||||
cleanup.push(coreApi.deleteNamespacedConfigMap({ name, namespace: buildNamespace }).catch(() => undefined));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,9 +317,7 @@ export class BuildService {
|
||||
}
|
||||
|
||||
// Use the cluster's kubeconfig instead of default
|
||||
const cluster = app.clusterId
|
||||
? await this.clustersService.findOne(app.clusterId)
|
||||
: await this.clustersService.getDefault();
|
||||
const cluster = app.clusterId ? await this.clustersService.findOne(app.clusterId) : await this.clustersService.getDefault();
|
||||
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromString(cluster.kubeconfig);
|
||||
@@ -253,7 +325,11 @@ export class BuildService {
|
||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||
|
||||
if (deploymentId) {
|
||||
this.updateBuildSession(deploymentId, { coreApi, batchApi, namespace: buildNamespace });
|
||||
this.updateBuildSession(deploymentId, {
|
||||
coreApi,
|
||||
batchApi,
|
||||
namespace: buildNamespace,
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure the build namespace exists
|
||||
@@ -289,9 +365,7 @@ export class BuildService {
|
||||
// Allocate PVC size = zip size * 3 (zip + extracted), min 1Gi
|
||||
const pvcSizeGi = Math.max(1, Math.ceil((zipSize * 3) / (1024 * 1024 * 1024)));
|
||||
|
||||
await this.uploadSourceViaPVC(
|
||||
kc, coreApi, buildNamespace!, sourcePvcName, codePath!, pvcSizeGi, deploymentId,
|
||||
);
|
||||
await this.uploadSourceViaPVC(kc, coreApi, buildNamespace!, sourcePvcName, codePath!, pvcSizeGi, deploymentId);
|
||||
}
|
||||
|
||||
// Build the Kaniko Job spec
|
||||
@@ -339,7 +413,10 @@ export class BuildService {
|
||||
name: 'unzip-source',
|
||||
image: 'alpine:3.19',
|
||||
imagePullPolicy: 'IfNotPresent',
|
||||
command: ['sh', '-c', `
|
||||
command: [
|
||||
'sh',
|
||||
'-c',
|
||||
`
|
||||
apk add --no-cache unzip tar gzip &&
|
||||
cp /workspace/Dockerfile /workspace-out/Dockerfile &&
|
||||
mkdir -p /tmp/extract &&
|
||||
@@ -368,10 +445,15 @@ export class BuildService {
|
||||
rm -rf /tmp/extract &&
|
||||
echo "--- Final workspace contents ---" &&
|
||||
ls -la /workspace-out/source/
|
||||
`],
|
||||
`,
|
||||
],
|
||||
volumeMounts: [
|
||||
{ name: 'workspace', mountPath: '/workspace-out' },
|
||||
{ name: 'dockerfile', mountPath: '/workspace/Dockerfile', subPath: 'Dockerfile' },
|
||||
{
|
||||
name: 'dockerfile',
|
||||
mountPath: '/workspace/Dockerfile',
|
||||
subPath: 'Dockerfile',
|
||||
},
|
||||
{ name: 'source-pvc', mountPath: '/source-pvc' },
|
||||
],
|
||||
});
|
||||
@@ -398,13 +480,17 @@ export class BuildService {
|
||||
name: 'git-clone',
|
||||
image: 'alpine/git:2.43.0',
|
||||
imagePullPolicy: 'IfNotPresent',
|
||||
command: ['sh', '-c', `
|
||||
command: [
|
||||
'sh',
|
||||
'-c',
|
||||
`
|
||||
echo ">>> Cloning branch '${branch}' from ${app.gitUrl}" &&
|
||||
git clone --depth 1 --branch ${branch} ${cloneUrl} /workspace-out/source &&
|
||||
cp /dockerfile/Dockerfile /workspace-out/Dockerfile &&
|
||||
echo ">>> Workspace contents:" &&
|
||||
ls -la /workspace-out/source/
|
||||
`],
|
||||
`,
|
||||
],
|
||||
volumeMounts: [
|
||||
{ name: 'workspace', mountPath: '/workspace-out' },
|
||||
{ name: 'dockerfile', mountPath: '/dockerfile' },
|
||||
@@ -426,12 +512,16 @@ export class BuildService {
|
||||
name: 'prepare-workspace',
|
||||
image: 'alpine:3.19',
|
||||
imagePullPolicy: 'IfNotPresent',
|
||||
command: ['sh', '-c', `
|
||||
command: [
|
||||
'sh',
|
||||
'-c',
|
||||
`
|
||||
mkdir -p /workspace-out/source &&
|
||||
cp /dockerfile/Dockerfile /workspace-out/Dockerfile &&
|
||||
echo ">>> Prepared empty workspace for fresh install" &&
|
||||
ls -la /workspace-out/
|
||||
`],
|
||||
`,
|
||||
],
|
||||
volumeMounts: [
|
||||
{ name: 'workspace', mountPath: '/workspace-out' },
|
||||
{ name: 'dockerfile', mountPath: '/dockerfile' },
|
||||
@@ -475,15 +565,25 @@ export class BuildService {
|
||||
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
await coreApi.createNamespacedConfigMap(buildNamespace!, dockerfileConfigMap);
|
||||
await coreApi.createNamespacedConfigMap({
|
||||
namespace: buildNamespace!,
|
||||
body: dockerfileConfigMap,
|
||||
});
|
||||
this.logger.log(`[timing] ConfigMap created in ${Date.now() - t0}ms`);
|
||||
|
||||
const t1 = Date.now();
|
||||
await batchApi.createNamespacedJob(buildNamespace!, buildJob);
|
||||
await batchApi.createNamespacedJob({
|
||||
namespace: buildNamespace!,
|
||||
body: buildJob,
|
||||
});
|
||||
this.logger.log(`[timing] Job created in ${Date.now() - t1}ms`);
|
||||
|
||||
// Wait for build to complete
|
||||
this.setProgress(deploymentId, { phase: 'building', percent: 15, message: 'Building Docker image...' });
|
||||
this.setProgress(deploymentId, {
|
||||
phase: 'building',
|
||||
percent: 15,
|
||||
message: 'Building Docker image...',
|
||||
});
|
||||
await this.waitForJobCompletion(batchApi, coreApi, buildPodName, buildNamespace!, 600, deploymentId);
|
||||
|
||||
// Capture build logs on success
|
||||
@@ -513,7 +613,10 @@ export class BuildService {
|
||||
// Clean up build resources
|
||||
if (sourcePvcName) {
|
||||
try {
|
||||
await coreApi.deleteNamespacedPersistentVolumeClaim(sourcePvcName, buildNamespace!);
|
||||
await coreApi.deleteNamespacedPersistentVolumeClaim({
|
||||
name: sourcePvcName,
|
||||
namespace: buildNamespace!,
|
||||
});
|
||||
this.logger.log(`Cleaned up source PVC: ${sourcePvcName}`);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Failed to clean up source PVC ${sourcePvcName}: ${e.message}`);
|
||||
@@ -521,7 +624,10 @@ export class BuildService {
|
||||
}
|
||||
// Clean up Dockerfile ConfigMap
|
||||
try {
|
||||
await coreApi.deleteNamespacedConfigMap(`${buildPodName}-dockerfile`, buildNamespace!);
|
||||
await coreApi.deleteNamespacedConfigMap({
|
||||
name: `${buildPodName}-dockerfile`,
|
||||
namespace: buildNamespace!,
|
||||
});
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Failed to clean up ConfigMap: ${e.message}`);
|
||||
}
|
||||
@@ -533,75 +639,68 @@ export class BuildService {
|
||||
* Upload a local file to the helper pod using kubectl cp with progress tracking.
|
||||
* kubectl cp uses tar over the k8s exec API — reliable for any file size.
|
||||
*/
|
||||
private streamFileToHelperPod(
|
||||
kubeconfig: string,
|
||||
namespace: string,
|
||||
podName: string,
|
||||
filePath: string,
|
||||
fileSize: number,
|
||||
deploymentId?: string,
|
||||
): Promise<void> {
|
||||
private streamFileToHelperPod(kubeconfig: string, namespace: string, podName: string, filePath: string, fileSize: number, deploymentId?: string): Promise<void> {
|
||||
const maxAttempts = 3;
|
||||
|
||||
const runOnce = () => new Promise<void>((resolve, reject) => {
|
||||
this.throwIfCancelled(deploymentId);
|
||||
const runOnce = () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
this.throwIfCancelled(deploymentId);
|
||||
|
||||
const kubectl = spawn('kubectl', [
|
||||
'--kubeconfig', kubeconfig,
|
||||
'cp', filePath, `${namespace}/${podName}:/data/source.zip`,
|
||||
'-c', 'helper',
|
||||
'--retries', '3',
|
||||
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
this.registerProcess(deploymentId, kubectl);
|
||||
const kubectl = spawn('kubectl', ['--kubeconfig', kubeconfig, 'cp', filePath, `${namespace}/${podName}:/data/source.zip`, '-c', 'helper', '--retries', '3'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
this.registerProcess(deploymentId, kubectl);
|
||||
|
||||
let stderr = '';
|
||||
kubectl.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
||||
let stderr = '';
|
||||
kubectl.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
// Track progress by polling remote file size
|
||||
let progressTimer: NodeJS.Timeout | undefined;
|
||||
const pollProgress = () => {
|
||||
execFileAsync('kubectl', [
|
||||
'--kubeconfig', kubeconfig,
|
||||
'exec', '-n', namespace, podName, '-c', 'helper', '--',
|
||||
'sh', '-c', 'wc -c < /data/source.zip 2>/dev/null || echo 0',
|
||||
], { timeout: 10_000 }).then(({ stdout }) => {
|
||||
const remoteSize = parseInt(stdout.trim(), 10) || 0;
|
||||
const percent = Math.min(99, Math.round((remoteSize / fileSize) * 100));
|
||||
this.setProgress(deploymentId, {
|
||||
phase: 'uploading',
|
||||
percent,
|
||||
bytesUploaded: remoteSize,
|
||||
totalBytes: fileSize,
|
||||
message: `Uploading to cluster... ${percent}%`,
|
||||
});
|
||||
}).catch(() => { /* polling failure is non-fatal */ });
|
||||
};
|
||||
progressTimer = setInterval(pollProgress, 3000);
|
||||
pollProgress();
|
||||
// Track progress by polling remote file size
|
||||
let progressTimer: NodeJS.Timeout | undefined;
|
||||
const pollProgress = () => {
|
||||
execFileAsync('kubectl', ['--kubeconfig', kubeconfig, 'exec', '-n', namespace, podName, '-c', 'helper', '--', 'sh', '-c', 'wc -c < /data/source.zip 2>/dev/null || echo 0'], {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.then(({ stdout }) => {
|
||||
const remoteSize = parseInt(stdout.trim(), 10) || 0;
|
||||
const percent = Math.min(99, Math.round((remoteSize / fileSize) * 100));
|
||||
this.setProgress(deploymentId, {
|
||||
phase: 'uploading',
|
||||
percent,
|
||||
bytesUploaded: remoteSize,
|
||||
totalBytes: fileSize,
|
||||
message: `Uploading to cluster... ${percent}%`,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
/* polling failure is non-fatal */
|
||||
});
|
||||
};
|
||||
progressTimer = setInterval(pollProgress, 3000);
|
||||
pollProgress();
|
||||
|
||||
kubectl.on('error', (err) => {
|
||||
clearInterval(progressTimer);
|
||||
reject(new Error(`kubectl cp spawn error: ${err.message}`));
|
||||
kubectl.on('error', (err) => {
|
||||
clearInterval(progressTimer);
|
||||
reject(new Error(`kubectl cp spawn error: ${err.message}`));
|
||||
});
|
||||
|
||||
kubectl.on('close', (code) => {
|
||||
clearInterval(progressTimer);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`kubectl cp failed (code ${code}): ${stderr.trim()}`));
|
||||
});
|
||||
});
|
||||
|
||||
kubectl.on('close', (code) => {
|
||||
clearInterval(progressTimer);
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`kubectl cp failed (code ${code}): ${stderr.trim()}`));
|
||||
});
|
||||
});
|
||||
|
||||
return (async () => {
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
this.throwIfCancelled(deploymentId);
|
||||
if (attempt > 1) {
|
||||
this.logger.warn(`Retrying source upload (attempt ${attempt}/${maxAttempts})...`);
|
||||
await execFileAsync('kubectl', [
|
||||
'--kubeconfig', kubeconfig,
|
||||
'exec', '-n', namespace, podName, '-c', 'helper', '--',
|
||||
'rm', '-f', '/data/source.zip',
|
||||
], { timeout: 15_000 }).catch(() => undefined);
|
||||
await execFileAsync('kubectl', ['--kubeconfig', kubeconfig, 'exec', '-n', namespace, podName, '-c', 'helper', '--', 'rm', '-f', '/data/source.zip'], { timeout: 15_000 }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
this.setProgress(deploymentId, {
|
||||
phase: 'uploading',
|
||||
percent: 0,
|
||||
@@ -625,15 +724,7 @@ export class BuildService {
|
||||
* Upload source zip to K8s via PVC + helper pod.
|
||||
* This handles files of any size (unlike Secret/ConfigMap which are limited to ~1MB).
|
||||
*/
|
||||
private async uploadSourceViaPVC(
|
||||
kc: k8s.KubeConfig,
|
||||
coreApi: k8s.CoreV1Api,
|
||||
namespace: string,
|
||||
pvcName: string,
|
||||
zipPath: string,
|
||||
sizeGi: number,
|
||||
deploymentId?: string,
|
||||
): Promise<void> {
|
||||
private async uploadSourceViaPVC(kc: k8s.KubeConfig, coreApi: k8s.CoreV1Api, namespace: string, pvcName: string, zipPath: string, sizeGi: number, deploymentId?: string): Promise<void> {
|
||||
const t0 = Date.now();
|
||||
const helperPodName = `${pvcName}-helper`;
|
||||
const zipSize = fs.statSync(zipPath).size;
|
||||
@@ -645,13 +736,16 @@ export class BuildService {
|
||||
this.logger.log(`Uploading source via PVC (${(zipSize / 1024 / 1024).toFixed(1)} MB) → ${pvcName}`);
|
||||
|
||||
// 1. Create PVC
|
||||
await coreApi.createNamespacedPersistentVolumeClaim(namespace, {
|
||||
apiVersion: 'v1',
|
||||
kind: 'PersistentVolumeClaim',
|
||||
metadata: { name: pvcName, namespace },
|
||||
spec: {
|
||||
accessModes: ['ReadWriteOnce'],
|
||||
resources: { requests: { storage: `${sizeGi}Gi` } },
|
||||
await coreApi.createNamespacedPersistentVolumeClaim({
|
||||
namespace,
|
||||
body: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'PersistentVolumeClaim',
|
||||
metadata: { name: pvcName, namespace },
|
||||
spec: {
|
||||
accessModes: ['ReadWriteOnce'],
|
||||
resources: { requests: { storage: `${sizeGi}Gi` } },
|
||||
},
|
||||
},
|
||||
});
|
||||
this.logger.log(`[timing] PVC ${pvcName} created in ${Date.now() - t0}ms`);
|
||||
@@ -664,39 +758,46 @@ export class BuildService {
|
||||
kind: 'Pod',
|
||||
metadata: { name: helperPodName, namespace },
|
||||
spec: {
|
||||
containers: [{
|
||||
name: 'helper',
|
||||
image: 'alpine:3.19',
|
||||
imagePullPolicy: 'IfNotPresent',
|
||||
command: ['sh', '-c', 'sleep 3600'],
|
||||
volumeMounts: [{ name: 'source', mountPath: '/data' }],
|
||||
resources: {
|
||||
requests: { cpu: '100m', memory: '128Mi' },
|
||||
limits: { cpu: '500m', memory: '256Mi' },
|
||||
containers: [
|
||||
{
|
||||
name: 'helper',
|
||||
image: 'alpine:3.19',
|
||||
imagePullPolicy: 'IfNotPresent',
|
||||
command: ['sh', '-c', 'sleep 3600'],
|
||||
volumeMounts: [{ name: 'source', mountPath: '/data' }],
|
||||
resources: {
|
||||
requests: { cpu: '100m', memory: '128Mi' },
|
||||
limits: { cpu: '500m', memory: '256Mi' },
|
||||
},
|
||||
},
|
||||
}],
|
||||
volumes: [{
|
||||
name: 'source',
|
||||
persistentVolumeClaim: { claimName: pvcName },
|
||||
}],
|
||||
],
|
||||
volumes: [
|
||||
{
|
||||
name: 'source',
|
||||
persistentVolumeClaim: { claimName: pvcName },
|
||||
},
|
||||
],
|
||||
restartPolicy: 'Never',
|
||||
},
|
||||
};
|
||||
|
||||
await coreApi.createNamespacedPod(namespace, helperPod);
|
||||
await coreApi.createNamespacedPod({ namespace, body: helperPod });
|
||||
|
||||
// 3. Wait for helper pod to be Running
|
||||
const podTimeout = 120_000; // 2 minutes
|
||||
const podStart = Date.now();
|
||||
while (Date.now() - podStart < podTimeout) {
|
||||
this.throwIfCancelled(deploymentId);
|
||||
const pod = await coreApi.readNamespacedPod(helperPodName, namespace);
|
||||
const phase = pod.body.status?.phase;
|
||||
const pod = await coreApi.readNamespacedPod({
|
||||
name: helperPodName,
|
||||
namespace,
|
||||
});
|
||||
const phase = pod.status?.phase;
|
||||
if (phase === 'Running') break;
|
||||
if (phase === 'Failed' || phase === 'Unknown') {
|
||||
throw new Error(`Helper pod ${helperPodName} failed to start: phase=${phase}`);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
if (Date.now() - podStart >= podTimeout) {
|
||||
throw new Error(`Helper pod ${helperPodName} did not become Running within 2 minutes`);
|
||||
@@ -719,9 +820,7 @@ export class BuildService {
|
||||
message: 'Uploading source to cluster...',
|
||||
});
|
||||
|
||||
await this.streamFileToHelperPod(
|
||||
tmpKubeconfig, namespace, helperPodName, zipPath, zipSize, deploymentId,
|
||||
);
|
||||
await this.streamFileToHelperPod(tmpKubeconfig, namespace, helperPodName, zipPath, zipSize, deploymentId);
|
||||
|
||||
this.logger.log(`[timing] Source stream upload completed in ${Date.now() - t2}ms (${(zipSize / 1024 / 1024).toFixed(1)} MB)`);
|
||||
this.setProgress(deploymentId, {
|
||||
@@ -733,41 +832,47 @@ export class BuildService {
|
||||
});
|
||||
|
||||
// 5b. Verify the file was written correctly (exact size)
|
||||
const { stdout: sizeStr } = await execFileAsync('kubectl', [
|
||||
'--kubeconfig', tmpKubeconfig,
|
||||
'exec', '-n', namespace, helperPodName, '-c', 'helper',
|
||||
'--', 'sh', '-c', 'wc -c < /data/source.zip',
|
||||
], { timeout: 30_000 });
|
||||
const { stdout: sizeStr } = await execFileAsync(
|
||||
'kubectl',
|
||||
['--kubeconfig', tmpKubeconfig, 'exec', '-n', namespace, helperPodName, '-c', 'helper', '--', 'sh', '-c', 'wc -c < /data/source.zip'],
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
const remoteSize = parseInt(sizeStr.trim(), 10);
|
||||
if (isNaN(remoteSize) || remoteSize !== zipSize) {
|
||||
throw new Error(
|
||||
`Source upload incomplete: expected ${zipSize} bytes but got ${remoteSize} bytes on remote. ` +
|
||||
`(${(zipSize / 1024 / 1024).toFixed(1)} MB expected, ${(remoteSize / 1024 / 1024).toFixed(1)} MB received)`,
|
||||
`(${(zipSize / 1024 / 1024).toFixed(1)} MB expected, ${(remoteSize / 1024 / 1024).toFixed(1)} MB received)`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`[verify] Remote file size: ${remoteSize} bytes (expected ${zipSize}) ✓`);
|
||||
} finally {
|
||||
// Clean up temp kubeconfig
|
||||
try { fs.unlinkSync(tmpKubeconfig); } catch {}
|
||||
try {
|
||||
fs.unlinkSync(tmpKubeconfig);
|
||||
} catch {}
|
||||
|
||||
// 6. Delete the helper pod and WAIT for it to be fully terminated
|
||||
// (PVC is ReadWriteOnce — if the pod is still terminating when the
|
||||
// build Job starts, Kaniko can't mount the PVC → stuck in Pending)
|
||||
try {
|
||||
await coreApi.deleteNamespacedPod(helperPodName, namespace, undefined, undefined, 0);
|
||||
await coreApi.deleteNamespacedPod({
|
||||
name: helperPodName,
|
||||
namespace,
|
||||
gracePeriodSeconds: 0,
|
||||
});
|
||||
this.logger.log(`Helper pod ${helperPodName} delete requested — waiting for termination…`);
|
||||
|
||||
const delTimeout = 60_000;
|
||||
const delStart = Date.now();
|
||||
while (Date.now() - delStart < delTimeout) {
|
||||
try {
|
||||
await coreApi.readNamespacedPod(helperPodName, namespace);
|
||||
await coreApi.readNamespacedPod({ name: helperPodName, namespace });
|
||||
// Pod still exists — wait
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
} catch (err: any) {
|
||||
if (err.statusCode === 404 || err.body?.code === 404) {
|
||||
if (err.code === 404 || err.body?.code === 404) {
|
||||
this.logger.log(`Helper pod ${helperPodName} fully terminated`);
|
||||
break;
|
||||
}
|
||||
@@ -790,12 +895,12 @@ export class BuildService {
|
||||
private async ensureNamespace(coreApi: k8s.CoreV1Api, namespace: string): Promise<void> {
|
||||
// 1. Ensure namespace
|
||||
try {
|
||||
await coreApi.readNamespace(namespace);
|
||||
await coreApi.readNamespace({ name: namespace });
|
||||
} catch (err: any) {
|
||||
if (err.statusCode === 404 || err.body?.code === 404) {
|
||||
if (err.code === 404 || err.body?.code === 404) {
|
||||
this.logger.log(`Namespace "${namespace}" not found — creating it`);
|
||||
await coreApi.createNamespace({
|
||||
metadata: { name: namespace },
|
||||
body: { metadata: { name: namespace } },
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
@@ -805,12 +910,13 @@ export class BuildService {
|
||||
// 2. Ensure service account for Kaniko
|
||||
const saName = this.configService.get<string>('build.serviceAccount') || 'kaniko-builder';
|
||||
try {
|
||||
await coreApi.readNamespacedServiceAccount(saName, namespace);
|
||||
await coreApi.readNamespacedServiceAccount({ name: saName, namespace });
|
||||
} catch (err: any) {
|
||||
if (err.statusCode === 404 || err.body?.code === 404) {
|
||||
if (err.code === 404 || err.body?.code === 404) {
|
||||
this.logger.log(`ServiceAccount "${saName}" not found in "${namespace}" — creating it`);
|
||||
await coreApi.createNamespacedServiceAccount(namespace, {
|
||||
metadata: { name: saName, namespace },
|
||||
await coreApi.createNamespacedServiceAccount({
|
||||
namespace,
|
||||
body: { metadata: { name: saName, namespace } },
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
@@ -820,15 +926,21 @@ export class BuildService {
|
||||
// 3. Ensure registry-credentials secret (docker config for Kaniko to push)
|
||||
const registrySecretName = 'registry-credentials';
|
||||
try {
|
||||
await coreApi.readNamespacedSecret(registrySecretName, namespace);
|
||||
await coreApi.readNamespacedSecret({
|
||||
name: registrySecretName,
|
||||
namespace,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err.statusCode === 404 || err.body?.code === 404) {
|
||||
if (err.code === 404 || err.body?.code === 404) {
|
||||
this.logger.log(`Secret "${registrySecretName}" not found in "${namespace}" — creating it`);
|
||||
await coreApi.createNamespacedSecret(namespace, {
|
||||
metadata: { name: registrySecretName, namespace },
|
||||
type: 'kubernetes.io/dockerconfigjson',
|
||||
data: {
|
||||
'.dockerconfigjson': Buffer.from(this.registryService.buildDockerConfigJson()).toString('base64'),
|
||||
await coreApi.createNamespacedSecret({
|
||||
namespace,
|
||||
body: {
|
||||
metadata: { name: registrySecretName, namespace },
|
||||
type: 'kubernetes.io/dockerconfigjson',
|
||||
data: {
|
||||
'.dockerconfigjson': Buffer.from(this.registryService.buildDockerConfigJson()).toString('base64'),
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -870,7 +982,7 @@ export class BuildService {
|
||||
}
|
||||
|
||||
// If the directory only contains the zip, we can't detect — trust user
|
||||
const nonZipFiles = files.filter(f => !f.endsWith('.zip') && !f.endsWith('.sql'));
|
||||
const nonZipFiles = files.filter((f) => !f.endsWith('.zip') && !f.endsWith('.sql'));
|
||||
if (nonZipFiles.length === 0) {
|
||||
return app.runtime;
|
||||
}
|
||||
@@ -895,9 +1007,7 @@ export class BuildService {
|
||||
}
|
||||
|
||||
if (detected && detected !== app.runtime) {
|
||||
this.logger.warn(
|
||||
`Runtime mismatch for "${app.name}": configured="${app.runtime}" but source looks like "${detected}". Auto-correcting to "${detected}".`,
|
||||
);
|
||||
this.logger.warn(`Runtime mismatch for "${app.name}": configured="${app.runtime}" but source looks like "${detected}". Auto-correcting to "${detected}".`);
|
||||
return detected;
|
||||
}
|
||||
|
||||
@@ -1094,7 +1204,9 @@ RUN a2enmod rewrite
|
||||
# Increase PHP upload limits for WordPress media
|
||||
RUN echo "upload_max_filesize = 64M\\npost_max_size = 64M\\nmax_execution_time = 300\\nmemory_limit = 256M" > /usr/local/etc/php/conf.d/uploads.ini
|
||||
|
||||
${hasUploadedCode ? `# Copy user's custom WordPress files
|
||||
${
|
||||
hasUploadedCode
|
||||
? `# Copy user's custom WordPress files
|
||||
COPY . /tmp/user-content
|
||||
|
||||
# Auto-detect: full public_html root (has wp-admin) vs wp-content only
|
||||
@@ -1176,14 +1288,20 @@ RUN { \\
|
||||
echo ''; \\
|
||||
echo 'exec docker-entrypoint.sh apache2-foreground'; \\
|
||||
} > /usr/local/bin/cloudhost-entrypoint.sh && chmod +x /usr/local/bin/cloudhost-entrypoint.sh
|
||||
` : `# Fresh install — no user content to merge
|
||||
`}
|
||||
`
|
||||
: `# Fresh install — no user content to merge
|
||||
`
|
||||
}
|
||||
# Set proper ownership
|
||||
RUN chown -R www-data:www-data /var/www/html
|
||||
|
||||
EXPOSE 80
|
||||
${hasUploadedCode ? `ENTRYPOINT ["cloudhost-entrypoint.sh"]
|
||||
CMD []` : `CMD ["apache2-foreground"]`}
|
||||
${
|
||||
hasUploadedCode
|
||||
? `ENTRYPOINT ["cloudhost-entrypoint.sh"]
|
||||
CMD []`
|
||||
: `CMD ["apache2-foreground"]`
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1478,27 +1596,13 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' !
|
||||
if (typeof statusCode === 'number' && (statusCode >= 500 || statusCode === 429)) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [
|
||||
err?.code,
|
||||
err?.message,
|
||||
err?.body?.message,
|
||||
err?.cause?.code,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const haystack = [err?.code, err?.message, err?.body?.message, err?.cause?.code].filter(Boolean).join(' ');
|
||||
return /ECONNRESET|ECONNREFUSED|ETIMEDOUT|ESOCKETTIMEDOUT|EPIPE|EAI_AGAIN|ENOTFOUND|ENETUNREACH|socket hang up|timed? ?out|allotted timeout|did not complete|Client network socket disconnected/i.test(
|
||||
haystack,
|
||||
);
|
||||
}
|
||||
|
||||
private async waitForJobCompletion(
|
||||
batchApi: k8s.BatchV1Api,
|
||||
coreApi: k8s.CoreV1Api,
|
||||
jobName: string,
|
||||
namespace: string,
|
||||
timeoutSeconds: number,
|
||||
deploymentId?: string,
|
||||
): Promise<void> {
|
||||
private async waitForJobCompletion(batchApi: k8s.BatchV1Api, coreApi: k8s.CoreV1Api, jobName: string, namespace: string, timeoutSeconds: number, deploymentId?: string): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const timeoutMs = timeoutSeconds * 1000;
|
||||
let lastLoggedStatus = '';
|
||||
@@ -1513,9 +1617,9 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' !
|
||||
message: 'Building Docker image...',
|
||||
});
|
||||
// ── Check Job status (with retry for transient connection errors) ──
|
||||
let job: { body: k8s.V1Job };
|
||||
let job: k8s.V1Job;
|
||||
try {
|
||||
job = await batchApi.readNamespacedJob(jobName, namespace);
|
||||
job = await batchApi.readNamespacedJob({ name: jobName, namespace });
|
||||
} catch (pollErr: any) {
|
||||
// The Kaniko job keeps running independently of these status polls.
|
||||
// A single API blip (timeout, reset, 5xx, DNS) must NOT abort a build
|
||||
@@ -1523,12 +1627,12 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' !
|
||||
if (this.isTransientK8sError(pollErr)) {
|
||||
const detail = pollErr?.code || pollErr?.message || pollErr?.statusCode || 'unknown';
|
||||
this.logger.warn(`Transient K8s API error polling job ${jobName}: ${detail} — retrying in 5s`);
|
||||
await new Promise(r => setTimeout(r, 5000));
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
continue;
|
||||
}
|
||||
throw pollErr;
|
||||
}
|
||||
const status = job.body.status;
|
||||
const status = job.status;
|
||||
|
||||
if (status?.succeeded && status.succeeded > 0) {
|
||||
this.logger.log(`Build job ${jobName} succeeded`);
|
||||
@@ -1536,51 +1640,42 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' !
|
||||
}
|
||||
|
||||
// Check if the Job has permanently failed (all retries exhausted)
|
||||
const failedCondition = (status?.conditions || []).find(
|
||||
(c) => c.type === 'Failed' && c.status === 'True',
|
||||
);
|
||||
const failedCondition = (status?.conditions || []).find((c) => c.type === 'Failed' && c.status === 'True');
|
||||
if (failedCondition) {
|
||||
const logs = await this.getBuildLogs(coreApi, jobName, namespace);
|
||||
throw new Error(`Build job ${jobName} failed.\nLogs:\n${logs}`);
|
||||
}
|
||||
|
||||
// Safety net: if failures exceed backoffLimit and no pod is still running
|
||||
const backoffLimit = job.body.spec?.backoffLimit ?? 0;
|
||||
const backoffLimit = job.spec?.backoffLimit ?? 0;
|
||||
const failedCount = status?.failed ?? 0;
|
||||
if (failedCount > backoffLimit) {
|
||||
// Double-check: are there still active pods?
|
||||
const activePods = (status as any)?.active ?? 0;
|
||||
if (activePods === 0) {
|
||||
const logs = await this.getBuildLogs(coreApi, jobName, namespace);
|
||||
throw new Error(
|
||||
`Build job ${jobName} failed: ${failedCount} failures exceeded backoffLimit=${backoffLimit}.\nLogs:\n${logs}`,
|
||||
);
|
||||
throw new Error(`Build job ${jobName} failed: ${failedCount} failures exceeded backoffLimit=${backoffLimit}.\nLogs:\n${logs}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Log intermediate pod failures (retries still available)
|
||||
if (failedCount > 0) {
|
||||
this.logger.warn(
|
||||
`Build job ${jobName}: ${failedCount} pod failure(s), backoffLimit=${backoffLimit} — retrying...`,
|
||||
);
|
||||
this.logger.warn(`Build job ${jobName}: ${failedCount} pod failure(s), backoffLimit=${backoffLimit} — retrying...`);
|
||||
}
|
||||
|
||||
// ── Check Pod status for early failure detection ──
|
||||
try {
|
||||
const pods = await coreApi.listNamespacedPod(
|
||||
namespace, undefined, undefined, undefined, undefined,
|
||||
`job-name=${jobName}`,
|
||||
);
|
||||
const pods = await coreApi.listNamespacedPod({
|
||||
namespace,
|
||||
labelSelector: `job-name=${jobName}`,
|
||||
});
|
||||
|
||||
for (const pod of pods.body.items) {
|
||||
for (const pod of pods.items) {
|
||||
const podName = pod.metadata?.name || 'unknown';
|
||||
const phase = pod.status?.phase;
|
||||
|
||||
// Check all container statuses (init + regular) for stuck states
|
||||
const allStatuses = [
|
||||
...(pod.status?.initContainerStatuses || []),
|
||||
...(pod.status?.containerStatuses || []),
|
||||
];
|
||||
const allStatuses = [...(pod.status?.initContainerStatuses || []), ...(pod.status?.containerStatuses || [])];
|
||||
|
||||
for (const cs of allStatuses) {
|
||||
const waiting = cs.state?.waiting;
|
||||
@@ -1589,17 +1684,11 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' !
|
||||
const msg = waiting.message || '';
|
||||
|
||||
// These are unrecoverable — fail fast instead of waiting 10 minutes
|
||||
const fatalReasons = [
|
||||
'ErrImagePull', 'ImagePullBackOff',
|
||||
'CreateContainerConfigError', 'InvalidImageName',
|
||||
'CrashLoopBackOff',
|
||||
];
|
||||
const fatalReasons = ['ErrImagePull', 'ImagePullBackOff', 'CreateContainerConfigError', 'InvalidImageName', 'CrashLoopBackOff'];
|
||||
|
||||
if (fatalReasons.includes(reason)) {
|
||||
const logs = await this.getBuildLogs(coreApi, jobName, namespace);
|
||||
throw new Error(
|
||||
`Build pod ${podName} stuck: ${reason} — ${msg}\nLogs:\n${logs}`,
|
||||
);
|
||||
throw new Error(`Build pod ${podName} stuck: ${reason} — ${msg}\nLogs:\n${logs}`);
|
||||
}
|
||||
|
||||
// Log non-fatal waiting states periodically
|
||||
@@ -1638,50 +1727,33 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' !
|
||||
throw new Error(`Build job ${jobName} timed out after ${timeoutSeconds}s\nLogs:\n${logs}`);
|
||||
}
|
||||
|
||||
private async getBuildLogs(
|
||||
coreApi: k8s.CoreV1Api,
|
||||
jobName: string,
|
||||
namespace: string,
|
||||
): Promise<string> {
|
||||
private async getBuildLogs(coreApi: k8s.CoreV1Api, jobName: string, namespace: string): Promise<string> {
|
||||
try {
|
||||
const pods = await coreApi.listNamespacedPod(
|
||||
const pods = await coreApi.listNamespacedPod({
|
||||
namespace,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
`job-name=${jobName}`,
|
||||
);
|
||||
labelSelector: `job-name=${jobName}`,
|
||||
});
|
||||
|
||||
if (pods.body.items.length === 0) {
|
||||
if (pods.items.length === 0) {
|
||||
return 'No pods found for build job.';
|
||||
}
|
||||
|
||||
const podName = pods.body.items[0].metadata?.name;
|
||||
const podName = pods.items[0].metadata?.name;
|
||||
if (!podName) return 'Pod name not found.';
|
||||
|
||||
// Get logs from all containers (init + kaniko)
|
||||
let allLogs = '';
|
||||
const containers = [
|
||||
...(pods.body.items[0].spec?.initContainers || []),
|
||||
...(pods.body.items[0].spec?.containers || []),
|
||||
];
|
||||
const containers = [...(pods.items[0].spec?.initContainers || []), ...(pods.items[0].spec?.containers || [])];
|
||||
|
||||
for (const container of containers) {
|
||||
try {
|
||||
const logResponse = await coreApi.readNamespacedPodLog(
|
||||
podName,
|
||||
const logResponse = await coreApi.readNamespacedPodLog({
|
||||
name: podName,
|
||||
namespace,
|
||||
container.name,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
500,
|
||||
);
|
||||
allLogs += `\n--- ${container.name} ---\n${logResponse.body}`;
|
||||
container: container.name,
|
||||
tailLines: 500,
|
||||
});
|
||||
allLogs += `\n--- ${container.name} ---\n${logResponse}`;
|
||||
} catch {
|
||||
allLogs += `\n--- ${container.name} --- (no logs available)`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user