Add managed databases and services with billing-aligned upgrades.
Introduce product types for managed PostgreSQL, Redis, and RabbitMQ with a dedicated dashboard, Helm-only deploy pipeline, external access, snapshots with progress, and prorated resource or storage upgrades matching application billing rules. PVCs use an expandable StorageClass with automatic migration when legacy disks cannot resize in place. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,7 +6,12 @@ import { Deployment } from './entities/deployment.entity';
|
||||
import { ApplicationsService } from '../applications/applications.service';
|
||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||
import { BuildService, BuildProgress, BuildCancelledError } from '../build/build.service';
|
||||
import { AppLifecycleStatus, DeploymentStatus } from '../common/enums';
|
||||
import {
|
||||
AppLifecycleStatus,
|
||||
DeploymentStatus,
|
||||
isManagedProductType,
|
||||
MANAGED_DEPLOY_MARKER,
|
||||
} from '../common/enums';
|
||||
import { ClustersService } from '../clusters/clusters.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -36,22 +41,111 @@ export class DeploymentsService {
|
||||
});
|
||||
const saved = await this.deploymentsRepository.save(deployment);
|
||||
|
||||
// Trigger async build & deploy pipeline
|
||||
this.executePipeline(saved.id, app).catch((error) => {
|
||||
// Trigger async pipeline (Helm-only for managed services, build+deploy for apps)
|
||||
const run = isManagedProductType(app.productType)
|
||||
? this.executeManagedPipeline(saved.id, app)
|
||||
: this.executePipeline(saved.id, app);
|
||||
run.catch((error) => {
|
||||
this.logger.error(`Pipeline failed for deployment ${saved.id}:`, error);
|
||||
});
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** Provision managed database/redis/rabbitmq via Helm only — no image build. */
|
||||
private async executeManagedPipeline(deploymentId: string, app: any): Promise<void> {
|
||||
try {
|
||||
await this.updateStatus(deploymentId, DeploymentStatus.DEPLOYING);
|
||||
this.buildService.setProgress(deploymentId, {
|
||||
phase: 'deploying',
|
||||
percent: 10,
|
||||
message: 'Provisioning service via Helm...',
|
||||
});
|
||||
|
||||
const hasDbDump = app.dbDumpPath && fs.existsSync(app.dbDumpPath);
|
||||
const { app: deployedApp, k8sResources } = await this.deployManagedWithClusterFallback(
|
||||
deploymentId,
|
||||
app,
|
||||
hasDbDump,
|
||||
);
|
||||
app = deployedApp;
|
||||
|
||||
if (hasDbDump) {
|
||||
this.logger.log(`Restoring DB dump for ${app.name} from ${app.dbDumpPath}`);
|
||||
try {
|
||||
await this.kubernetesService.waitForDatabaseReady(app, 120_000);
|
||||
const freshApp = await this.applicationsService.findOne(app.id);
|
||||
const result = await this.kubernetesService.restoreDatabaseDump(freshApp, freshApp.dbDumpPath!);
|
||||
if (result.success) {
|
||||
this.logger.log(`DB dump restored successfully for ${app.name}`);
|
||||
} else {
|
||||
this.logger.warn(`DB dump restore failed for ${app.name}: ${result.logs}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`DB dump restore error for ${app.name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.buildService.setProgress(deploymentId, {
|
||||
phase: 'deploying',
|
||||
percent: 96,
|
||||
message: 'Waiting for service pods to become ready...',
|
||||
});
|
||||
await this.kubernetesService.waitForApplicationReady(
|
||||
app,
|
||||
600_000,
|
||||
() => this.isDeploymentCancelled(deploymentId),
|
||||
);
|
||||
|
||||
if (await this.isDeploymentCancelled(deploymentId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.applicationsService.updateImageTag(app.id, MANAGED_DEPLOY_MARKER);
|
||||
|
||||
this.buildService.setProgress(deploymentId, {
|
||||
phase: 'done',
|
||||
percent: 100,
|
||||
message: 'Service provisioned',
|
||||
});
|
||||
await this.deploymentsRepository.update(deploymentId, {
|
||||
status: DeploymentStatus.RUNNING,
|
||||
k8sResources,
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (this.isCancellationError(error) || (await this.isDeploymentCancelled(deploymentId))) {
|
||||
this.logger.log(`Deployment ${deploymentId} cancelled by user`);
|
||||
await this.deploymentsRepository.update(deploymentId, {
|
||||
status: DeploymentStatus.CANCELLED,
|
||||
errorMessage: 'Cancelled by user',
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.logger.error(`Managed deployment ${deploymentId} failed:`, error);
|
||||
this.buildService.setProgress(deploymentId, {
|
||||
phase: 'failed',
|
||||
percent: 0,
|
||||
message: error.message || 'Provisioning failed',
|
||||
});
|
||||
await this.deploymentsRepository.update(deploymentId, {
|
||||
status: DeploymentStatus.FAILED,
|
||||
errorMessage: error.message,
|
||||
finishedAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async executePipeline(deploymentId: string, app: any): Promise<void> {
|
||||
try {
|
||||
// Step 1: Build image
|
||||
await this.updateStatus(deploymentId, DeploymentStatus.BUILDING);
|
||||
const { imageUri, buildLog } = await this.buildService.buildImage(app, deploymentId);
|
||||
const buildResult = await this.buildService.buildImage(app, deploymentId);
|
||||
const imageUri = buildResult.imageUri;
|
||||
|
||||
// Save build log
|
||||
await this.deploymentsRepository.update(deploymentId, { buildLog });
|
||||
await this.deploymentsRepository.update(deploymentId, { buildLog: buildResult.buildLog });
|
||||
|
||||
// Step 2: Update app with new image tag
|
||||
await this.applicationsService.updateImageTag(app.id, imageUri);
|
||||
@@ -149,6 +243,74 @@ export class DeploymentsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async deployManagedWithClusterFallback(
|
||||
deploymentId: string,
|
||||
app: any,
|
||||
hasDbDump: boolean,
|
||||
): Promise<{ app: any; k8sResources: Record<string, any> }> {
|
||||
const failedClusterIds: string[] = [];
|
||||
let currentApp = app;
|
||||
let lastError: any;
|
||||
const maxAttempts = Number(process.env.CLUSTER_DEPLOY_FALLBACK_ATTEMPTS || 3);
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
if (await this.isDeploymentCancelled(deploymentId)) {
|
||||
throw new Error('Deployment cancelled by user');
|
||||
}
|
||||
|
||||
try {
|
||||
this.buildService.setProgress(deploymentId, {
|
||||
phase: 'deploying',
|
||||
percent: Math.min(20 + attempt * 5, 90),
|
||||
message:
|
||||
attempt === 1
|
||||
? 'Installing Helm release...'
|
||||
: `Retrying Helm install on fallback cluster (${attempt}/${maxAttempts})...`,
|
||||
});
|
||||
|
||||
const k8sResources = await this.kubernetesService.deployManagedService(currentApp);
|
||||
return { app: currentApp, k8sResources };
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
failedClusterIds.push(currentApp.clusterId);
|
||||
const failureMessage = error?.message || 'Helm provisioning failed on selected cluster';
|
||||
await this.clustersService.markAllocationFailure(currentApp.id, currentApp.clusterId, failureMessage);
|
||||
|
||||
if (attempt >= maxAttempts) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const fallback = await this.clustersService.chooseFallbackClusterForApplication(
|
||||
currentApp,
|
||||
failedClusterIds,
|
||||
failureMessage,
|
||||
);
|
||||
const updatedApp = await this.applicationsService.updateClusterAssignment(
|
||||
currentApp.id,
|
||||
fallback.cluster.id,
|
||||
fallback.pool?.id,
|
||||
);
|
||||
await this.clustersService.attachAllocationToApplication(fallback.allocationLogId, currentApp.id);
|
||||
this.logger.warn(
|
||||
`Managed deployment ${deploymentId} falling back from cluster ${currentApp.clusterId || 'none'} to ${fallback.cluster.id}`,
|
||||
);
|
||||
currentApp = {
|
||||
...currentApp,
|
||||
...updatedApp,
|
||||
clusterId: fallback.cluster.id,
|
||||
poolId: fallback.pool?.id || currentApp.poolId,
|
||||
};
|
||||
} catch (fallbackError: any) {
|
||||
lastError = fallbackError;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Managed service provisioning failed');
|
||||
}
|
||||
|
||||
private async deployWithClusterFallback(
|
||||
deploymentId: string,
|
||||
app: any,
|
||||
@@ -174,7 +336,10 @@ export class DeploymentsService {
|
||||
: `Retrying deployment on fallback cluster (${attempt}/${maxAttempts})...`,
|
||||
});
|
||||
|
||||
const deployApp = hasDbDump ? { ...currentApp, replicas: 0 } : currentApp;
|
||||
const deployApp =
|
||||
hasDbDump && !isManagedProductType(currentApp.productType)
|
||||
? { ...currentApp, replicas: 0 }
|
||||
: currentApp;
|
||||
const k8sResources = await this.kubernetesService.deployApplication(deployApp, imageUri);
|
||||
return { app: currentApp, k8sResources };
|
||||
} catch (error: any) {
|
||||
@@ -231,6 +396,12 @@ export class DeploymentsService {
|
||||
String(error?.message || '').toLowerCase().includes('cancelled');
|
||||
}
|
||||
|
||||
/** Managed DB/Redis/RabbitMQ or rows already provisioned via Helm without an app image build. */
|
||||
private isManagedOrHelmOnlyApp(app: { productType?: string; latestImageTag?: string }): boolean {
|
||||
if (isManagedProductType(app.productType)) return true;
|
||||
return app.latestImageTag === MANAGED_DEPLOY_MARKER;
|
||||
}
|
||||
|
||||
private ensureRedeployAllowed(app: any): void {
|
||||
if (!app.billingCycle) return;
|
||||
|
||||
@@ -267,8 +438,7 @@ export class DeploymentsService {
|
||||
}
|
||||
|
||||
async getBuildLogs(applicationId: string, userId: string): Promise<{ buildLog: string | null; status: string; version: string | null; createdAt: Date }> {
|
||||
// Verify user access
|
||||
await this.applicationsService.findOne(applicationId, userId);
|
||||
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||
|
||||
const latest = await this.deploymentsRepository.findOne({
|
||||
where: { applicationId },
|
||||
@@ -279,6 +449,15 @@ export class DeploymentsService {
|
||||
return { buildLog: null, status: 'no_deployment', version: null, createdAt: new Date() };
|
||||
}
|
||||
|
||||
if (this.isManagedOrHelmOnlyApp(app)) {
|
||||
return {
|
||||
buildLog: null,
|
||||
status: latest.status,
|
||||
version: latest.version,
|
||||
createdAt: latest.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
buildLog: latest.buildLog || null,
|
||||
status: latest.status,
|
||||
@@ -288,7 +467,8 @@ export class DeploymentsService {
|
||||
}
|
||||
|
||||
async getBuildProgress(applicationId: string, userId: string): Promise<BuildProgress | null> {
|
||||
await this.applicationsService.findOne(applicationId, userId);
|
||||
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||
const managed = this.isManagedOrHelmOnlyApp(app);
|
||||
|
||||
const latest = await this.deploymentsRepository.findOne({
|
||||
where: { applicationId },
|
||||
@@ -311,10 +491,18 @@ export class DeploymentsService {
|
||||
return { phase: 'cancelled', percent: 0, message: latest.errorMessage || 'Cancelled by user' };
|
||||
}
|
||||
if (latest.status === DeploymentStatus.BUILDING) {
|
||||
return { phase: 'building', percent: 0, message: 'Building...' };
|
||||
return {
|
||||
phase: managed ? 'deploying' : 'building',
|
||||
percent: 0,
|
||||
message: managed ? 'Provisioning...' : 'Building...',
|
||||
};
|
||||
}
|
||||
if (latest.status === DeploymentStatus.DEPLOYING) {
|
||||
return { phase: 'deploying', percent: 90, message: 'Deploying...' };
|
||||
return {
|
||||
phase: 'deploying',
|
||||
percent: 90,
|
||||
message: managed ? 'Provisioning via Helm...' : 'Deploying...',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -412,6 +600,11 @@ export class DeploymentsService {
|
||||
async redeployApplication(applicationId: string, userId: string): Promise<Deployment> {
|
||||
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||
|
||||
if (this.isManagedOrHelmOnlyApp(app)) {
|
||||
this.logger.log(`Re-provisioning ${app.name} via Helm (no build)`);
|
||||
return this.triggerDeployment(applicationId, userId);
|
||||
}
|
||||
|
||||
if (!app.codePath && !app.gitUrl) {
|
||||
throw new NotFoundException('No source code available. Upload code or set a git URL first.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user