Restore the preview host and its SSL when a custom domain is removed.

Removing a custom domain left the Ingress with a stale custom-domain rule
and no preview host, so the platform preview URL 404'd. updateIngress now
resolves the app's stable preview number from its latest deployment and
re-emits the preview host (with cert-manager TLS) whenever no verified
custom domain is set, and removeCustomDomain re-applies the Ingress.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-02 16:25:43 +03:30
parent 0b08b995f0
commit 6df11b738f
3 changed files with 50 additions and 4 deletions
@@ -162,6 +162,22 @@ export class DomainService {
const saved = await this.appRepo.save(app); const saved = await this.appRepo.save(app);
this.logger.log(`Custom domain removed for ${app.name}`); this.logger.log(`Custom domain removed for ${app.name}`);
// Re-apply the Ingress so the platform preview host (e.g.
// <prefix>-<num>.3fase.ir) is restored and routed again — and cert-manager
// (re)issues its TLS cert. Without this the Ingress would keep the now-stale
// custom-domain rule and serve a 404 on the preview URL. Best-effort: a
// failure must not fail the remove call; the next deploy re-applies it too.
try {
await this.kubernetesService.updateIngress(saved);
this.logger.log(`Ingress restored to preview host for ${app.name}`);
} catch (ingressErr: any) {
this.logger.warn(
`Custom domain removed but ingress restore failed for ${app.name}: ${ingressErr.message}. ` +
`It will be retried on the next deployment.`,
);
}
return saved; return saved;
} }
+2 -1
View File
@@ -8,9 +8,10 @@ import { ElasticsearchController } from './elasticsearch.controller';
import { LogsController } from './logs.controller'; import { LogsController } from './logs.controller';
import { ClustersModule } from '../clusters/clusters.module'; import { ClustersModule } from '../clusters/clusters.module';
import { Application } from '../applications/entities/application.entity'; import { Application } from '../applications/entities/application.entity';
import { Deployment } from '../deployments/entities/deployment.entity';
@Module({ @Module({
imports: [forwardRef(() => ClustersModule), TypeOrmModule.forFeature([Application])], imports: [forwardRef(() => ClustersModule), TypeOrmModule.forFeature([Application, Deployment])],
controllers: [ElasticsearchController, LogsController], controllers: [ElasticsearchController, LogsController],
providers: [KubernetesService, HelmService, RegistryService, ElasticsearchService], providers: [KubernetesService, HelmService, RegistryService, ElasticsearchService],
exports: [KubernetesService, HelmService, RegistryService, ElasticsearchService], exports: [KubernetesService, HelmService, RegistryService, ElasticsearchService],
+32 -3
View File
@@ -1,5 +1,7 @@
import { Injectable, Logger, OnModuleInit, BadRequestException } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
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';
@@ -8,6 +10,7 @@ import { promisify } from 'util';
import { PassThrough } from 'stream'; import { PassThrough } from 'stream';
import { ClustersService } from '../clusters/clusters.service'; import { ClustersService } from '../clusters/clusters.service';
import { Application } from '../applications/entities/application.entity'; import { Application } from '../applications/entities/application.entity';
import { Deployment } from '../deployments/entities/deployment.entity';
import { ensureAppUrlEnv } from '../applications/app-url.util'; import { ensureAppUrlEnv } from '../applications/app-url.util';
import { import {
AppRuntime, AppRuntime,
@@ -74,8 +77,27 @@ export class KubernetesService implements OnModuleInit {
private clustersService: ClustersService, private clustersService: ClustersService,
private helmService: HelmService, private helmService: HelmService,
private registryService: RegistryService, private registryService: RegistryService,
@InjectRepository(Deployment)
private deploymentsRepository: Repository<Deployment>,
) {} ) {}
/**
* Stable per-app preview number, reused from the latest deployment that has
* one. Used to restore the preview host on the Ingress (e.g. after a custom
* domain is removed) without triggering a fresh deploy.
*/
private async resolvePreviewNumber(applicationId: string): Promise<string | null> {
const existing = await this.deploymentsRepository
.createQueryBuilder('d')
.select('d.previewSubdomain', 'previewSubdomain')
.where('d.applicationId = :applicationId', { applicationId })
.andWhere('d.previewSubdomain IS NOT NULL')
.orderBy('d.createdAt', 'DESC')
.limit(1)
.getRawOne<{ previewSubdomain: string }>();
return existing?.previewSubdomain || null;
}
onModuleInit() { onModuleInit() {
// Helm chart is used for deployments — no local template loading needed // Helm chart is used for deployments — no local template loading needed
} }
@@ -456,13 +478,20 @@ export class KubernetesService implements OnModuleInit {
const customDomain = (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED) const customDomain = (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED)
? app.customDomain : undefined; ? app.customDomain : undefined;
// When there's no verified custom domain, restore the stable preview host so
// the app stays reachable (and keeps its TLS) — e.g. after a custom domain is
// removed. buildHelmValues only emits a preview host when there's no
// app.customDomain at all, so we only resolve it when none is set.
const previewNumber = app.customDomain
? null
: await this.resolvePreviewNumber(app.id);
try { try {
const kubeconfig = await this.getKubeconfig(app.clusterId); const kubeconfig = await this.getKubeconfig(app.clusterId);
const imageUri = app.latestImageTag const imageUri = app.latestImageTag
? this.registryService.normalizeImageReference(app.latestImageTag) ? this.registryService.normalizeImageReference(app.latestImageTag)
: ''; : '';
const values = this.buildHelmValues(app, imageUri); const values = this.buildHelmValues(app, imageUri, previewNumber);
// Preview host is managed per-deployment; Helm chart only needs the main ingress host here.
await this.helmService.installOrUpgrade(app.name, namespace, values, kubeconfig); await this.helmService.installOrUpgrade(app.name, namespace, values, kubeconfig);
this.logger.log(`Updated ingress for ${app.name} via Helm (customDomain: ${customDomain || 'none'})`); this.logger.log(`Updated ingress for ${app.name} via Helm (customDomain: ${customDomain || 'none'})`);
} catch (helmError: any) { } catch (helmError: any) {
@@ -498,7 +527,7 @@ export class KubernetesService implements OnModuleInit {
ownerId: app.userId, ownerId: app.userId,
applicationId: app.id, applicationId: app.id,
}; };
await this.applyIngress(networkingApi, ctx, customDomain); await this.applyIngress(networkingApi, ctx, customDomain, previewNumber);
this.logger.log(`Updated ingress for ${app.name} via K8s API (customDomain: ${customDomain || 'none'})`); this.logger.log(`Updated ingress for ${app.name} via K8s API (customDomain: ${customDomain || 'none'})`);
} }
} }