fix: reliable source upload, build cancel, WordPress port 80 default
- Replace port-forward/netcat PVC upload with kubectl cp for integrity - Add build cancellation API and session cleanup; deploy catches cancel - Default port 80 for WordPress, PHP, and Laravel on create - Build progress modal with cancel; Helm/K8s adjustments for deployments - Update build and kubernetes specs Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -231,11 +231,6 @@ spec:
|
|||||||
- name: app-storage
|
- name: app-storage
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: {{ $name }}-storage
|
claimName: {{ $name }}-storage
|
||||||
{{- if .Values.wordpress.enabled }}
|
|
||||||
- name: wp-content
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: {{ $name }}-wp-content
|
|
||||||
{{- end }}
|
|
||||||
{{- if .Values.elasticsearch.enabled }}
|
{{- if .Values.elasticsearch.enabled }}
|
||||||
- name: app-logs
|
- name: app-logs
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
{{- if .Values.wordpress.enabled }}
|
|
||||||
{{- $name := include "cloudhost-app.name" . -}}
|
|
||||||
{{- $ns := include "cloudhost-app.namespace" . -}}
|
|
||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: {{ $name }}-wp-content
|
|
||||||
namespace: {{ $ns }}
|
|
||||||
labels:
|
|
||||||
{{- include "cloudhost-app.labels" . | nindent 4 }}
|
|
||||||
annotations:
|
|
||||||
"helm.sh/resource-policy": keep
|
|
||||||
spec:
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteOnce
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: {{ .Values.wordpress.wpContentStorageSize | quote }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -51,7 +51,6 @@ database:
|
|||||||
# ── WordPress-specific ───────────────────────────────────
|
# ── WordPress-specific ───────────────────────────────────
|
||||||
wordpress:
|
wordpress:
|
||||||
enabled: false
|
enabled: false
|
||||||
wpContentStorageSize: "2Gi"
|
|
||||||
|
|
||||||
# ── Redis ──────────────────────────────────────────────
|
# ── Redis ──────────────────────────────────────────────
|
||||||
redis:
|
redis:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import * as crypto from 'crypto';
|
|||||||
import { Application } from './entities/application.entity';
|
import { Application } from './entities/application.entity';
|
||||||
import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto';
|
import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto';
|
||||||
import { ClustersService } from '../clusters/clusters.service';
|
import { ClustersService } from '../clusters/clusters.service';
|
||||||
import { UserRole, DatabaseType, CustomDomainStatus } from '../common/enums';
|
import { UserRole, DatabaseType, CustomDomainStatus, AppRuntime } from '../common/enums';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApplicationsService {
|
export class ApplicationsService {
|
||||||
@@ -78,6 +78,10 @@ export class ApplicationsService {
|
|||||||
|
|
||||||
const customDomain = dto.customDomain?.toLowerCase().trim() || undefined;
|
const customDomain = dto.customDomain?.toLowerCase().trim() || undefined;
|
||||||
|
|
||||||
|
const defaultPort = [AppRuntime.WORDPRESS, AppRuntime.PHP, AppRuntime.LARAVEL].includes(dto.runtime)
|
||||||
|
? 80
|
||||||
|
: 3000;
|
||||||
|
|
||||||
const app = this.appsRepository.create({
|
const app = this.appsRepository.create({
|
||||||
...dto,
|
...dto,
|
||||||
userId,
|
userId,
|
||||||
@@ -85,6 +89,7 @@ export class ApplicationsService {
|
|||||||
poolId,
|
poolId,
|
||||||
dbUsername,
|
dbUsername,
|
||||||
dbPassword,
|
dbPassword,
|
||||||
|
port: dto.port ?? defaultPort,
|
||||||
subdomain: `${dto.name}-${userId.split('-')[0]}`,
|
subdomain: `${dto.name}-${userId.split('-')[0]}`,
|
||||||
customDomain: customDomain || undefined,
|
customDomain: customDomain || undefined,
|
||||||
customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE,
|
customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE,
|
||||||
|
|||||||
@@ -402,28 +402,20 @@ describe('Helper pod PVC race condition', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('WordPress entrypoint script', () => {
|
describe('WordPress entrypoint script', () => {
|
||||||
// The generated entrypoint script content
|
|
||||||
const entrypointScript = `#!/bin/bash
|
const entrypointScript = `#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Merge user wp-content into PVC (first run only)
|
# Merge user wp-content into PVC
|
||||||
if [ -d /usr/src/wordpress-user/wp-content ] && [ ! -f /var/www/html/wp-content/.user-content-merged ]; then
|
if [ -d /usr/src/wordpress-user/wp-content ]; then
|
||||||
|
mkdir -p /var/www/html/wp-content
|
||||||
cp -a /usr/src/wordpress-user/wp-content/. /var/www/html/wp-content/
|
cp -a /usr/src/wordpress-user/wp-content/. /var/www/html/wp-content/
|
||||||
touch /var/www/html/wp-content/.user-content-merged
|
|
||||||
chown -R www-data:www-data /var/www/html/wp-content
|
chown -R www-data:www-data /var/www/html/wp-content
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Apply user wp-config.php if docker-entrypoint has not created one yet
|
|
||||||
if [ -f /usr/src/wordpress-user/wp-config.php ] && [ ! -f /var/www/html/wp-config.php ]; then
|
|
||||||
cp /usr/src/wordpress-user/wp-config.php /var/www/html/wp-config.php
|
|
||||||
chown www-data:www-data /var/www/html/wp-config.php
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec docker-entrypoint.sh apache2-foreground`;
|
exec docker-entrypoint.sh apache2-foreground`;
|
||||||
|
|
||||||
it('should call docker-entrypoint.sh exactly once (via exec)', () => {
|
it('should call docker-entrypoint.sh exactly once (via exec)', () => {
|
||||||
const matches = entrypointScript.match(/docker-entrypoint\.sh/g);
|
const matches = entrypointScript.match(/docker-entrypoint\.sh/g);
|
||||||
// Should appear only once — in the final exec line
|
|
||||||
expect(matches).toHaveLength(1);
|
expect(matches).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -431,12 +423,13 @@ exec docker-entrypoint.sh apache2-foreground`;
|
|||||||
expect(entrypointScript).toContain('exec docker-entrypoint.sh apache2-foreground');
|
expect(entrypointScript).toContain('exec docker-entrypoint.sh apache2-foreground');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should merge wp-content only on first run', () => {
|
it('should merge wp-content on every start when staged content exists', () => {
|
||||||
expect(entrypointScript).toContain('.user-content-merged');
|
expect(entrypointScript).toContain('/usr/src/wordpress-user/wp-content');
|
||||||
|
expect(entrypointScript).not.toContain('.user-content-merged');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should only apply user wp-config.php if no config exists', () => {
|
it('should not copy user wp-config.php (credentials come from env vars)', () => {
|
||||||
expect(entrypointScript).toContain('! -f /var/www/html/wp-config.php');
|
expect(entrypointScript).not.toContain('wp-config.php');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set proper ownership after merging wp-content', () => {
|
it('should set proper ownership after merging wp-content', () => {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import * as k8s from '@kubernetes/client-node';
|
import * as k8s from '@kubernetes/client-node';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { execFile } from 'child_process';
|
import { execFile, spawn, ChildProcess } from 'child_process';
|
||||||
|
import * as net from 'net';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { Application } from '../applications/entities/application.entity';
|
import { Application } from '../applications/entities/application.entity';
|
||||||
import { AppRuntime } from '../common/enums';
|
import { AppRuntime } from '../common/enums';
|
||||||
@@ -11,20 +12,207 @@ import { ClustersService } from '../clusters/clusters.service';
|
|||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
export class BuildCancelledError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Build cancelled by user');
|
||||||
|
this.name = 'BuildCancelledError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveBuildSession {
|
||||||
|
cancelled: boolean;
|
||||||
|
coreApi?: k8s.CoreV1Api;
|
||||||
|
batchApi?: k8s.BatchV1Api;
|
||||||
|
namespace?: string;
|
||||||
|
buildPodName?: string;
|
||||||
|
sourcePvcName?: string;
|
||||||
|
helperPodName?: string;
|
||||||
|
processes: ChildProcess[];
|
||||||
|
socket?: net.Socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildProgress {
|
||||||
|
phase: 'uploading' | 'building' | 'deploying' | 'done' | 'failed';
|
||||||
|
percent: number;
|
||||||
|
bytesUploaded?: number;
|
||||||
|
totalBytes?: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BuildService {
|
export class BuildService {
|
||||||
private readonly logger = new Logger(BuildService.name);
|
private readonly logger = new Logger(BuildService.name);
|
||||||
|
private readonly progressMap = new Map<string, BuildProgress>();
|
||||||
|
private readonly activeBuilds = new Map<string, ActiveBuildSession>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private configService: ConfigService,
|
private configService: ConfigService,
|
||||||
private clustersService: ClustersService,
|
private clustersService: ClustersService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private beginBuildSession(deploymentId: string): void {
|
||||||
|
this.activeBuilds.set(deploymentId, { cancelled: false, processes: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSession(deploymentId?: string): ActiveBuildSession | undefined {
|
||||||
|
if (!deploymentId) return undefined;
|
||||||
|
return this.activeBuilds.get(deploymentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateBuildSession(deploymentId: string, update: Partial<ActiveBuildSession>): void {
|
||||||
|
const session = this.activeBuilds.get(deploymentId);
|
||||||
|
if (session) Object.assign(session, update);
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerProcess(deploymentId: string | undefined, proc: ChildProcess): void {
|
||||||
|
const session = this.getSession(deploymentId);
|
||||||
|
if (!session) return;
|
||||||
|
session.processes.push(proc);
|
||||||
|
if (session.cancelled) {
|
||||||
|
try { proc.kill('SIGKILL'); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerSocket(deploymentId: string | undefined, socket: net.Socket): void {
|
||||||
|
const session = this.getSession(deploymentId);
|
||||||
|
if (!session) return;
|
||||||
|
if (session.socket) {
|
||||||
|
try { session.socket.destroy(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
session.socket = socket;
|
||||||
|
if (session.cancelled) {
|
||||||
|
try { socket.destroy(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private throwIfCancelled(deploymentId?: string): void {
|
||||||
|
if (deploymentId && this.activeBuilds.get(deploymentId)?.cancelled) {
|
||||||
|
throw new BuildCancelledError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private endBuildSession(deploymentId?: string): void {
|
||||||
|
if (deploymentId) this.activeBuilds.delete(deploymentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelBuild(deploymentId: string): Promise<void> {
|
||||||
|
const session = this.activeBuilds.get(deploymentId);
|
||||||
|
if (!session) {
|
||||||
|
this.setProgress(deploymentId, { phase: 'failed', percent: 0, message: 'Cancelled by user' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.cancelled = true;
|
||||||
|
this.logger.log(`Cancelling build for deployment ${deploymentId}`);
|
||||||
|
|
||||||
|
if (session.socket) {
|
||||||
|
try { session.socket.destroy(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
for (const proc of session.processes) {
|
||||||
|
try { proc.kill('SIGKILL'); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { coreApi, batchApi, namespace, buildPodName, sourcePvcName, helperPodName } = session;
|
||||||
|
if (coreApi && namespace) {
|
||||||
|
const cleanup: Promise<unknown>[] = [];
|
||||||
|
if (helperPodName) {
|
||||||
|
cleanup.push(
|
||||||
|
coreApi.deleteNamespacedPod(helperPodName, namespace, undefined, undefined, 0).catch(() => undefined),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (buildPodName && batchApi) {
|
||||||
|
cleanup.push(
|
||||||
|
batchApi.deleteNamespacedJob(buildPodName, namespace, undefined, undefined, 0, undefined, 'Foreground').catch(() => undefined),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (sourcePvcName) {
|
||||||
|
cleanup.push(
|
||||||
|
coreApi.deleteNamespacedPersistentVolumeClaim(sourcePvcName, namespace).catch(() => undefined),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (buildPodName) {
|
||||||
|
cleanup.push(
|
||||||
|
coreApi.deleteNamespacedConfigMap(`${buildPodName}-dockerfile`, namespace).catch(() => undefined),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await Promise.all(cleanup);
|
||||||
|
this.logger.log(`Cleaned up K8s build resources for deployment ${deploymentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setProgress(deploymentId, { phase: 'failed', percent: 0, message: 'Cancelled by user' });
|
||||||
|
this.activeBuilds.delete(deploymentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete all in-flight build artifacts for an app (helper pods, jobs, PVCs, configmaps). */
|
||||||
|
async cleanupBuildResourcesForApp(app: Application): Promise<void> {
|
||||||
|
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 kc = new k8s.KubeConfig();
|
||||||
|
kc.loadFromString(cluster.kubeconfig);
|
||||||
|
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
|
||||||
|
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||||
|
|
||||||
|
const cleanup: Promise<unknown>[] = [];
|
||||||
|
|
||||||
|
const [pods, pvcs, jobs, configMaps] = await Promise.all([
|
||||||
|
coreApi.listNamespacedPod(buildNamespace),
|
||||||
|
coreApi.listNamespacedPersistentVolumeClaim(buildNamespace),
|
||||||
|
batchApi.listNamespacedJob(buildNamespace),
|
||||||
|
coreApi.listNamespacedConfigMap(buildNamespace),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const pod of pods.body.items) {
|
||||||
|
const name = pod.metadata?.name || '';
|
||||||
|
if (name.startsWith(prefix)) {
|
||||||
|
cleanup.push(coreApi.deleteNamespacedPod(name, buildNamespace, undefined, undefined, 0).catch(() => undefined));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const pvc of pvcs.body.items) {
|
||||||
|
const name = pvc.metadata?.name || '';
|
||||||
|
if (name.startsWith(prefix)) {
|
||||||
|
cleanup.push(coreApi.deleteNamespacedPersistentVolumeClaim(name, buildNamespace).catch(() => undefined));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const job of jobs.body.items) {
|
||||||
|
const name = job.metadata?.name || '';
|
||||||
|
if (name.startsWith(prefix)) {
|
||||||
|
cleanup.push(batchApi.deleteNamespacedJob(name, buildNamespace, undefined, undefined, 0, undefined, 'Foreground').catch(() => undefined));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const cm of configMaps.body.items) {
|
||||||
|
const name = cm.metadata?.name || '';
|
||||||
|
if (name.startsWith(prefix)) {
|
||||||
|
cleanup.push(coreApi.deleteNamespacedConfigMap(name, buildNamespace).catch(() => undefined));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(cleanup);
|
||||||
|
this.logger.log(`Cleaned up all build resources matching "${prefix}*" in ${buildNamespace}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getProgress(deploymentId: string): BuildProgress | null {
|
||||||
|
return this.progressMap.get(deploymentId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress(deploymentId: string | undefined, progress: BuildProgress): void {
|
||||||
|
if (!deploymentId) return;
|
||||||
|
this.progressMap.set(deploymentId, progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearProgress(deploymentId: string): void {
|
||||||
|
this.progressMap.delete(deploymentId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a Docker image for the application using Kaniko inside K8s.
|
* Builds a Docker image for the application using Kaniko inside K8s.
|
||||||
* Returns { imageUri, buildLog } — the full image URI and the build logs.
|
* Returns { imageUri, buildLog } — the full image URI and the build logs.
|
||||||
*/
|
*/
|
||||||
async buildImage(app: Application): Promise<{ imageUri: string; buildLog: string }> {
|
async buildImage(app: Application, deploymentId?: string): Promise<{ imageUri: string; buildLog: string }> {
|
||||||
// Internal registry (used by Kaniko inside K8s for pushing)
|
// Internal registry (used by Kaniko inside K8s for pushing)
|
||||||
const internalRegistryUrl = this.configService.get<string>('registry.url') || 'registry.cloudhost-builds.svc.cluster.local:5000';
|
const internalRegistryUrl = this.configService.get<string>('registry.url') || 'registry.cloudhost-builds.svc.cluster.local:5000';
|
||||||
// External registry URL (used by kubelet for pulling — NodePort or external)
|
// External registry URL (used by kubelet for pulling — NodePort or external)
|
||||||
@@ -36,12 +224,20 @@ export class BuildService {
|
|||||||
|
|
||||||
this.logger.log(`Starting image build for ${app.name} → push: ${pushImageUri}, pull: ${pullImageUri}`);
|
this.logger.log(`Starting image build for ${app.name} → push: ${pushImageUri}, pull: ${pullImageUri}`);
|
||||||
|
|
||||||
|
if (deploymentId) {
|
||||||
|
this.beginBuildSession(deploymentId);
|
||||||
|
}
|
||||||
|
|
||||||
// Determine Dockerfile based on runtime
|
// Determine Dockerfile based on runtime
|
||||||
const dockerfileContent = this.generateDockerfile(app);
|
const dockerfileContent = this.generateDockerfile(app);
|
||||||
|
|
||||||
// Create Kaniko build pod
|
// Create Kaniko build pod
|
||||||
const buildPodName = `build-${app.name}-${tag}`.substring(0, 63).replace(/[^a-z0-9-]/g, '');
|
const buildPodName = `build-${app.name}-${tag}`.substring(0, 63).replace(/[^a-z0-9-]/g, '');
|
||||||
|
|
||||||
|
if (deploymentId) {
|
||||||
|
this.updateBuildSession(deploymentId, { buildPodName });
|
||||||
|
}
|
||||||
|
|
||||||
// Use the cluster's kubeconfig instead of default
|
// Use the cluster's kubeconfig instead of default
|
||||||
const cluster = app.clusterId
|
const cluster = app.clusterId
|
||||||
? await this.clustersService.findOne(app.clusterId)
|
? await this.clustersService.findOne(app.clusterId)
|
||||||
@@ -52,8 +248,13 @@ export class BuildService {
|
|||||||
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
|
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
|
||||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||||
|
|
||||||
|
if (deploymentId) {
|
||||||
|
this.updateBuildSession(deploymentId, { coreApi, batchApi, namespace: buildNamespace });
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure the build namespace exists
|
// Ensure the build namespace exists
|
||||||
await this.ensureNamespace(coreApi, buildNamespace);
|
await this.ensureNamespace(coreApi, buildNamespace);
|
||||||
|
this.throwIfCancelled(deploymentId);
|
||||||
|
|
||||||
// Determine if we have uploaded code or git URL
|
// Determine if we have uploaded code or git URL
|
||||||
const codePath = app.codePath ? path.resolve(app.codePath) : null;
|
const codePath = app.codePath ? path.resolve(app.codePath) : null;
|
||||||
@@ -77,12 +278,15 @@ export class BuildService {
|
|||||||
let sourcePvcName: string | undefined;
|
let sourcePvcName: string | undefined;
|
||||||
if (hasUploadedCode) {
|
if (hasUploadedCode) {
|
||||||
sourcePvcName = `${buildPodName}-source`;
|
sourcePvcName = `${buildPodName}-source`;
|
||||||
|
if (deploymentId) {
|
||||||
|
this.updateBuildSession(deploymentId, { sourcePvcName });
|
||||||
|
}
|
||||||
const zipSize = fs.statSync(codePath!).size;
|
const zipSize = fs.statSync(codePath!).size;
|
||||||
// Allocate PVC size = zip size * 3 (zip + extracted), min 1Gi
|
// Allocate PVC size = zip size * 3 (zip + extracted), min 1Gi
|
||||||
const pvcSizeGi = Math.max(1, Math.ceil((zipSize * 3) / (1024 * 1024 * 1024)));
|
const pvcSizeGi = Math.max(1, Math.ceil((zipSize * 3) / (1024 * 1024 * 1024)));
|
||||||
|
|
||||||
await this.uploadSourceViaPVC(
|
await this.uploadSourceViaPVC(
|
||||||
kc, coreApi, buildNamespace!, sourcePvcName, codePath!, pvcSizeGi,
|
kc, coreApi, buildNamespace!, sourcePvcName, codePath!, pvcSizeGi, deploymentId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,11 +335,19 @@ export class BuildService {
|
|||||||
name: 'unzip-source',
|
name: 'unzip-source',
|
||||||
image: 'alpine:3.19',
|
image: 'alpine:3.19',
|
||||||
command: ['sh', '-c', `
|
command: ['sh', '-c', `
|
||||||
apk add --no-cache unzip &&
|
apk add --no-cache unzip tar gzip &&
|
||||||
cp /workspace/Dockerfile /workspace-out/Dockerfile &&
|
cp /workspace/Dockerfile /workspace-out/Dockerfile &&
|
||||||
mkdir -p /tmp/extract &&
|
mkdir -p /tmp/extract &&
|
||||||
cd /tmp/extract &&
|
cd /tmp/extract &&
|
||||||
unzip /source-pvc/source.zip &&
|
if tar tzf /source-pvc/source.zip >/dev/null 2>&1; then
|
||||||
|
echo ">>> Detected gzip tarball" &&
|
||||||
|
tar xzf /source-pvc/source.zip
|
||||||
|
elif unzip -t /source-pvc/source.zip >/dev/null 2>&1; then
|
||||||
|
echo ">>> Detected zip archive" &&
|
||||||
|
unzip -q /source-pvc/source.zip
|
||||||
|
else
|
||||||
|
echo "ERROR: source archive is not a valid zip or tar.gz" && exit 1
|
||||||
|
fi &&
|
||||||
echo "--- Extracted contents ---" &&
|
echo "--- Extracted contents ---" &&
|
||||||
ls -la /tmp/extract/ &&
|
ls -la /tmp/extract/ &&
|
||||||
mkdir -p /workspace-out/source &&
|
mkdir -p /workspace-out/source &&
|
||||||
@@ -263,7 +475,8 @@ export class BuildService {
|
|||||||
this.logger.log(`[timing] Job created in ${Date.now() - t1}ms`);
|
this.logger.log(`[timing] Job created in ${Date.now() - t1}ms`);
|
||||||
|
|
||||||
// Wait for build to complete
|
// Wait for build to complete
|
||||||
await this.waitForJobCompletion(batchApi, coreApi, buildPodName, buildNamespace!, 600);
|
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
|
// Capture build logs on success
|
||||||
let buildLog = '';
|
let buildLog = '';
|
||||||
@@ -274,6 +487,9 @@ export class BuildService {
|
|||||||
this.logger.log(`Build completed successfully: ${pullImageUri}`);
|
this.logger.log(`Build completed successfully: ${pullImageUri}`);
|
||||||
return { imageUri: pullImageUri, buildLog };
|
return { imageUri: pullImageUri, buildLog };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error instanceof BuildCancelledError || error?.name === 'BuildCancelledError') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
// Try to get build logs for debugging
|
// Try to get build logs for debugging
|
||||||
let buildLog = '';
|
let buildLog = '';
|
||||||
try {
|
try {
|
||||||
@@ -301,11 +517,115 @@ export class BuildService {
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.logger.warn(`Failed to clean up ConfigMap: ${e.message}`);
|
this.logger.warn(`Failed to clean up ConfigMap: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
this.endBuildSession(deploymentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload source zip to K8s via PVC + helper pod + kubectl cp.
|
* Poll the helper pod until netcat finishes writing and reports the expected byte count.
|
||||||
|
*/
|
||||||
|
private async waitForRemoteUploadDone(
|
||||||
|
kubeconfig: string,
|
||||||
|
namespace: string,
|
||||||
|
podName: string,
|
||||||
|
expectedSize: number,
|
||||||
|
deploymentId?: string,
|
||||||
|
): Promise<void> { /* unused — kept for API compat */ }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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> {
|
||||||
|
const maxAttempts = 3;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
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()}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
this.setProgress(deploymentId, {
|
||||||
|
phase: 'uploading',
|
||||||
|
percent: 0,
|
||||||
|
bytesUploaded: 0,
|
||||||
|
totalBytes: fileSize,
|
||||||
|
message: `Retrying upload (attempt ${attempt})...`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await runOnce();
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof BuildCancelledError || (err as Error)?.name === 'BuildCancelledError') throw err;
|
||||||
|
if (attempt === maxAttempts) throw err;
|
||||||
|
this.logger.warn(`Upload attempt ${attempt} failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload source zip to K8s via PVC + helper pod.
|
||||||
* This handles files of any size (unlike Secret/ConfigMap which are limited to ~1MB).
|
* This handles files of any size (unlike Secret/ConfigMap which are limited to ~1MB).
|
||||||
*/
|
*/
|
||||||
private async uploadSourceViaPVC(
|
private async uploadSourceViaPVC(
|
||||||
@@ -315,11 +635,16 @@ export class BuildService {
|
|||||||
pvcName: string,
|
pvcName: string,
|
||||||
zipPath: string,
|
zipPath: string,
|
||||||
sizeGi: number,
|
sizeGi: number,
|
||||||
|
deploymentId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const helperPodName = `${pvcName}-helper`;
|
const helperPodName = `${pvcName}-helper`;
|
||||||
const zipSize = fs.statSync(zipPath).size;
|
const zipSize = fs.statSync(zipPath).size;
|
||||||
|
|
||||||
|
if (deploymentId) {
|
||||||
|
this.updateBuildSession(deploymentId, { helperPodName });
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log(`Uploading source via PVC (${(zipSize / 1024 / 1024).toFixed(1)} MB) → ${pvcName}`);
|
this.logger.log(`Uploading source via PVC (${(zipSize / 1024 / 1024).toFixed(1)} MB) → ${pvcName}`);
|
||||||
|
|
||||||
// 1. Create PVC
|
// 1. Create PVC
|
||||||
@@ -366,6 +691,7 @@ export class BuildService {
|
|||||||
const podTimeout = 120_000; // 2 minutes
|
const podTimeout = 120_000; // 2 minutes
|
||||||
const podStart = Date.now();
|
const podStart = Date.now();
|
||||||
while (Date.now() - podStart < podTimeout) {
|
while (Date.now() - podStart < podTimeout) {
|
||||||
|
this.throwIfCancelled(deploymentId);
|
||||||
const pod = await coreApi.readNamespacedPod(helperPodName, namespace);
|
const pod = await coreApi.readNamespacedPod(helperPodName, namespace);
|
||||||
const phase = pod.body.status?.phase;
|
const phase = pod.body.status?.phase;
|
||||||
if (phase === 'Running') break;
|
if (phase === 'Running') break;
|
||||||
@@ -385,35 +711,44 @@ export class BuildService {
|
|||||||
fs.writeFileSync(tmpKubeconfig, kcYaml);
|
fs.writeFileSync(tmpKubeconfig, kcYaml);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 5. Copy the zip into the helper pod via kubectl cp.
|
// 5. Upload the zip via kubectl cp (tar-based, reliable for any size).
|
||||||
// Both k8s.Exec (WebSocket) and kubectl exec -i stdin piping are unreliable
|
|
||||||
// for binary transfers — data can be lost before the connection is ready.
|
|
||||||
// kubectl cp uses tar internally and handles connection timing correctly.
|
|
||||||
const t2 = Date.now();
|
const t2 = Date.now();
|
||||||
|
this.setProgress(deploymentId, {
|
||||||
|
phase: 'uploading',
|
||||||
|
percent: 0,
|
||||||
|
bytesUploaded: 0,
|
||||||
|
totalBytes: zipSize,
|
||||||
|
message: 'Uploading source to cluster...',
|
||||||
|
});
|
||||||
|
|
||||||
await execFileAsync('kubectl', [
|
await this.streamFileToHelperPod(
|
||||||
'--kubeconfig', tmpKubeconfig,
|
tmpKubeconfig, namespace, helperPodName, zipPath, zipSize, deploymentId,
|
||||||
'cp', zipPath,
|
);
|
||||||
`${namespace}/${helperPodName}:/data/source.zip`,
|
|
||||||
'-c', 'helper',
|
|
||||||
], { timeout: 1200_000 });
|
|
||||||
|
|
||||||
this.logger.log(`[timing] kubectl cp upload completed in ${Date.now() - t2}ms (${(zipSize / 1024 / 1024).toFixed(1)} MB)`);
|
this.logger.log(`[timing] Source stream upload completed in ${Date.now() - t2}ms (${(zipSize / 1024 / 1024).toFixed(1)} MB)`);
|
||||||
|
this.setProgress(deploymentId, {
|
||||||
|
phase: 'uploading',
|
||||||
|
percent: 100,
|
||||||
|
bytesUploaded: zipSize,
|
||||||
|
totalBytes: zipSize,
|
||||||
|
message: 'Upload complete, verifying...',
|
||||||
|
});
|
||||||
|
|
||||||
// 5b. Verify the file was written correctly
|
// 5b. Verify the file was written correctly (exact size)
|
||||||
const { stdout: sizeStr } = await execFileAsync('kubectl', [
|
const { stdout: sizeStr } = await execFileAsync('kubectl', [
|
||||||
'--kubeconfig', tmpKubeconfig,
|
'--kubeconfig', tmpKubeconfig,
|
||||||
'exec', '-n', namespace, helperPodName,
|
'exec', '-n', namespace, helperPodName, '-c', 'helper',
|
||||||
'--', 'sh', '-c', 'wc -c < /data/source.zip',
|
'--', 'sh', '-c', 'wc -c < /data/source.zip',
|
||||||
], { timeout: 30_000 });
|
], { timeout: 30_000 });
|
||||||
|
|
||||||
const remoteSize = parseInt(sizeStr.trim(), 10);
|
const remoteSize = parseInt(sizeStr.trim(), 10);
|
||||||
if (isNaN(remoteSize) || remoteSize < zipSize * 0.95) {
|
if (isNaN(remoteSize) || remoteSize !== zipSize) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Source upload incomplete: expected ${zipSize} bytes but got ${remoteSize} bytes on remote. ` +
|
`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}) ✓`);
|
this.logger.log(`[verify] Remote file size: ${remoteSize} bytes (expected ${zipSize}) ✓`);
|
||||||
} finally {
|
} finally {
|
||||||
// Clean up temp kubeconfig
|
// Clean up temp kubeconfig
|
||||||
@@ -799,11 +1134,15 @@ RUN mkdir -p /usr/src/wordpress-user && \\
|
|||||||
if [ -d /tmp/user-content/wp-content ]; then \\
|
if [ -d /tmp/user-content/wp-content ]; then \\
|
||||||
echo ">>> Staging user wp-content (themes, plugins, uploads)..." && \\
|
echo ">>> Staging user wp-content (themes, plugins, uploads)..." && \\
|
||||||
cp -a /tmp/user-content/wp-content /usr/src/wordpress-user/wp-content; \\
|
cp -a /tmp/user-content/wp-content /usr/src/wordpress-user/wp-content; \\
|
||||||
|
elif [ -d /tmp/user-content/themes ] || [ -d /tmp/user-content/plugins ] || [ -d /tmp/user-content/uploads ]; then \\
|
||||||
|
echo ">>> Staging loose themes/plugins/uploads into wp-content..." && \\
|
||||||
|
mkdir -p /usr/src/wordpress-user/wp-content && \\
|
||||||
|
[ -d /tmp/user-content/themes ] && cp -a /tmp/user-content/themes /usr/src/wordpress-user/wp-content/ || true && \\
|
||||||
|
[ -d /tmp/user-content/plugins ] && cp -a /tmp/user-content/plugins /usr/src/wordpress-user/wp-content/ || true && \\
|
||||||
|
[ -d /tmp/user-content/uploads ] && cp -a /tmp/user-content/uploads /usr/src/wordpress-user/wp-content/ || true; \\
|
||||||
fi && \\
|
fi && \\
|
||||||
if [ -f /tmp/user-content/wp-config.php ]; then \\
|
# wp-config.php is intentionally NOT copied — docker-entrypoint.sh generates it
|
||||||
echo ">>> Saving custom wp-config.php" && \\
|
# from WORDPRESS_DB_* env vars so credentials always match the deployed database.
|
||||||
cp /tmp/user-content/wp-config.php /usr/src/wordpress-user/wp-config.php; \\
|
|
||||||
fi && \\
|
|
||||||
if [ -f /tmp/user-content/.htaccess ]; then \\
|
if [ -f /tmp/user-content/.htaccess ]; then \\
|
||||||
echo ">>> Copying .htaccess" && \\
|
echo ">>> Copying .htaccess" && \\
|
||||||
cp /tmp/user-content/.htaccess /var/www/html/.htaccess; \\
|
cp /tmp/user-content/.htaccess /var/www/html/.htaccess; \\
|
||||||
@@ -812,30 +1151,22 @@ RUN mkdir -p /usr/src/wordpress-user && \\
|
|||||||
echo ">>> WordPress user content staged"
|
echo ">>> WordPress user content staged"
|
||||||
|
|
||||||
# Custom entrypoint:
|
# Custom entrypoint:
|
||||||
# 1. Merge staged wp-content into the PVC mount on first run
|
# 1. Merge staged wp-content into the PVC mount (every start — idempotent)
|
||||||
# 2. Optionally apply user's wp-config.php (saved during build)
|
# 2. Hand off to official docker-entrypoint.sh which creates wp-config.php
|
||||||
# 3. Hand off to the official docker-entrypoint.sh which handles
|
# from WORDPRESS_DB_* env vars (never use uploaded wp-config with old credentials)
|
||||||
# WORDPRESS_DB_* env-var injection and first-run setup
|
|
||||||
RUN { \\
|
RUN { \\
|
||||||
echo '#!/bin/bash'; \\
|
echo '#!/bin/bash'; \\
|
||||||
echo 'set -e'; \\
|
echo 'set -e'; \\
|
||||||
echo ''; \\
|
echo ''; \\
|
||||||
echo '# ── Merge user wp-content into PVC (first run only) ──'; \\
|
echo '# ── Merge user wp-content into PVC ──'; \\
|
||||||
echo 'if [ -d /usr/src/wordpress-user/wp-content ] && [ ! -f /var/www/html/wp-content/.user-content-merged ]; then'; \\
|
echo 'if [ -d /usr/src/wordpress-user/wp-content ]; then'; \\
|
||||||
echo ' echo ">>> First run: merging user wp-content into PVC..."'; \\
|
echo ' echo ">>> Merging user wp-content into PVC..."'; \\
|
||||||
|
echo ' mkdir -p /var/www/html/wp-content'; \\
|
||||||
echo ' cp -a /usr/src/wordpress-user/wp-content/. /var/www/html/wp-content/'; \\
|
echo ' cp -a /usr/src/wordpress-user/wp-content/. /var/www/html/wp-content/'; \\
|
||||||
echo ' touch /var/www/html/wp-content/.user-content-merged'; \\
|
|
||||||
echo ' chown -R www-data:www-data /var/www/html/wp-content'; \\
|
echo ' chown -R www-data:www-data /var/www/html/wp-content'; \\
|
||||||
echo ' echo ">>> User wp-content merged successfully"'; \\
|
echo ' echo ">>> User wp-content merged successfully"'; \\
|
||||||
echo 'fi'; \\
|
echo 'fi'; \\
|
||||||
echo ''; \\
|
echo ''; \\
|
||||||
echo '# ── Apply user wp-config.php if docker-entrypoint has not created one yet ──'; \\
|
|
||||||
echo 'if [ -f /usr/src/wordpress-user/wp-config.php ] && [ ! -f /var/www/html/wp-config.php ]; then'; \\
|
|
||||||
echo ' echo ">>> Applying user wp-config.php"'; \\
|
|
||||||
echo ' cp /usr/src/wordpress-user/wp-config.php /var/www/html/wp-config.php'; \\
|
|
||||||
echo ' chown www-data:www-data /var/www/html/wp-config.php'; \\
|
|
||||||
echo 'fi'; \\
|
|
||||||
echo ''; \\
|
|
||||||
echo 'exec docker-entrypoint.sh apache2-foreground'; \\
|
echo 'exec docker-entrypoint.sh apache2-foreground'; \\
|
||||||
} > /usr/local/bin/cloudhost-entrypoint.sh && chmod +x /usr/local/bin/cloudhost-entrypoint.sh
|
} > /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
|
||||||
@@ -1135,12 +1466,21 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' !
|
|||||||
jobName: string,
|
jobName: string,
|
||||||
namespace: string,
|
namespace: string,
|
||||||
timeoutSeconds: number,
|
timeoutSeconds: number,
|
||||||
|
deploymentId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const timeoutMs = timeoutSeconds * 1000;
|
const timeoutMs = timeoutSeconds * 1000;
|
||||||
let lastLoggedStatus = '';
|
let lastLoggedStatus = '';
|
||||||
|
|
||||||
while (Date.now() - startTime < timeoutMs) {
|
while (Date.now() - startTime < timeoutMs) {
|
||||||
|
this.throwIfCancelled(deploymentId);
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const buildPercent = Math.min(90, 15 + Math.round((elapsed / timeoutMs) * 75));
|
||||||
|
this.setProgress(deploymentId, {
|
||||||
|
phase: 'building',
|
||||||
|
percent: buildPercent,
|
||||||
|
message: 'Building Docker image...',
|
||||||
|
});
|
||||||
// ── Check Job status (with retry for transient connection errors) ──
|
// ── Check Job status (with retry for transient connection errors) ──
|
||||||
let job: { body: k8s.V1Job };
|
let job: { body: k8s.V1Job };
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -42,12 +42,25 @@ export class DeploymentsController {
|
|||||||
return { logs: await this.deploymentsService.getLogs(appId, req.user.id) };
|
return { logs: await this.deploymentsService.getLogs(appId, req.user.id) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('applications/:appId/build-progress')
|
||||||
|
@ApiOperation({ summary: 'Get real-time build/upload progress for latest deployment' })
|
||||||
|
async getBuildProgress(@Param('appId') appId: string, @Request() req: any) {
|
||||||
|
return { progress: await this.deploymentsService.getBuildProgress(appId, req.user.id) };
|
||||||
|
}
|
||||||
|
|
||||||
@Get('applications/:appId/build-logs')
|
@Get('applications/:appId/build-logs')
|
||||||
@ApiOperation({ summary: 'Get build logs for latest deployment' })
|
@ApiOperation({ summary: 'Get build logs for latest deployment' })
|
||||||
async getBuildLogs(@Param('appId') appId: string, @Request() req: any) {
|
async getBuildLogs(@Param('appId') appId: string, @Request() req: any) {
|
||||||
return this.deploymentsService.getBuildLogs(appId, req.user.id);
|
return this.deploymentsService.getBuildLogs(appId, req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('applications/:appId/cancel')
|
||||||
|
@ApiOperation({ summary: 'Cancel an in-progress build/deploy pipeline' })
|
||||||
|
async cancel(@Param('appId') appId: string, @Request() req: any) {
|
||||||
|
const deployment = await this.deploymentsService.cancelDeployment(appId, req.user.id);
|
||||||
|
return { message: 'Deployment cancelled', deployment };
|
||||||
|
}
|
||||||
|
|
||||||
@Post('applications/:appId/stop')
|
@Post('applications/:appId/stop')
|
||||||
@ApiOperation({ summary: 'Stop an application' })
|
@ApiOperation({ summary: 'Stop an application' })
|
||||||
async stop(@Param('appId') appId: string, @Request() req: any) {
|
async stop(@Param('appId') appId: string, @Request() req: any) {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Injectable, NotFoundException, Logger, Inject, forwardRef } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { Deployment } from './entities/deployment.entity';
|
import { Deployment } from './entities/deployment.entity';
|
||||||
import { ApplicationsService } from '../applications/applications.service';
|
import { ApplicationsService } from '../applications/applications.service';
|
||||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||||
import { BuildService } from '../build/build.service';
|
import { BuildService, BuildProgress, BuildCancelledError } from '../build/build.service';
|
||||||
import { DeploymentStatus } from '../common/enums';
|
import { DeploymentStatus } from '../common/enums';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -46,7 +46,7 @@ export class DeploymentsService {
|
|||||||
try {
|
try {
|
||||||
// Step 1: Build image
|
// Step 1: Build image
|
||||||
await this.updateStatus(deploymentId, DeploymentStatus.BUILDING);
|
await this.updateStatus(deploymentId, DeploymentStatus.BUILDING);
|
||||||
const { imageUri, buildLog } = await this.buildService.buildImage(app);
|
const { imageUri, buildLog } = await this.buildService.buildImage(app, deploymentId);
|
||||||
|
|
||||||
// Save build log
|
// Save build log
|
||||||
await this.deploymentsRepository.update(deploymentId, { buildLog });
|
await this.deploymentsRepository.update(deploymentId, { buildLog });
|
||||||
@@ -56,15 +56,23 @@ export class DeploymentsService {
|
|||||||
|
|
||||||
// Step 3: Deploy to Kubernetes
|
// Step 3: Deploy to Kubernetes
|
||||||
await this.updateStatus(deploymentId, DeploymentStatus.DEPLOYING);
|
await this.updateStatus(deploymentId, DeploymentStatus.DEPLOYING);
|
||||||
const k8sResources = await this.kubernetesService.deployApplication(app, imageUri);
|
this.buildService.setProgress(deploymentId, {
|
||||||
|
phase: 'deploying',
|
||||||
|
percent: 92,
|
||||||
|
message: 'Deploying to Kubernetes...',
|
||||||
|
});
|
||||||
|
|
||||||
|
// If a DB dump will be restored, deploy with 0 replicas first so WordPress
|
||||||
|
// does not initialize empty tables before the dump is imported.
|
||||||
|
const hasDbDump = app.dbDumpPath && fs.existsSync(app.dbDumpPath);
|
||||||
|
const deployApp = hasDbDump ? { ...app, replicas: 0 } : app;
|
||||||
|
const k8sResources = await this.kubernetesService.deployApplication(deployApp, imageUri);
|
||||||
|
|
||||||
// Step 3.5: Restore DB dump if one was uploaded (must happen after deploy creates the namespace + DB)
|
// Step 3.5: Restore DB dump if one was uploaded (must happen after deploy creates the namespace + DB)
|
||||||
if (app.dbDumpPath && fs.existsSync(app.dbDumpPath)) {
|
if (hasDbDump) {
|
||||||
this.logger.log(`Restoring DB dump for ${app.name} from ${app.dbDumpPath}`);
|
this.logger.log(`Restoring DB dump for ${app.name} from ${app.dbDumpPath}`);
|
||||||
try {
|
try {
|
||||||
// Wait for the database pod to be Ready before restoring
|
|
||||||
await this.kubernetesService.waitForDatabaseReady(app, 120_000);
|
await this.kubernetesService.waitForDatabaseReady(app, 120_000);
|
||||||
// Re-fetch app to ensure we have latest data
|
|
||||||
const freshApp = await this.applicationsService.findOne(app.id);
|
const freshApp = await this.applicationsService.findOne(app.id);
|
||||||
const result = await this.kubernetesService.restoreDatabaseDump(freshApp, freshApp.dbDumpPath!);
|
const result = await this.kubernetesService.restoreDatabaseDump(freshApp, freshApp.dbDumpPath!);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -74,18 +82,43 @@ export class DeploymentsService {
|
|||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.logger.warn(`DB dump restore error for ${app.name}: ${e.message}`);
|
this.logger.warn(`DB dump restore error for ${app.name}: ${e.message}`);
|
||||||
// Don't fail the deployment — DB restore is a best-effort step
|
}
|
||||||
|
// Scale WordPress app up after restore (or even if restore failed)
|
||||||
|
try {
|
||||||
|
await this.kubernetesService.scaleDeployment(app, app.replicas || 1);
|
||||||
|
this.logger.log(`Scaled ${app.name} to ${app.replicas || 1} replica(s) after DB restore`);
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`Failed to scale up ${app.name} after DB restore: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Mark success
|
// Step 4: Mark success
|
||||||
|
this.buildService.setProgress(deploymentId, {
|
||||||
|
phase: 'done',
|
||||||
|
percent: 100,
|
||||||
|
message: 'Deployment complete',
|
||||||
|
});
|
||||||
await this.deploymentsRepository.update(deploymentId, {
|
await this.deploymentsRepository.update(deploymentId, {
|
||||||
status: DeploymentStatus.RUNNING,
|
status: DeploymentStatus.RUNNING,
|
||||||
k8sResources,
|
k8sResources,
|
||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error instanceof BuildCancelledError || error?.name === 'BuildCancelledError') {
|
||||||
|
this.logger.log(`Deployment ${deploymentId} cancelled by user`);
|
||||||
|
await this.deploymentsRepository.update(deploymentId, {
|
||||||
|
status: DeploymentStatus.FAILED,
|
||||||
|
errorMessage: 'Cancelled by user',
|
||||||
|
finishedAt: new Date(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.logger.error(`Deployment ${deploymentId} failed:`, error);
|
this.logger.error(`Deployment ${deploymentId} failed:`, error);
|
||||||
|
this.buildService.setProgress(deploymentId, {
|
||||||
|
phase: 'failed',
|
||||||
|
percent: 0,
|
||||||
|
message: error.message || 'Deployment failed',
|
||||||
|
});
|
||||||
|
|
||||||
// Save build log if available (attached by build service on failure)
|
// Save build log if available (attached by build service on failure)
|
||||||
const buildLog = error.buildLog || null;
|
const buildLog = error.buildLog || null;
|
||||||
@@ -146,6 +179,65 @@ export class DeploymentsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getBuildProgress(applicationId: string, userId: string): Promise<BuildProgress | null> {
|
||||||
|
await this.applicationsService.findOne(applicationId, userId);
|
||||||
|
|
||||||
|
const latest = await this.deploymentsRepository.findOne({
|
||||||
|
where: { applicationId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!latest) return null;
|
||||||
|
|
||||||
|
const progress = this.buildService.getProgress(latest.id);
|
||||||
|
if (progress) return progress;
|
||||||
|
|
||||||
|
// No in-memory progress — infer from deployment status
|
||||||
|
if (latest.status === DeploymentStatus.RUNNING) {
|
||||||
|
return { phase: 'done', percent: 100, message: 'Deployment complete' };
|
||||||
|
}
|
||||||
|
if (latest.status === DeploymentStatus.FAILED) {
|
||||||
|
return { phase: 'failed', percent: 0, message: latest.errorMessage || 'Deployment failed' };
|
||||||
|
}
|
||||||
|
if (latest.status === DeploymentStatus.BUILDING) {
|
||||||
|
return { phase: 'building', percent: 0, message: 'Building...' };
|
||||||
|
}
|
||||||
|
if (latest.status === DeploymentStatus.DEPLOYING) {
|
||||||
|
return { phase: 'deploying', percent: 90, message: 'Deploying...' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelDeployment(applicationId: string, userId: string): Promise<Deployment> {
|
||||||
|
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||||
|
|
||||||
|
const latest = await this.deploymentsRepository.findOne({
|
||||||
|
where: { applicationId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!latest) {
|
||||||
|
throw new NotFoundException('No deployment found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const inProgress = [
|
||||||
|
DeploymentStatus.PENDING,
|
||||||
|
DeploymentStatus.BUILDING,
|
||||||
|
DeploymentStatus.DEPLOYING,
|
||||||
|
];
|
||||||
|
if (!inProgress.includes(latest.status as DeploymentStatus)) {
|
||||||
|
throw new BadRequestException('No deployment in progress to cancel');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.buildService.cancelBuild(latest.id);
|
||||||
|
await this.buildService.cleanupBuildResourcesForApp(app);
|
||||||
|
|
||||||
|
latest.status = DeploymentStatus.FAILED;
|
||||||
|
latest.errorMessage = 'Cancelled by user';
|
||||||
|
latest.finishedAt = new Date();
|
||||||
|
return this.deploymentsRepository.save(latest);
|
||||||
|
}
|
||||||
|
|
||||||
async stopDeployment(applicationId: string, userId: string): Promise<Deployment | null> {
|
async stopDeployment(applicationId: string, userId: string): Promise<Deployment | null> {
|
||||||
const app = await this.applicationsService.findOne(applicationId, userId);
|
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||||
await this.kubernetesService.scaleDeployment(app, 0);
|
await this.kubernetesService.scaleDeployment(app, 0);
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ describe('buildHelmValues logic', () => {
|
|||||||
},
|
},
|
||||||
wordpress: {
|
wordpress: {
|
||||||
enabled: isWordPress,
|
enabled: isWordPress,
|
||||||
wpContentStorageSize: '2Gi',
|
|
||||||
},
|
},
|
||||||
changeCause: `Deploy ${imageUri} at 2024-01-01T00:00:00.000Z`,
|
changeCause: `Deploy ${imageUri} at 2024-01-01T00:00:00.000Z`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
image: imageUri,
|
image: imageUri,
|
||||||
port: app.port,
|
port: app.port,
|
||||||
replicas: app.replicas,
|
replicas: app.replicas,
|
||||||
|
storageSize: app.appStorageSize || '2Gi',
|
||||||
},
|
},
|
||||||
resources: {
|
resources: {
|
||||||
cpuRequest: app.cpuRequest,
|
cpuRequest: app.cpuRequest,
|
||||||
@@ -141,7 +142,6 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
},
|
},
|
||||||
wordpress: {
|
wordpress: {
|
||||||
enabled: isWordPress,
|
enabled: isWordPress,
|
||||||
wpContentStorageSize: '2Gi',
|
|
||||||
},
|
},
|
||||||
redis: {
|
redis: {
|
||||||
enabled: app.enableRedis || false,
|
enabled: app.enableRedis || false,
|
||||||
@@ -2431,7 +2431,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
||||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const pvcName = `${app.name}-wp-content`;
|
const pvcName = `${app.name}-storage`;
|
||||||
const jobName = `${app.name}-wp-archive-${Date.now()}`;
|
const jobName = `${app.name}-wp-archive-${Date.now()}`;
|
||||||
|
|
||||||
const job: k8s.V1Job = {
|
const job: k8s.V1Job = {
|
||||||
@@ -2563,7 +2563,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
|
||||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||||
const pvcName = `${app.name}-wp-content`;
|
const pvcName = `${app.name}-storage`;
|
||||||
const jobName = `${app.name}-wp-restore-${Date.now()}`;
|
const jobName = `${app.name}-wp-restore-${Date.now()}`;
|
||||||
const secretName = `${jobName}-archive`;
|
const secretName = `${jobName}-archive`;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPool
|
|||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle } from 'lucide-react';
|
import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle } from 'lucide-react';
|
||||||
import { useConfirm } from '@/components/confirm-modal';
|
import { useConfirm } from '@/components/confirm-modal';
|
||||||
|
import { BuildProgressModal } from '@/components/build-progress-modal';
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
running: 'badge-green',
|
running: 'badge-green',
|
||||||
@@ -2375,6 +2376,7 @@ export default function AppDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<BuildProgressModal appId={appId} enabled={isInProgress} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import { Loader2, Upload, Hammer, Rocket, CheckCircle, XCircle, X } from 'lucide-react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
export interface BuildProgress {
|
||||||
|
phase: 'uploading' | 'building' | 'deploying' | 'done' | 'failed';
|
||||||
|
percent: number;
|
||||||
|
bytesUploaded?: number;
|
||||||
|
totalBytes?: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes?: number): string {
|
||||||
|
if (!bytes) return '';
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseConfig = {
|
||||||
|
uploading: { label: 'Uploading source to cluster', icon: Upload, bg: 'bg-blue-500' },
|
||||||
|
building: { label: 'Building Docker image', icon: Hammer, bg: 'bg-amber-500' },
|
||||||
|
deploying: { label: 'Deploying to Kubernetes', icon: Rocket, bg: 'bg-purple-500' },
|
||||||
|
done: { label: 'Deployment complete', icon: CheckCircle, bg: 'bg-green-500' },
|
||||||
|
failed: { label: 'Deployment failed', icon: XCircle, bg: 'bg-red-500' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BuildProgressModal({ appId, enabled }: { appId: string; enabled: boolean }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) setDismissed(false);
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
const { data } = useQuery<{ progress: BuildProgress | null }>({
|
||||||
|
queryKey: ['build-progress', appId],
|
||||||
|
queryFn: () => api.get(`/deployments/applications/${appId}/build-progress`).then((r) => r.data),
|
||||||
|
enabled: enabled && !dismissed,
|
||||||
|
refetchInterval: enabled && !dismissed ? 1500 : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelMutation = useMutation({
|
||||||
|
mutationFn: () => api.post(`/deployments/applications/${appId}/cancel`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Deployment cancelled');
|
||||||
|
setDismissed(true);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['deployments', appId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['build-progress', appId] });
|
||||||
|
},
|
||||||
|
onError: () => toast.error('Failed to cancel deployment'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const progress = data?.progress;
|
||||||
|
if (!enabled || dismissed || !progress || progress.phase === 'done') return null;
|
||||||
|
|
||||||
|
const cfg = phaseConfig[progress.phase];
|
||||||
|
const PhaseIcon = cfg.icon;
|
||||||
|
const isActive = progress.phase !== 'failed';
|
||||||
|
const showBytes = progress.phase === 'uploading' && progress.totalBytes;
|
||||||
|
const isCancelling = cancelMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="relative bg-white rounded-2xl shadow-2xl p-8 max-w-md w-full mx-4 space-y-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => cancelMutation.mutate()}
|
||||||
|
disabled={isCancelling || progress.phase === 'failed'}
|
||||||
|
className="absolute top-4 right-4 p-1.5 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors disabled:opacity-50"
|
||||||
|
aria-label="Cancel and close"
|
||||||
|
title="Cancel deployment"
|
||||||
|
>
|
||||||
|
{isCancelling ? <Loader2 className="w-5 h-5 animate-spin" /> : <X className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
{isActive ? (
|
||||||
|
<Loader2 className="w-12 h-12 text-primary-600 mx-auto mb-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<PhaseIcon className="w-12 h-12 text-red-500 mx-auto mb-3" />
|
||||||
|
)}
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{cfg.label}</h3>
|
||||||
|
{progress.message && (
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{progress.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isActive && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm text-gray-600">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<PhaseIcon className="w-4 h-4" />
|
||||||
|
{progress.percent}%
|
||||||
|
</span>
|
||||||
|
{showBytes && (
|
||||||
|
<span>{formatBytes(progress.bytesUploaded)} / {formatBytes(progress.totalBytes)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full ${cfg.bg} rounded-full transition-all duration-500 ease-out`}
|
||||||
|
style={{ width: `${progress.percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{progress.phase === 'failed' && progress.message && (
|
||||||
|
<p className="text-sm text-red-600 text-center">{progress.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isActive && (
|
||||||
|
<p className="text-xs text-gray-400 text-center">
|
||||||
|
Click the close button to cancel and remove cluster build resources.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user