Add application migration workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-19 00:31:29 +03:30
parent fda8384a5c
commit 41a276d16d
14 changed files with 1145 additions and 6 deletions
@@ -0,0 +1,36 @@
CREATE TABLE IF NOT EXISTS application_migration_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"applicationId" UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
"requestedBy" UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
"sourceClusterId" UUID NOT NULL REFERENCES clusters(id) ON DELETE RESTRICT,
"targetClusterId" UUID NOT NULL REFERENCES clusters(id) ON DELETE RESTRICT,
status VARCHAR NOT NULL DEFAULT 'queued',
attempts INTEGER NOT NULL DEFAULT 0,
"maxAttempts" INTEGER NOT NULL DEFAULT 3,
"currentStep" VARCHAR,
"errorMessage" VARCHAR,
metadata JSONB,
"startedAt" TIMESTAMPTZ,
"completedAt" TIMESTAMPTZ,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_application_migration_jobs_app_created
ON application_migration_jobs("applicationId", "createdAt" DESC);
CREATE INDEX IF NOT EXISTS idx_application_migration_jobs_status
ON application_migration_jobs(status);
CREATE TABLE IF NOT EXISTS application_migration_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"migrationId" UUID NOT NULL REFERENCES application_migration_jobs(id) ON DELETE CASCADE,
step VARCHAR NOT NULL,
level VARCHAR NOT NULL DEFAULT 'info',
message VARCHAR NOT NULL,
metadata JSONB,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_application_migration_events_migration_created
ON application_migration_events("migrationId", "createdAt" ASC);
+2
View File
@@ -13,6 +13,7 @@ import { TicketsModule } from './tickets/tickets.module';
import { BillingModule } from './billing/billing.module';
import { SnapshotsModule } from './snapshots/snapshots.module';
import { LifecycleModule } from './lifecycle/lifecycle.module';
import { ApplicationMigrationsModule } from './application-migrations/application-migrations.module';
import configuration from './config/configuration';
@Module({
@@ -64,6 +65,7 @@ import configuration from './config/configuration';
BillingModule,
SnapshotsModule,
LifecycleModule,
ApplicationMigrationsModule,
],
})
export class AppModule {}
@@ -0,0 +1,51 @@
import { Body, Controller, Get, Param, Post, Query, Request, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApplicationMigrationsService } from './application-migrations.service';
import { CreateApplicationMigrationDto } from './dto/application-migration.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole } from '../common/enums';
@ApiTags('Application Migrations')
@ApiBearerAuth()
@Controller('application-migrations')
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles(UserRole.ADMIN)
export class ApplicationMigrationsController {
constructor(private readonly migrationsService: ApplicationMigrationsService) {}
@Post('applications/:applicationId')
@ApiOperation({ summary: 'Queue application migration to another cluster (Super Admin)' })
async create(
@Param('applicationId') applicationId: string,
@Request() req: any,
@Body() dto: CreateApplicationMigrationDto,
) {
return this.migrationsService.create(applicationId, req.user.id, dto);
}
@Get()
@ApiOperation({ summary: 'List application migration jobs (Super Admin)' })
async list(@Query('applicationId') applicationId?: string) {
return this.migrationsService.list(applicationId);
}
@Get(':id')
@ApiOperation({ summary: 'Get application migration details (Super Admin)' })
async findOne(@Param('id') id: string) {
return this.migrationsService.findOne(id);
}
@Get(':id/events')
@ApiOperation({ summary: 'Get application migration event log (Super Admin)' })
async events(@Param('id') id: string) {
return this.migrationsService.events(id);
}
@Post(':id/retry')
@ApiOperation({ summary: 'Retry failed application migration (Super Admin)' })
async retry(@Param('id') id: string, @Request() req: any) {
return this.migrationsService.retry(id, req.user.id);
}
}
@@ -0,0 +1,25 @@
import { Module, forwardRef } from '@nestjs/common';
import { BullModule } from '@nestjs/bull';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationMigrationsController } from './application-migrations.controller';
import { ApplicationMigrationsProcessor } from './application-migrations.processor';
import { ApplicationMigrationsService } from './application-migrations.service';
import { ApplicationMigrationJob } from './entities/application-migration-job.entity';
import { ApplicationMigrationEvent } from './entities/application-migration-event.entity';
import { ApplicationsModule } from '../applications/applications.module';
import { ClustersModule } from '../clusters/clusters.module';
import { KubernetesModule } from '../kubernetes/kubernetes.module';
@Module({
imports: [
TypeOrmModule.forFeature([ApplicationMigrationJob, ApplicationMigrationEvent]),
BullModule.registerQueue({ name: 'application-migrations' }),
forwardRef(() => ApplicationsModule),
ClustersModule,
KubernetesModule,
],
controllers: [ApplicationMigrationsController],
providers: [ApplicationMigrationsService, ApplicationMigrationsProcessor],
exports: [ApplicationMigrationsService],
})
export class ApplicationMigrationsModule {}
@@ -0,0 +1,13 @@
import { Process, Processor } from '@nestjs/bull';
import { Job } from 'bull';
import { ApplicationMigrationsService } from './application-migrations.service';
@Processor('application-migrations')
export class ApplicationMigrationsProcessor {
constructor(private readonly migrationsService: ApplicationMigrationsService) {}
@Process('run')
async run(job: Job<{ migrationId: string }>): Promise<void> {
await this.migrationsService.process(job.data.migrationId);
}
}
@@ -0,0 +1,263 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bull';
import { InjectRepository } from '@nestjs/typeorm';
import { Queue } from 'bull';
import { Repository } from 'typeorm';
import { ApplicationMigrationJob } from './entities/application-migration-job.entity';
import { ApplicationMigrationEvent } from './entities/application-migration-event.entity';
import { CreateApplicationMigrationDto } from './dto/application-migration.dto';
import { ApplicationsService } from '../applications/applications.service';
import { ClustersService } from '../clusters/clusters.service';
import { KubernetesService } from '../kubernetes/kubernetes.service';
import { ClusterStatus } from '../common/enums';
@Injectable()
export class ApplicationMigrationsService {
constructor(
@InjectRepository(ApplicationMigrationJob)
private readonly jobsRepository: Repository<ApplicationMigrationJob>,
@InjectRepository(ApplicationMigrationEvent)
private readonly eventsRepository: Repository<ApplicationMigrationEvent>,
@InjectQueue('application-migrations')
private readonly queue: Queue,
private readonly applicationsService: ApplicationsService,
private readonly clustersService: ClustersService,
private readonly kubernetesService: KubernetesService,
) {}
async create(
applicationId: string,
requestedBy: string,
dto: CreateApplicationMigrationDto,
): Promise<ApplicationMigrationJob> {
const app = await this.applicationsService.findOne(applicationId);
if (!app.clusterId) {
throw new BadRequestException('Application does not have a source cluster assignment');
}
if (app.clusterId === dto.targetClusterId) {
throw new BadRequestException('Target cluster must be different from the source cluster');
}
const target = await this.clustersService.findOne(dto.targetClusterId);
if (target.status !== ClusterStatus.ACTIVE || target.healthStatus !== 'healthy') {
throw new BadRequestException('Target cluster must be active and healthy before migration');
}
const activeJob = await this.jobsRepository.findOne({
where: [
{ applicationId, status: 'queued' },
{ applicationId, status: 'running' },
{ applicationId, status: 'rolling_back' },
],
order: { createdAt: 'DESC' },
});
if (activeJob) {
throw new BadRequestException('A migration is already queued or running for this application');
}
const job = await this.jobsRepository.save(this.jobsRepository.create({
applicationId,
requestedBy,
sourceClusterId: app.clusterId,
targetClusterId: dto.targetClusterId,
status: 'queued',
maxAttempts: 3,
metadata: {
note: dto.note,
migrateStorage: dto.migrateStorage !== false,
},
}));
await this.log(job.id, 'queued', 'Migration job queued', { targetClusterId: dto.targetClusterId });
await this.queue.add('run', { migrationId: job.id }, {
attempts: 1,
removeOnComplete: true,
removeOnFail: false,
});
return this.findOne(job.id);
}
async list(applicationId?: string): Promise<ApplicationMigrationJob[]> {
return this.jobsRepository.find({
where: applicationId ? { applicationId } : {},
relations: ['application', 'sourceCluster', 'targetCluster'],
order: { createdAt: 'DESC' },
take: 100,
});
}
async findOne(id: string): Promise<ApplicationMigrationJob> {
const job = await this.jobsRepository.findOne({
where: { id },
relations: ['application', 'sourceCluster', 'targetCluster'],
});
if (!job) {
throw new NotFoundException('Migration job not found');
}
return job;
}
async events(id: string): Promise<ApplicationMigrationEvent[]> {
await this.findOne(id);
return this.eventsRepository.find({
where: { migrationId: id },
order: { createdAt: 'ASC' },
});
}
async retry(id: string, requestedBy: string): Promise<ApplicationMigrationJob> {
const job = await this.findOne(id);
if (!['failed', 'rolled_back'].includes(job.status)) {
throw new BadRequestException('Only failed or rolled back migrations can be retried');
}
if (job.attempts >= job.maxAttempts) {
throw new BadRequestException('Migration retry limit reached');
}
await this.jobsRepository.update(id, {
status: 'queued',
errorMessage: null,
currentStep: 'queued',
completedAt: null,
metadata: {
...(job.metadata || {}),
retriedBy: requestedBy,
retriedAt: new Date().toISOString(),
} as any,
});
await this.log(id, 'queued', 'Migration retry queued', { requestedBy });
await this.queue.add('run', { migrationId: id }, {
attempts: 1,
removeOnComplete: true,
removeOnFail: false,
});
return this.findOne(id);
}
async process(migrationId: string): Promise<void> {
const job = await this.findOne(migrationId);
const app = await this.applicationsService.findOne(job.applicationId);
const sourceClusterId = job.sourceClusterId;
const targetClusterId = job.targetClusterId;
const sourceApp = { ...app, clusterId: sourceClusterId };
const targetApp = { ...app, clusterId: targetClusterId };
await this.updateJob(job.id, 'running', 'validate-target', { attempts: job.attempts + 1, startedAt: new Date() });
try {
await this.validateTargetCluster(targetClusterId);
await this.log(job.id, 'validate-target', 'Target cluster is active and healthy', { targetClusterId });
await this.updateJob(job.id, 'running', 'transfer-secrets-configs');
await this.kubernetesService.prepareApplicationMigration(sourceApp as any, targetClusterId, {
migrateStorage: job.metadata?.migrateStorage !== false,
log: (step, message, metadata) => this.log(job.id, step, message, metadata),
});
if (!app.latestImageTag) {
throw new BadRequestException('Application must have a deployed image before migration');
}
await this.updateJob(job.id, 'running', 'deploy-target');
await this.log(job.id, 'deploy-target', 'Deploying application on target cluster before cutover');
const targetResources = await this.kubernetesService.deployApplication(targetApp as any, app.latestImageTag);
await this.updateJob(job.id, 'running', 'health-check');
await this.kubernetesService.waitForApplicationReady(targetApp as any, 600_000);
await this.log(job.id, 'health-check', 'Target deployment is healthy');
await this.updateJob(job.id, 'running', 'update-ingress');
await this.kubernetesService.updateIngress(targetApp as any);
await this.log(job.id, 'update-ingress', 'Ingress/load balancer updated on target cluster');
await this.updateJob(job.id, 'running', 'cutover');
await this.applicationsService.updateClusterAssignment(app.id, targetClusterId, app.poolId);
await this.log(job.id, 'cutover', 'Application traffic cut over to target cluster', {
sourceClusterId,
targetClusterId,
});
await this.updateJob(job.id, 'running', 'cleanup-source');
await this.kubernetesService.deleteApplication(sourceApp as any);
await this.log(job.id, 'cleanup-source', 'Source cluster deployment removed after successful migration');
await this.jobsRepository.update(job.id, {
status: 'completed',
currentStep: 'completed',
completedAt: new Date(),
metadata: {
...(job.metadata || {}),
targetResources,
},
});
await this.log(job.id, 'completed', 'Migration completed successfully');
} catch (error: any) {
await this.handleFailure(job.id, sourceApp, targetApp, error);
throw error;
}
}
private async validateTargetCluster(targetClusterId: string): Promise<void> {
const target = await this.clustersService.findOne(targetClusterId);
if (target.status !== ClusterStatus.ACTIVE || target.healthStatus !== 'healthy') {
throw new BadRequestException('Target cluster is not active and healthy');
}
}
private async handleFailure(jobId: string, sourceApp: any, targetApp: any, error: any): Promise<void> {
const message = error?.message || 'Migration failed';
await this.jobsRepository.update(jobId, {
status: 'rolling_back',
currentStep: 'rollback',
errorMessage: message,
});
await this.log(jobId, 'rollback', 'Migration failed; rolling back target resources', { error: message }, 'error');
try {
await this.kubernetesService.deleteApplication(targetApp);
await this.applicationsService.updateClusterAssignment(sourceApp.id, sourceApp.clusterId, sourceApp.poolId);
await this.jobsRepository.update(jobId, {
status: 'rolled_back',
currentStep: 'rolled_back',
completedAt: new Date(),
});
await this.log(jobId, 'rolled_back', 'Rollback completed; source cluster assignment preserved');
} catch (rollbackError: any) {
await this.jobsRepository.update(jobId, {
status: 'failed',
currentStep: 'failed',
completedAt: new Date(),
errorMessage: `${message}; rollback failed: ${rollbackError.message}`,
});
await this.log(jobId, 'failed', `Rollback failed: ${rollbackError.message}`, undefined, 'error');
}
}
private async updateJob(
id: string,
status: ApplicationMigrationJob['status'],
currentStep: string,
extra: Partial<ApplicationMigrationJob> = {},
): Promise<void> {
await this.jobsRepository.update(id, {
status,
currentStep,
...extra,
});
}
private async log(
migrationId: string,
step: string,
message: string,
metadata?: Record<string, any>,
level: 'info' | 'warn' | 'error' = 'info',
): Promise<void> {
await this.eventsRepository.save(this.eventsRepository.create({
migrationId,
step,
message,
metadata,
level,
}));
}
}
@@ -0,0 +1,18 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateApplicationMigrationDto {
@ApiProperty({ description: 'Destination Kubernetes cluster ID' })
@IsUUID()
targetClusterId: string;
@ApiPropertyOptional({ description: 'Optional operator note for audit logs' })
@IsOptional()
@IsString()
note?: string;
@ApiPropertyOptional({ description: 'Copy PVC definitions and attempt provider-neutral storage preparation' })
@IsOptional()
@IsBoolean()
migrateStorage?: boolean;
}
@@ -0,0 +1,37 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { ApplicationMigrationJob } from './application-migration-job.entity';
@Entity('application_migration_events')
export class ApplicationMigrationEvent {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
migrationId: string;
@ManyToOne(() => ApplicationMigrationJob, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'migrationId' })
migration: ApplicationMigrationJob;
@Column()
step: string;
@Column({ default: 'info' })
level: 'info' | 'warn' | 'error';
@Column()
message: string;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any>;
@CreateDateColumn()
createdAt: Date;
}
@@ -0,0 +1,79 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Application } from '../../applications/entities/application.entity';
import { Cluster } from '../../clusters/entities/cluster.entity';
export type ApplicationMigrationStatus =
| 'queued'
| 'running'
| 'completed'
| 'failed'
| 'rolling_back'
| 'rolled_back';
@Entity('application_migration_jobs')
export class ApplicationMigrationJob {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
applicationId: string;
@ManyToOne(() => Application, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'applicationId' })
application: Application;
@Column()
requestedBy: string;
@Column({ type: 'uuid' })
sourceClusterId: string;
@ManyToOne(() => Cluster, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'sourceClusterId' })
sourceCluster: Cluster;
@Column({ type: 'uuid' })
targetClusterId: string;
@ManyToOne(() => Cluster, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'targetClusterId' })
targetCluster: Cluster;
@Column({ default: 'queued' })
status: ApplicationMigrationStatus;
@Column({ default: 0 })
attempts: number;
@Column({ default: 3 })
maxAttempts: number;
@Column({ type: 'varchar', nullable: true })
currentStep: string | null;
@Column({ type: 'varchar', nullable: true })
errorMessage: string | null;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any> | null;
@Column({ type: 'timestamptz', nullable: true })
startedAt: Date | null;
@Column({ type: 'timestamptz', nullable: true })
completedAt: Date | null;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
+7 -1
View File
@@ -22,6 +22,7 @@ import { CreateClusterPoolDto, UpdateClusterPoolDto } from './dto/cluster-pool.d
import { ClusterStatus } from '../common/enums';
import { ElasticsearchService } from '../kubernetes/elasticsearch.service';
import { CreateApplicationDto } from '../applications/dto/application.dto';
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
@Injectable()
export class ClustersService implements OnModuleInit, OnModuleDestroy {
@@ -77,8 +78,11 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
*/
async testConnection(kubeconfig: string): Promise<{ connected: boolean; version?: string; error?: string }> {
try {
const decryptedKubeconfig = this.decryptKubeconfig(kubeconfig);
registerKubeconfigNoProxy(decryptedKubeconfig);
const kc = new k8s.KubeConfig();
kc.loadFromString(this.decryptKubeconfig(kubeconfig));
kc.loadFromString(decryptedKubeconfig);
const versionApi = kc.makeApiClient(k8s.VersionApi);
const result = await versionApi.getCode();
@@ -702,6 +706,7 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
}
const kc = new k8s.KubeConfig();
registerKubeconfigNoProxy(cluster.kubeconfig);
kc.loadFromString(cluster.kubeconfig);
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
@@ -825,6 +830,7 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
*/
async bootstrapCluster(kubeconfig: string): Promise<void> {
const kc = new k8s.KubeConfig();
registerKubeconfigNoProxy(kubeconfig);
kc.loadFromString(kubeconfig);
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
const appsApi = kc.makeApiClient(k8s.AppsV1Api);
@@ -0,0 +1,42 @@
import * as k8s from '@kubernetes/client-node';
function normalizeNoProxyValue(value?: string): string[] {
return (value || '')
.split(',')
.map((entry) => entry.trim().replace(/^['"]|['"]$/g, ''))
.filter(Boolean);
}
function appendNoProxyEntries(envKey: 'NO_PROXY' | 'no_proxy', entries: string[]): void {
const existing = normalizeNoProxyValue(process.env[envKey]);
if (existing.includes('*')) {
return;
}
const merged = new Set(existing);
for (const entry of entries) {
merged.add(entry);
}
process.env[envKey] = Array.from(merged).join(',');
}
export function registerKubeconfigNoProxy(kubeconfig: string): string[] {
const kc = new k8s.KubeConfig();
kc.loadFromString(kubeconfig);
const cluster = kc.getCurrentCluster() || kc.getClusters()[0];
if (!cluster?.server) {
return [];
}
const url = new URL(cluster.server);
const entries = new Set<string>();
entries.add(url.hostname);
entries.add(url.host);
appendNoProxyEntries('NO_PROXY', Array.from(entries));
appendNoProxyEntries('no_proxy', Array.from(entries));
return Array.from(entries);
}
+309 -3
View File
@@ -11,6 +11,7 @@ import { Application } from '../applications/entities/application.entity';
import { ensureAppUrlEnv } from '../applications/app-url.util';
import { AppRuntime, DatabaseType, CustomDomainStatus, ServiceAccessTarget } from '../common/enums';
import { HelmService } from './helm.service';
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
const execFileAsync = promisify(execFile);
@@ -81,6 +82,7 @@ export class KubernetesService implements OnModuleInit {
: await this.clustersService.getDefault();
const kc = new k8s.KubeConfig();
registerKubeconfigNoProxy(cluster.kubeconfig);
kc.loadFromString(cluster.kubeconfig);
return {
@@ -218,7 +220,11 @@ export class KubernetesService implements OnModuleInit {
}
}
async waitForApplicationReady(app: Application, timeoutMs = 600_000): Promise<void> {
async waitForApplicationReady(
app: Application,
timeoutMs = 600_000,
shouldAbort?: () => Promise<boolean>,
): Promise<void> {
const { coreApi, appsApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const workloads = [
@@ -235,6 +241,10 @@ export class KubernetesService implements OnModuleInit {
);
while (Date.now() - start < timeoutMs) {
if (await shouldAbort?.()) {
throw new Error('Deployment cancelled by user');
}
const statuses = await Promise.all(
workloads.map((workload) => this.getDeploymentReadiness(appsApi, namespace, workload.name, workload.replicas)),
);
@@ -2046,6 +2056,16 @@ export class KubernetesService implements OnModuleInit {
}
}
private toDnsLabel(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 63)
.replace(/-$/g, '');
}
resolveAccessTarget(
app: Application,
target: ServiceAccessTarget,
@@ -2087,7 +2107,7 @@ export class KubernetesService implements OnModuleInit {
const namespace = this.getUserNamespace(app.userId);
const { selector, targetPort, portName } = this.resolveAccessTarget(app, target);
const shortId = grantId.split('-')[0];
const k8sServiceName = `${app.name}-${target}-access-${shortId}`.slice(0, 63);
const k8sServiceName = this.toDnsLabel(`${app.name}-${target}-access-${shortId}`);
const portSpec: k8s.V1ServicePort = {
port: targetPort,
@@ -2116,7 +2136,16 @@ export class KubernetesService implements OnModuleInit {
},
};
const created = await coreApi.createNamespacedService(namespace, service);
let created: { body: k8s.V1Service };
try {
created = await coreApi.createNamespacedService(namespace, service);
} catch (error: any) {
const message = error.body?.message || error.response?.body?.message || error.message || 'HTTP request failed';
this.logger.warn(
`K8s client failed to create temporary access service ${k8sServiceName}; trying kubectl fallback: ${message}`,
);
created = { body: await this.applyServiceWithKubectl(app.clusterId, namespace, service) };
}
const nodePort = created.body.spec?.ports?.[0]?.nodePort;
if (!nodePort) {
try {
@@ -2133,6 +2162,50 @@ export class KubernetesService implements OnModuleInit {
return { host, nodePort, k8sServiceName, targetPort };
}
private async applyServiceWithKubectl(
clusterId: string | undefined,
namespace: string,
service: k8s.V1Service,
): Promise<k8s.V1Service> {
const tmpDir = fs.mkdtempSync(path.join('/tmp', 'cloudhost-access-'));
const kubeconfigPath = path.join(tmpDir, 'kubeconfig.yaml');
const manifestPath = path.join(tmpDir, 'service.json');
try {
fs.writeFileSync(kubeconfigPath, await this.getKubeconfig(clusterId), { mode: 0o600 });
fs.writeFileSync(manifestPath, JSON.stringify(service), { mode: 0o600 });
await execFileAsync('kubectl', [
'--kubeconfig',
kubeconfigPath,
'apply',
'-n',
namespace,
'-f',
manifestPath,
]);
const { stdout } = await execFileAsync('kubectl', [
'--kubeconfig',
kubeconfigPath,
'get',
'service',
service.metadata!.name!,
'-n',
namespace,
'-o',
'json',
]);
return JSON.parse(stdout) as k8s.V1Service;
} catch (error: any) {
const message = error.stderr || error.message || 'kubectl failed';
throw new Error(`Failed to create temporary access service: ${message}`);
} finally {
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
}
}
async revokeTemporaryAccess(
clusterId: string,
namespace: string,
@@ -2341,6 +2414,239 @@ export class KubernetesService implements OnModuleInit {
this.logger.log(`All K8s resources cleaned up for ${app.name} in ${namespace}`);
}
async prepareApplicationMigration(
app: Application,
targetClusterId: string,
options: {
migrateStorage?: boolean;
log?: (step: string, message: string, metadata?: Record<string, any>) => Promise<void>;
} = {},
): Promise<void> {
const namespace = this.getUserNamespace(app.userId);
const source = await this.getK8sClient(app.clusterId);
const target = await this.getK8sClient(targetClusterId);
await this.ensureNamespaceOnCluster(target.coreApi, namespace);
await options.log?.('transfer-secrets-configs', 'Target namespace ensured', { namespace });
const secretNames = [
`${app.name}-env`,
`${app.name}-db-secret`,
`${app.name}-redis-secret`,
`${app.name}-rabbitmq-secret`,
`${app.name}-tls`,
];
for (const secretName of secretNames) {
try {
const secret = await source.coreApi.readNamespacedSecret(secretName, namespace);
await this.upsertSecret(target.coreApi, namespace, this.cleanK8sObject(secret.body));
await options.log?.('transfer-secrets-configs', `Secret ${secretName} copied`);
} catch (error: any) {
if (!this.isK8sNotFound(error)) {
throw error;
}
}
}
const configMapNames = [
`${app.name}-fluent-bit-config`,
];
for (const configMapName of configMapNames) {
try {
const configMap = await source.coreApi.readNamespacedConfigMap(configMapName, namespace);
await this.upsertConfigMap(target.coreApi, namespace, this.cleanK8sObject(configMap.body));
await options.log?.('transfer-secrets-configs', `ConfigMap ${configMapName} copied`);
} catch (error: any) {
if (!this.isK8sNotFound(error)) {
throw error;
}
}
}
if (options.migrateStorage !== false) {
const pvcNames = [
`${app.name}-storage`,
`${app.name}-wp-content`,
`${app.name}-db`,
`${app.name}-redis-data`,
`${app.name}-rabbitmq-data`,
];
for (const pvcName of pvcNames) {
try {
const pvc = await source.coreApi.readNamespacedPersistentVolumeClaim(pvcName, namespace);
await this.upsertPvcDefinition(target.coreApi, namespace, this.cleanPvcForMigration(pvc.body));
await options.log?.('transfer-volumes', `PVC definition ${pvcName} prepared on target cluster`, {
note: 'Starting best-effort PVC data copy through migration helper pods.',
});
await this.copyPvcDataBetweenClusters(app.clusterId!, targetClusterId, namespace, pvcName, options.log);
} catch (error: any) {
if (!this.isK8sNotFound(error)) {
throw error;
}
}
}
}
}
private async copyPvcDataBetweenClusters(
sourceClusterId: string,
targetClusterId: string,
namespace: string,
pvcName: string,
log?: (step: string, message: string, metadata?: Record<string, any>) => Promise<void>,
): Promise<void> {
const safeName = pvcName.replace(/[^a-z0-9-]/gi, '-').toLowerCase().slice(0, 32);
const suffix = `${Date.now()}`.slice(-6);
const sourcePod = `migrate-src-${safeName}-${suffix}`;
const targetPod = `migrate-dst-${safeName}-${suffix}`;
const tempDir = path.join('/tmp', `app-migration-${safeName}-${suffix}`);
const sourceKubeconfig = path.join(tempDir, 'source.kubeconfig');
const targetKubeconfig = path.join(tempDir, 'target.kubeconfig');
fs.mkdirSync(tempDir, { recursive: true });
fs.writeFileSync(sourceKubeconfig, await this.getKubeconfig(sourceClusterId), { mode: 0o600 });
fs.writeFileSync(targetKubeconfig, await this.getKubeconfig(targetClusterId), { mode: 0o600 });
try {
await this.createPvcCopyPod(sourceKubeconfig, namespace, sourcePod, pvcName);
await this.createPvcCopyPod(targetKubeconfig, namespace, targetPod, pvcName);
await log?.('transfer-volumes', `Copy helper pods ready for PVC ${pvcName}`);
const localDataPath = path.join(tempDir, 'data');
await execFileAsync('kubectl', ['--kubeconfig', sourceKubeconfig, '-n', namespace, 'cp', `${sourcePod}:/data`, localDataPath], {
timeout: 30 * 60 * 1000,
});
await execFileAsync('kubectl', ['--kubeconfig', targetKubeconfig, '-n', namespace, 'cp', `${localDataPath}/.`, `${targetPod}:/data`], {
timeout: 30 * 60 * 1000,
});
await log?.('transfer-volumes', `PVC data copied for ${pvcName}`);
} finally {
await execFileAsync('kubectl', ['--kubeconfig', sourceKubeconfig, '-n', namespace, 'delete', 'pod', sourcePod, '--ignore-not-found=true']).catch(() => undefined);
await execFileAsync('kubectl', ['--kubeconfig', targetKubeconfig, '-n', namespace, 'delete', 'pod', targetPod, '--ignore-not-found=true']).catch(() => undefined);
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
private async createPvcCopyPod(
kubeconfigPath: string,
namespace: string,
podName: string,
pvcName: string,
): Promise<void> {
const manifestPath = path.join('/tmp', `${podName}.json`);
const manifest = {
apiVersion: 'v1',
kind: 'Pod',
metadata: { name: podName, namespace },
spec: {
restartPolicy: 'Never',
containers: [{
name: 'copy',
image: 'busybox:1.36',
command: ['sh', '-c', 'mkdir -p /data && sleep 3600'],
volumeMounts: [{ name: 'data', mountPath: '/data' }],
}],
volumes: [{ name: 'data', persistentVolumeClaim: { claimName: pvcName } }],
},
};
fs.writeFileSync(manifestPath, JSON.stringify(manifest));
try {
await execFileAsync('kubectl', ['--kubeconfig', kubeconfigPath, 'apply', '-f', manifestPath], { timeout: 120_000 });
await execFileAsync('kubectl', ['--kubeconfig', kubeconfigPath, '-n', namespace, 'wait', '--for=condition=Ready', `pod/${podName}`, '--timeout=180s'], { timeout: 210_000 });
} finally {
fs.rmSync(manifestPath, { force: true });
}
}
private async ensureNamespaceOnCluster(coreApi: k8s.CoreV1Api, namespace: string): Promise<void> {
try {
await coreApi.readNamespace(namespace);
} catch (error: any) {
if (this.isK8sNotFound(error)) {
await coreApi.createNamespace({ metadata: { name: namespace } });
return;
}
throw error;
}
}
private async upsertSecret(coreApi: k8s.CoreV1Api, namespace: string, secret: k8s.V1Secret): Promise<void> {
secret.metadata = { ...(secret.metadata || {}), namespace };
try {
await coreApi.replaceNamespacedSecret(secret.metadata.name!, namespace, secret);
} catch (error: any) {
if (this.isK8sNotFound(error)) {
await coreApi.createNamespacedSecret(namespace, secret);
return;
}
throw error;
}
}
private async upsertConfigMap(coreApi: k8s.CoreV1Api, namespace: string, configMap: k8s.V1ConfigMap): Promise<void> {
configMap.metadata = { ...(configMap.metadata || {}), namespace };
try {
await coreApi.replaceNamespacedConfigMap(configMap.metadata.name!, namespace, configMap);
} catch (error: any) {
if (this.isK8sNotFound(error)) {
await coreApi.createNamespacedConfigMap(namespace, configMap);
return;
}
throw error;
}
}
private async upsertPvcDefinition(
coreApi: k8s.CoreV1Api,
namespace: string,
pvc: k8s.V1PersistentVolumeClaim,
): Promise<void> {
pvc.metadata = { ...(pvc.metadata || {}), namespace };
try {
await coreApi.readNamespacedPersistentVolumeClaim(pvc.metadata.name!, namespace);
} catch (error: any) {
if (this.isK8sNotFound(error)) {
await coreApi.createNamespacedPersistentVolumeClaim(namespace, pvc);
return;
}
throw error;
}
}
private cleanK8sObject<T extends { metadata?: k8s.V1ObjectMeta }>(obj: T): T {
const metadata = { ...(obj.metadata || {}) };
delete metadata.uid;
delete metadata.resourceVersion;
delete metadata.generation;
delete metadata.creationTimestamp;
delete metadata.managedFields;
delete metadata.selfLink;
return {
...obj,
metadata,
};
}
private cleanPvcForMigration(pvc: k8s.V1PersistentVolumeClaim): k8s.V1PersistentVolumeClaim {
const cleaned = this.cleanK8sObject(pvc);
return {
apiVersion: cleaned.apiVersion,
kind: cleaned.kind,
metadata: cleaned.metadata,
spec: {
accessModes: cleaned.spec?.accessModes,
resources: cleaned.spec?.resources,
storageClassName: cleaned.spec?.storageClassName,
volumeMode: cleaned.spec?.volumeMode,
},
};
}
private isK8sNotFound(error: any): boolean {
return error?.statusCode === 404 || error?.body?.code === 404;
}
/**
* Wait for the database pod to become Ready.
* Polls pod status with label selector `app=<appName>-db`.