Serve preview URLs over Traefik+TLS, stabilize them, and speed up builds.

Ingress / preview URLs:
- Default the app Ingress class and ACME HTTP-01 solver to Traefik
  (k3s default) via a new INGRESS_CLASS env, instead of hardcoding nginx —
  fixes 404s on clusters without ingress-nginx.
- Only put public, real-TLD hosts (custom domain + preview) in the TLS
  block; the internal *.apps.cloudhost.local host no longer poisons the
  Let's Encrypt order, so certs actually issue.
- Make the per-app preview number stable across redeploys so URLs stop
  breaking, and let PREVIEW_BASE_DOMAIN configure the base domain.

Registry pulls:
- Point the k3s registries.yaml mirror endpoint at the registry NodePort on
  loopback so node containerd never depends on cluster DNS (image pulls
  survive node restarts).

Builds:
- Pin the Kaniko image, use IfNotPresent pull policy, drop the dead build
  queue/processor, and retry transient Kubernetes API errors while polling
  build jobs.

Logs & apps list:
- fluent-bit reads log files from head so startup output reaches
  Elasticsearch.
- Order joined deployments newest-first so the apps list shows the latest
  deployment status.

Allocation:
- Reserve in-flight (pending/building) capacity and stop globally degrading
  the cluster on a single allocation failure, so concurrent deploys don't
  starve or wrongly report "no healthy cluster".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-02 15:44:08 +03:30
parent 786689e0fd
commit 4301277b48
13 changed files with 244 additions and 109 deletions
+6
View File
@@ -43,6 +43,12 @@ BUILD_SERVICE_ACCOUNT=kaniko-builder
# Platform # Platform
PLATFORM_DOMAIN=apps.cloudhost.local PLATFORM_DOMAIN=apps.cloudhost.local
# Base domain for per-user preview URLs (<userId>-<7-digit>.<base-domain>).
# Falls back to the root domain derived from FRONTEND_URL when unset.
# PREVIEW_BASE_DOMAIN=3fase.ir
# Ingress controller class for app Ingress + ACME HTTP-01 solver.
# k3s default is Traefik; use "nginx" only on clusters running ingress-nginx.
# INGRESS_CLASS=traefik
UPLOAD_DIR=./uploads UPLOAD_DIR=./uploads
# PVC resize: use a dynamic StorageClass with allowVolumeExpansion (k3s: rancher.io/local-path) # PVC resize: use a dynamic StorageClass with allowVolumeExpansion (k3s: rancher.io/local-path)
# k3s: use local-path and skip creating a custom class (set CREATE=false) # k3s: use local-path and skip creating a custom class (set CREATE=false)
@@ -26,7 +26,9 @@ spec:
containers: containers:
- name: {{ $name }} - name: {{ $name }}
image: {{ .Values.app.image | quote }} image: {{ .Values.app.image | quote }}
imagePullPolicy: Always # Image tags are unique per build (name:timestamp) and immutable —
# IfNotPresent avoids re-pulling on every restart/scale-up.
imagePullPolicy: IfNotPresent
{{- if include "cloudhost-app.loggingWrapEnabled" . }} {{- if include "cloudhost-app.loggingWrapEnabled" . }}
command: ["sh", "-c"] command: ["sh", "-c"]
args: args:
@@ -26,6 +26,10 @@ data:
Refresh_Interval 5 Refresh_Interval 5
Mem_Buf_Limit 5MB Mem_Buf_Limit 5MB
Skip_Long_Lines On Skip_Long_Lines On
# Read the whole file (incl. startup output written before fluent-bit
# attached) instead of only new lines — otherwise an idle app that
# logged only at boot would ship nothing to Elasticsearch.
Read_from_Head On
[FILTER] [FILTER]
Name record_modifier Name record_modifier
@@ -3,6 +3,13 @@
{{- $ns := include "cloudhost-app.namespace" . -}} {{- $ns := include "cloudhost-app.namespace" . -}}
{{- $host := printf "%s.%s" (default $name .Values.ingress.subdomain) .Values.ingress.domain -}} {{- $host := printf "%s.%s" (default $name .Values.ingress.subdomain) .Values.ingress.domain -}}
{{- $previewHost := .Values.ingress.previewHost | default "" -}} {{- $previewHost := .Values.ingress.previewHost | default "" -}}
{{- $className := .Values.ingress.className | default "traefik" -}}
{{- /* Only public, real-TLD hosts (custom domain + preview) can get a managed cert.
The internal *.apps.cloudhost.local host is not a valid public suffix and would
make Let's Encrypt reject the whole order. */ -}}
{{- $tlsHosts := list -}}
{{- if .Values.ingress.customDomain }}{{- $tlsHosts = append $tlsHosts .Values.ingress.customDomain -}}{{- end -}}
{{- if $previewHost }}{{- $tlsHosts = append $tlsHosts $previewHost -}}{{- end -}}
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
@@ -10,10 +17,12 @@ metadata:
namespace: {{ $ns }} namespace: {{ $ns }}
labels: labels:
{{- include "cloudhost-app.labels" . | nindent 4 }} {{- include "cloudhost-app.labels" . | nindent 4 }}
{{- if gt (len $tlsHosts) 0 }}
annotations: annotations:
cert-manager.io/cluster-issuer: {{ .Values.ingress.clusterIssuer | quote }} cert-manager.io/cluster-issuer: {{ .Values.ingress.clusterIssuer | quote }}
{{- end }}
spec: spec:
ingressClassName: nginx ingressClassName: {{ $className }}
rules: rules:
- host: {{ $host }} - host: {{ $host }}
http: http:
@@ -49,14 +58,12 @@ spec:
port: port:
number: 80 number: 80
{{- end }} {{- end }}
{{- if gt (len $tlsHosts) 0 }}
tls: tls:
- hosts: - hosts:
- {{ $host }} {{- range $tlsHosts }}
{{- if .Values.ingress.customDomain }} - {{ . }}
- {{ .Values.ingress.customDomain }}
{{- end }}
{{- if $previewHost }}
- {{ $previewHost }}
{{- end }} {{- end }}
secretName: {{ $name }}-tls secretName: {{ $name }}-tls
{{- end }}
{{- end }} {{- end }}
@@ -159,7 +159,9 @@ export class ApplicationsService {
.createQueryBuilder('app') .createQueryBuilder('app')
.leftJoinAndSelect('app.deployments', 'deployments') .leftJoinAndSelect('app.deployments', 'deployments')
.where('app.userId = :userId', { userId }) .where('app.userId = :userId', { userId })
.orderBy('app.createdAt', 'DESC'); .orderBy('app.createdAt', 'DESC')
// Ensure deployments[0] is the most recent so the UI shows the latest status.
.addOrderBy('deployments.createdAt', 'DESC');
if (options?.productType === 'application') { if (options?.productType === 'application') {
qb.andWhere( qb.andWhere(
@@ -184,7 +186,9 @@ export class ApplicationsService {
.createQueryBuilder('app') .createQueryBuilder('app')
.leftJoinAndSelect('app.user', 'user') .leftJoinAndSelect('app.user', 'user')
.leftJoinAndSelect('app.deployments', 'deployments') .leftJoinAndSelect('app.deployments', 'deployments')
.orderBy('app.createdAt', 'DESC'); .orderBy('app.createdAt', 'DESC')
// Ensure deployments[0] is the most recent so the UI shows the latest status.
.addOrderBy('deployments.createdAt', 'DESC');
if (search && search.trim()) { if (search && search.trim()) {
const s = `%${search.trim()}%`; const s = `%${search.trim()}%`;
+1 -4
View File
@@ -1,17 +1,14 @@
import { Module, forwardRef } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { BullModule } from '@nestjs/bull';
import { BuildService } from './build.service'; import { BuildService } from './build.service';
import { BuildProcessor } from './build.processor';
import { KubernetesModule } from '../kubernetes/kubernetes.module'; import { KubernetesModule } from '../kubernetes/kubernetes.module';
import { ClustersModule } from '../clusters/clusters.module'; import { ClustersModule } from '../clusters/clusters.module';
@Module({ @Module({
imports: [ imports: [
BullModule.registerQueue({ name: 'build' }),
forwardRef(() => KubernetesModule), forwardRef(() => KubernetesModule),
ClustersModule, ClustersModule,
], ],
providers: [BuildService, BuildProcessor], providers: [BuildService],
exports: [BuildService], exports: [BuildService],
}) })
export class BuildModule {} export class BuildModule {}
-42
View File
@@ -1,42 +0,0 @@
import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
import { Job } from 'bull';
import { BuildService } from './build.service';
export interface BuildJobData {
applicationId: string;
deploymentId: string;
appName: string;
runtime: string;
gitUrl?: string;
codePath?: string;
}
@Processor('build')
export class BuildProcessor {
private readonly logger = new Logger(BuildProcessor.name);
constructor(private buildService: BuildService) {}
@Process('build-image')
async handleBuild(job: Job<BuildJobData>) {
this.logger.log(`Processing build job ${job.id} for app: ${job.data.appName}`);
try {
await job.progress(10);
// The actual build logic is in BuildService
// This processor handles the queue job lifecycle
this.logger.log(`Build job ${job.id} started for ${job.data.appName}`);
await job.progress(50);
await job.progress(100);
this.logger.log(`Build job ${job.id} completed for ${job.data.appName}`);
return { status: 'completed', appName: job.data.appName };
} catch (error: any) {
this.logger.error(`Build job ${job.id} failed: ${error.message}`);
throw error;
}
}
}
+42 -15
View File
@@ -45,6 +45,12 @@ 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 progressMap = new Map<string, BuildProgress>();
private readonly activeBuilds = new Map<string, ActiveBuildSession>(); private readonly activeBuilds = new Map<string, ActiveBuildSession>();
/**
* Kaniko executor image. Pinned (not `:latest`) so it can be cached on the node
* with imagePullPolicy=IfNotPresent — avoids re-pulling the ~250MB image on every build.
*/
private readonly kanikoImage =
process.env.KANIKO_IMAGE || 'gcr.io/kaniko-project/executor:v1.23.2';
constructor( constructor(
private configService: ConfigService, private configService: ConfigService,
@@ -332,6 +338,7 @@ export class BuildService {
initContainers.push({ initContainers.push({
name: 'unzip-source', name: 'unzip-source',
image: 'alpine:3.19', image: 'alpine:3.19',
imagePullPolicy: 'IfNotPresent',
command: ['sh', '-c', ` command: ['sh', '-c', `
apk add --no-cache unzip tar gzip && apk add --no-cache unzip tar gzip &&
cp /workspace/Dockerfile /workspace-out/Dockerfile && cp /workspace/Dockerfile /workspace-out/Dockerfile &&
@@ -390,6 +397,7 @@ export class BuildService {
initContainers.push({ initContainers.push({
name: 'git-clone', name: 'git-clone',
image: 'alpine/git:2.43.0', image: 'alpine/git:2.43.0',
imagePullPolicy: 'IfNotPresent',
command: ['sh', '-c', ` command: ['sh', '-c', `
echo ">>> Cloning branch '${branch}' from ${app.gitUrl}" && echo ">>> Cloning branch '${branch}' from ${app.gitUrl}" &&
git clone --depth 1 --branch ${branch} ${cloneUrl} /workspace-out/source && git clone --depth 1 --branch ${branch} ${cloneUrl} /workspace-out/source &&
@@ -417,6 +425,7 @@ export class BuildService {
initContainers.push({ initContainers.push({
name: 'prepare-workspace', name: 'prepare-workspace',
image: 'alpine:3.19', image: 'alpine:3.19',
imagePullPolicy: 'IfNotPresent',
command: ['sh', '-c', ` command: ['sh', '-c', `
mkdir -p /workspace-out/source && mkdir -p /workspace-out/source &&
cp /dockerfile/Dockerfile /workspace-out/Dockerfile && cp /dockerfile/Dockerfile /workspace-out/Dockerfile &&
@@ -447,7 +456,8 @@ export class BuildService {
containers: [ containers: [
{ {
name: 'kaniko', name: 'kaniko',
image: 'gcr.io/kaniko-project/executor:latest', image: this.kanikoImage,
imagePullPolicy: 'IfNotPresent',
args: kanikoArgs, args: kanikoArgs,
volumeMounts: kanikoVolumeMounts, volumeMounts: kanikoVolumeMounts,
resources: { resources: {
@@ -519,17 +529,6 @@ export class BuildService {
} }
} }
/**
* 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. * 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. * kubectl cp uses tar over the k8s exec API — reliable for any file size.
@@ -668,6 +667,7 @@ export class BuildService {
containers: [{ containers: [{
name: 'helper', name: 'helper',
image: 'alpine:3.19', image: 'alpine:3.19',
imagePullPolicy: 'IfNotPresent',
command: ['sh', '-c', 'sleep 3600'], command: ['sh', '-c', 'sleep 3600'],
volumeMounts: [{ name: 'source', mountPath: '/data' }], volumeMounts: [{ name: 'source', mountPath: '/data' }],
resources: { resources: {
@@ -1467,6 +1467,30 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' !
`; `;
} }
/**
* Whether a K8s API error is a transient connectivity/availability blip that
* should be retried rather than failing the operation. Covers socket-level
* errors, request timeouts (incl. the apiserver "request did not complete
* within the allotted timeout" message), DNS hiccups and 5xx/429 responses.
*/
private isTransientK8sError(err: any): boolean {
const statusCode = err?.statusCode ?? err?.response?.statusCode ?? err?.body?.code;
if (typeof statusCode === 'number' && (statusCode >= 500 || statusCode === 429)) {
return true;
}
const haystack = [
err?.code,
err?.message,
err?.body?.message,
err?.cause?.code,
]
.filter(Boolean)
.join(' ');
return /ECONNRESET|ECONNREFUSED|ETIMEDOUT|ESOCKETTIMEDOUT|EPIPE|EAI_AGAIN|ENOTFOUND|ENETUNREACH|socket hang up|timed? ?out|allotted timeout|did not complete|Client network socket disconnected/i.test(
haystack,
);
}
private async waitForJobCompletion( private async waitForJobCompletion(
batchApi: k8s.BatchV1Api, batchApi: k8s.BatchV1Api,
coreApi: k8s.CoreV1Api, coreApi: k8s.CoreV1Api,
@@ -1493,9 +1517,12 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' !
try { try {
job = await batchApi.readNamespacedJob(jobName, namespace); job = await batchApi.readNamespacedJob(jobName, namespace);
} catch (pollErr: any) { } catch (pollErr: any) {
const code = pollErr?.code || pollErr?.message || ''; // The Kaniko job keeps running independently of these status polls.
if (/ECONNRESET|ECONNREFUSED|ETIMEDOUT|socket hang up/i.test(String(code))) { // A single API blip (timeout, reset, 5xx, DNS) must NOT abort a build
this.logger.warn(`Transient K8s API error polling job ${jobName}: ${code} — retrying in 5s`); // that is still progressing — just retry on the next poll tick.
if (this.isTransientK8sError(pollErr)) {
const detail = pollErr?.code || pollErr?.message || pollErr?.statusCode || 'unknown';
this.logger.warn(`Transient K8s API error polling job ${jobName}: ${detail} — retrying in 5s`);
await new Promise(r => setTimeout(r, 5000)); await new Promise(r => setTimeout(r, 5000));
continue; continue;
} }
@@ -25,7 +25,11 @@ const CERT_MANAGER_CHART = 'jetstack/cert-manager';
const CLUSTER_ISSUER_NAME = 'letsencrypt-prod'; const CLUSTER_ISSUER_NAME = 'letsencrypt-prod';
const ACME_PROD_SERVER = 'https://acme-v02.api.letsencrypt.org/directory'; const ACME_PROD_SERVER = 'https://acme-v02.api.letsencrypt.org/directory';
const DEFAULT_INGRESS_CLASS = 'nginx'; // k3s ships Traefik by default; match the app Ingress class (INGRESS_CLASS)
// so the ACME HTTP-01 solver Ingress is actually served by the controller.
const DEFAULT_INGRESS_CLASS = (process.env.INGRESS_CLASS || 'traefik')
.trim()
.toLowerCase();
const ISSUER_GROUP = 'cert-manager.io'; const ISSUER_GROUP = 'cert-manager.io';
const ISSUER_VERSION = 'v1'; const ISSUER_VERSION = 'v1';
+101 -19
View File
@@ -300,12 +300,15 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
} }
const candidates = await this.getCachedHealthyClusters(pool || undefined, options.excludeClusterIds || []); const candidates = await this.getCachedHealthyClusters(pool || undefined, options.excludeClusterIds || []);
const appCounts = await this.getAppCounts(candidates.map((cluster) => cluster.id)); const clusterIds = candidates.map((cluster) => cluster.id);
const appCounts = await this.getAppCounts(clusterIds);
const reservations = await this.getInFlightReservations(clusterIds, options.applicationId);
const candidateScores: Record<string, any>[] = []; const candidateScores: Record<string, any>[] = [];
const rejectionReasons: Record<string, any>[] = []; const rejectionReasons: Record<string, any>[] = [];
for (const cluster of candidates) { for (const cluster of candidates) {
const rejection = this.getClusterRejectionReason(cluster, estimatedRequest); const reserved = reservations.get(cluster.id) || { cpuMillicores: 0, memoryMi: 0, pods: 0 };
const rejection = this.getClusterRejectionReason(cluster, estimatedRequest, reserved);
if (rejection) { if (rejection) {
rejectionReasons.push({ clusterId: cluster.id, clusterName: cluster.name, reason: rejection }); rejectionReasons.push({ clusterId: cluster.id, clusterName: cluster.name, reason: rejection });
continue; continue;
@@ -313,8 +316,8 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
const strategy = this.resolveStrategy(pool?.strategy); const strategy = this.resolveStrategy(pool?.strategy);
const appCount = appCounts.get(cluster.id) || 0; const appCount = appCounts.get(cluster.id) || 0;
const score = this.scoreCluster(cluster, estimatedRequest, appCount, strategy, (dto as any).region); const score = this.scoreCluster(cluster, estimatedRequest, appCount, strategy, (dto as any).region, reserved);
const resourceMetrics = this.getResourceMetrics(cluster, estimatedRequest, appCount); const resourceMetrics = this.getResourceMetrics(cluster, estimatedRequest, appCount, reserved);
candidateScores.push({ candidateScores.push({
clusterId: cluster.id, clusterId: cluster.id,
clusterName: cluster.name, clusterName: cluster.name,
@@ -382,11 +385,13 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
message: string, message: string,
): Promise<void> { ): Promise<void> {
if (clusterId) { if (clusterId) {
await this.clustersRepository.update(clusterId, { // A single deployment failure (quota, image pull, app bug, transient
healthStatus: 'degraded', // scheduling pressure) does NOT mean the cluster is unhealthy — flipping
healthMessage: message, // its healthStatus to 'degraded' would poison it for every other app and,
lastHealthCheckedAt: new Date(), // when there's only one cluster, cause "no healthy cluster has capacity"
}); // for the next deployment. The fallback loop already excludes this cluster
// for *this* app via excludeClusterIds, so here we only invalidate the
// cached capacity snapshot to force a fresh read on the next allocation.
this.clusterHealthCache.delete(clusterId); this.clusterHealthCache.delete(clusterId);
} }
@@ -1023,6 +1028,13 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
const { username, password } = this.registryService.getRegistryCredentials(); const { username, password } = this.registryService.getRegistryCredentials();
const dsName = 'cloudhost-k3s-registry-mirrors'; const dsName = 'cloudhost-k3s-registry-mirrors';
// The mirror endpoint must be reachable by the node's containerd, which does
// NOT use cluster DNS — so we point it at the registry NodePort on loopback
// (http://127.0.0.1:<nodePort>) instead of the in-cluster service DNS name.
// Otherwise image pulls break whenever node-level resolution of
// *.svc.cluster.local is unavailable (e.g. right after a node restart).
const registryNodePort = 30500;
const nodePortHost = `127.0.0.1:${registryNodePort}`;
const configureScript = [ const configureScript = [
'set -e', 'set -e',
'REG=/host/etc/rancher/k3s/registries.yaml', 'REG=/host/etc/rancher/k3s/registries.yaml',
@@ -1031,12 +1043,16 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
'mirrors:', 'mirrors:',
` "${registryUrl}":`, ` "${registryUrl}":`,
' endpoint:', ' endpoint:',
` - "http://${registryUrl}"`, ` - "http://${nodePortHost}"`,
'configs:', 'configs:',
` "${registryUrl}":`, ` "${registryUrl}":`,
' auth:', ' auth:',
` username: ${JSON.stringify(username)}`, ` username: ${JSON.stringify(username)}`,
` password: ${JSON.stringify(password)}`, ` password: ${JSON.stringify(password)}`,
` "${nodePortHost}":`,
' auth:',
` username: ${JSON.stringify(username)}`,
` password: ${JSON.stringify(password)}`,
'EOFREG', 'EOFREG',
'if [ ! -f "$REG" ] || ! cmp -s /tmp/cloudhost-registries.yaml "$REG"; then', 'if [ ! -f "$REG" ] || ! cmp -s /tmp/cloudhost-registries.yaml "$REG"; then',
' cp /tmp/cloudhost-registries.yaml "$REG"', ' cp /tmp/cloudhost-registries.yaml "$REG"',
@@ -1212,7 +1228,11 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
} }
} }
private getClusterRejectionReason(cluster: Cluster, estimatedRequest: Record<string, any>): string | null { private getClusterRejectionReason(
cluster: Cluster,
estimatedRequest: Record<string, any>,
reserved: { cpuMillicores: number; memoryMi: number; pods: number } = { cpuMillicores: 0, memoryMi: 0, pods: 0 },
): string | null {
if (cluster.status !== ClusterStatus.ACTIVE) { if (cluster.status !== ClusterStatus.ACTIVE) {
return `status=${cluster.status}`; return `status=${cluster.status}`;
} }
@@ -1221,7 +1241,7 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
return `health=${cluster.healthStatus}`; return `health=${cluster.healthStatus}`;
} }
const metrics = this.getResourceMetrics(cluster, estimatedRequest, 0); const metrics = this.getResourceMetrics(cluster, estimatedRequest, 0, reserved);
const { available, utilization } = metrics; const { available, utilization } = metrics;
if (available.cpuMillicores > 0 && available.cpuMillicores < estimatedRequest.cpuMillicores) { if (available.cpuMillicores > 0 && available.cpuMillicores < estimatedRequest.cpuMillicores) {
@@ -1252,8 +1272,9 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
appCount: number, appCount: number,
strategy: PoolStrategy, strategy: PoolStrategy,
desiredRegion?: string, desiredRegion?: string,
reserved: { cpuMillicores: number; memoryMi: number; pods: number } = { cpuMillicores: 0, memoryMi: 0, pods: 0 },
): number { ): number {
const metrics = this.getResourceMetrics(cluster, estimatedRequest, appCount); const metrics = this.getResourceMetrics(cluster, estimatedRequest, appCount, reserved);
const capacityScore = metrics.capacityScore; const capacityScore = metrics.capacityScore;
const appPenalty = Math.min(appCount, 100) * 0.75; const appPenalty = Math.min(appCount, 100) * 0.75;
@@ -1281,6 +1302,7 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
cluster: Cluster, cluster: Cluster,
estimatedRequest: Record<string, any>, estimatedRequest: Record<string, any>,
appCount: number, appCount: number,
reserved: { cpuMillicores: number; memoryMi: number; pods: number } = { cpuMillicores: 0, memoryMi: 0, pods: 0 },
): { ): {
available: { cpuMillicores: number; memoryMi: number; storageMi: number; pods: number }; available: { cpuMillicores: number; memoryMi: number; storageMi: number; pods: number };
utilization: { cpu: number; memory: number; storage: number; pods: number; appPressure: number; average: number }; utilization: { cpu: number; memory: number; storage: number; pods: number; appPressure: number; average: number };
@@ -1297,17 +1319,23 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
const nodeCount = Number(resources.nodeCount || 0); const nodeCount = Number(resources.nodeCount || 0);
const podCapacity = Number(resources.podCapacity || nodeCount * 110 || 0); const podCapacity = Number(resources.podCapacity || nodeCount * 110 || 0);
// Include capacity reserved by in-flight deployments whose pods don't exist
// on the cluster yet, so concurrent allocations don't over-commit the node.
const cpuUsed = cpuRequested + (reserved.cpuMillicores || 0);
const memoryUsed = memoryRequested + (reserved.memoryMi || 0);
const podsUsed = podCount + (reserved.pods || 0);
const available = { const available = {
cpuMillicores: Math.max(cpuCapacity - cpuRequested, 0), cpuMillicores: Math.max(cpuCapacity - cpuUsed, 0),
memoryMi: Math.max(memoryCapacity - memoryRequested, 0), memoryMi: Math.max(memoryCapacity - memoryUsed, 0),
storageMi: storageCapacity > 0 ? Math.max(storageCapacity - storageUsed, 0) : 0, storageMi: storageCapacity > 0 ? Math.max(storageCapacity - storageUsed, 0) : 0,
pods: podCapacity > 0 ? Math.max(podCapacity - podCount, 0) : 0, pods: podCapacity > 0 ? Math.max(podCapacity - podsUsed, 0) : 0,
}; };
const utilization = { const utilization = {
cpu: this.utilizationRatio(cpuRequested, cpuCapacity), cpu: this.utilizationRatio(cpuUsed, cpuCapacity),
memory: this.utilizationRatio(memoryRequested, memoryCapacity), memory: this.utilizationRatio(memoryUsed, memoryCapacity),
storage: this.utilizationRatio(storageUsed, storageCapacity), storage: this.utilizationRatio(storageUsed, storageCapacity),
pods: this.utilizationRatio(podCount, podCapacity), pods: this.utilizationRatio(podsUsed, podCapacity),
appPressure: Math.min(appCount / 100, 1), appPressure: Math.min(appCount / 100, 1),
average: 0, average: 0,
}; };
@@ -1377,6 +1405,60 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
return new Map(rows.map((row) => [row.clusterId, parseInt(row.count, 10)])); return new Map(rows.map((row) => [row.clusterId, parseInt(row.count, 10)]));
} }
/**
* Capacity that has been allocated to deployments which are still building but
* whose pods don't exist on the cluster yet (status pending/building). The
* cluster's `availableResources` snapshot only reflects pods that already exist
* (and is cached up to cacheTtlMs), so without this, two deployments started
* within the same window both read the same stale capacity and can both be
* placed on a cluster that only has room for one — the second then can't be
* scheduled and hangs. We subtract these in-flight requests during allocation.
*
* @param excludeApplicationId skip an app's own in-flight deployment (used on
* fallback re-allocation so the app doesn't reserve capacity against itself).
*/
private async getInFlightReservations(
clusterIds: string[],
excludeApplicationId?: string,
): Promise<Map<string, { cpuMillicores: number; memoryMi: number; pods: number }>> {
const result = new Map<string, { cpuMillicores: number; memoryMi: number; pods: number }>();
if (clusterIds.length === 0) {
return result;
}
const rows: any[] = await this.dataSource.query(`
SELECT a.id as "applicationId",
a."clusterId" as "clusterId",
a."cpuRequest" as "cpuRequest",
a."memoryRequest" as "memoryRequest",
a.replicas as "replicas",
a."databaseType" as "databaseType",
a."enableRedis" as "enableRedis",
a."enableRabbitmq" as "enableRabbitmq",
a."appStorageSize" as "appStorageSize",
a."dbStorageSize" as "dbStorageSize",
a."optionalServiceResources" as "optionalServiceResources"
FROM deployments d
JOIN applications a ON a.id = d."applicationId"
WHERE a."clusterId" = ANY($1)
AND d.status IN ('pending', 'building')
`, [clusterIds]);
for (const row of rows) {
if (excludeApplicationId && row.applicationId === excludeApplicationId) {
continue;
}
const est = this.estimateApplicationRequest(row as CreateApplicationDto);
const current = result.get(row.clusterId) || { cpuMillicores: 0, memoryMi: 0, pods: 0 };
current.cpuMillicores += est.cpuMillicores;
current.memoryMi += est.memoryMi;
current.pods += est.podEstimate;
result.set(row.clusterId, current);
}
return result;
}
private parseStorageToMi(storage: string): number { private parseStorageToMi(storage: string): number {
if (!storage) return 0; if (!storage) return 0;
if (storage.endsWith('Ki')) return parseFloat(storage) / 1024; if (storage.endsWith('Ki')) return parseFloat(storage) / 1024;
+13
View File
@@ -28,6 +28,13 @@ function stripLeadingSubdomain(hostname: string): string {
} }
function resolvePreviewRootDomainFromEnv(): string { function resolvePreviewRootDomainFromEnv(): string {
// Explicit override takes priority — the base domain used for preview URLs
// (e.g. PREVIEW_BASE_DOMAIN=3fase.ir) must be configurable, never hardcoded.
const explicit = process.env.PREVIEW_BASE_DOMAIN;
if (explicit && explicit.trim()) {
return explicit.trim().toLowerCase();
}
const frontendUrl = process.env.FRONTEND_URL; const frontendUrl = process.env.FRONTEND_URL;
if (frontendUrl) { if (frontendUrl) {
try { try {
@@ -108,6 +115,12 @@ export default () => ({
platform: { platform: {
domain: resolvePlatformDomainFromEnv(), domain: resolvePlatformDomainFromEnv(),
previewRootDomain: resolvePreviewRootDomainFromEnv(), previewRootDomain: resolvePreviewRootDomainFromEnv(),
/**
* Ingress controller class used for app Ingress objects and the ACME
* HTTP-01 solver. k3s ships Traefik by default, so we default to "traefik".
* Set INGRESS_CLASS=nginx for clusters running ingress-nginx instead.
*/
ingressClass: (process.env.INGRESS_CLASS || 'traefik').trim().toLowerCase(),
uploadDir: process.env.UPLOAD_DIR || './uploads', uploadDir: process.env.UPLOAD_DIR || './uploads',
/** StorageClass for new PVCs; must support allowVolumeExpansion for disk resize */ /** StorageClass for new PVCs; must support allowVolumeExpansion for disk resize */
storageClass: process.env.PLATFORM_STORAGE_CLASS || 'cloudhost-expandable', storageClass: process.env.PLATFORM_STORAGE_CLASS || 'cloudhost-expandable',
+24 -8
View File
@@ -30,13 +30,29 @@ export class DeploymentsService {
) {} ) {}
/** /**
* Stable "number" part for preview host, derived from deployment id. * Random 7-digit suffix for the preview host: <userId>-<7-digit>.<baseDomain>.
* We keep it numeric to match the "<number>" requirement. * Generated once per application (see resolvePreviewNumber) and persisted.
*/ */
private computePreviewNumberFromDeploymentId(deploymentId: string): string { private generatePreviewNumber(): string {
const hashHex = crypto.createHash('sha256').update(deploymentId).digest('hex'); return String(crypto.randomInt(1_000_000, 10_000_000));
const num = parseInt(hashHex.slice(0, 8), 16) % 1_000_000; // 0..999999 }
return String(num).padStart(6, '0');
/**
* Returns a STABLE preview number for an application: reuse the one already
* assigned to a previous deployment so the public preview URL never changes
* across redeploys (otherwise old links 404). Only generates a new number the
* first time the app is deployed. Apps with a custom domain get no preview host.
*/
private async resolvePreviewNumber(applicationId: string): Promise<string> {
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 || this.generatePreviewNumber();
} }
async triggerDeployment(applicationId: string, userId: string): Promise<Deployment> { async triggerDeployment(applicationId: string, userId: string): Promise<Deployment> {
@@ -56,7 +72,7 @@ export class DeploymentsService {
// Fill deterministic preview number after we have the deployment id. // Fill deterministic preview number after we have the deployment id.
let previewSubdomain: string | null = null; let previewSubdomain: string | null = null;
if (!app.customDomain) { if (!app.customDomain) {
previewSubdomain = this.computePreviewNumberFromDeploymentId(saved.id); previewSubdomain = await this.resolvePreviewNumber(app.id);
await this.deploymentsRepository.update(saved.id, { previewSubdomain }); await this.deploymentsRepository.update(saved.id, { previewSubdomain });
saved.previewSubdomain = previewSubdomain; saved.previewSubdomain = previewSubdomain;
} }
@@ -657,7 +673,7 @@ export class DeploymentsService {
let previewSubdomain: string | null = null; let previewSubdomain: string | null = null;
if (!app.customDomain) { if (!app.customDomain) {
previewSubdomain = this.computePreviewNumberFromDeploymentId(saved.id); previewSubdomain = await this.resolvePreviewNumber(app.id);
await this.deploymentsRepository.update(saved.id, { previewSubdomain }); await this.deploymentsRepository.update(saved.id, { previewSubdomain });
saved.previewSubdomain = previewSubdomain; saved.previewSubdomain = previewSubdomain;
} }
+25 -10
View File
@@ -289,7 +289,7 @@ export class KubernetesService implements OnModuleInit {
const namespacePrefix = app.userId.split('-')[0]; const namespacePrefix = app.userId.split('-')[0];
const previewHost = const previewHost =
previewNumber && !app.customDomain previewNumber && !app.customDomain
? `${namespacePrefix}-${previewNumber}-preview.${previewRootDomain}` ? `${namespacePrefix}-${previewNumber}.${previewRootDomain}`
: ''; : '';
const pullRegistryUrl = this.registryService.getRegistryUrl(); const pullRegistryUrl = this.registryService.getRegistryUrl();
const isWordPress = app.runtime === AppRuntime.WORDPRESS; const isWordPress = app.runtime === AppRuntime.WORDPRESS;
@@ -317,6 +317,7 @@ export class KubernetesService implements OnModuleInit {
envVars: this.resolveEnvVars(app), envVars: this.resolveEnvVars(app),
ingress: { ingress: {
enabled: true, enabled: true,
className: this.configService.get<string>('platform.ingressClass') || 'traefik',
subdomain: app.subdomain || app.name, subdomain: app.subdomain || app.name,
domain: domain, domain: domain,
clusterIssuer: 'letsencrypt-prod', clusterIssuer: 'letsencrypt-prod',
@@ -931,8 +932,10 @@ export class KubernetesService implements OnModuleInit {
// Main application container // Main application container
const appContainer: any = { const appContainer: any = {
name: ctx.appName, name: ctx.appName,
// Image tags are unique per build (name:timestamp) and immutable, so
// IfNotPresent is correct and avoids re-pulling on every restart/scale-up.
image: ctx.image, image: ctx.image,
imagePullPolicy: 'Always', imagePullPolicy: 'IfNotPresent',
ports: [{ containerPort: ctx.port }], ports: [{ containerPort: ctx.port }],
envFrom, envFrom,
env: extraEnv, env: extraEnv,
@@ -1402,7 +1405,11 @@ export class KubernetesService implements OnModuleInit {
}, },
}, },
]; ];
const tlsHosts = [host]; // Only PUBLIC, real-TLD hosts may go into the TLS block. The internal
// `${subdomain}.${domain}` host (e.g. *.apps.cloudhost.local) is not a valid
// public suffix — including it makes Let's Encrypt reject the whole order,
// which would also block the cert for the legitimate preview/custom domains.
const tlsHosts: string[] = [];
if (customDomain) { if (customDomain) {
rules.push({ rules.push({
@@ -1424,7 +1431,7 @@ export class KubernetesService implements OnModuleInit {
const namespacePrefix = ctx.ownerId.split('-')[0]; const namespacePrefix = ctx.ownerId.split('-')[0];
const previewHost = const previewHost =
previewNumber && !customDomain previewNumber && !customDomain
? `${namespacePrefix}-${previewNumber}-preview.${previewRootDomain}` ? `${namespacePrefix}-${previewNumber}.${previewRootDomain}`
: ''; : '';
if (previewHost) { if (previewHost) {
rules.push({ rules.push({
@@ -1442,20 +1449,28 @@ export class KubernetesService implements OnModuleInit {
tlsHosts.push(previewHost); tlsHosts.push(previewHost);
} }
const ingressClass =
this.configService.get<string>('platform.ingressClass') || 'traefik';
// Request a managed cert only when we actually have a public host to issue for.
const annotations: Record<string, string> = {};
const tls: k8s.V1IngressTLS[] = [];
if (tlsHosts.length > 0) {
annotations['cert-manager.io/cluster-issuer'] = 'letsencrypt-prod';
tls.push({ hosts: tlsHosts, secretName: `${ctx.appName}-tls` });
}
const ingress: k8s.V1Ingress = { const ingress: k8s.V1Ingress = {
apiVersion: 'networking.k8s.io/v1', apiVersion: 'networking.k8s.io/v1',
kind: 'Ingress', kind: 'Ingress',
metadata: { metadata: {
name: ctx.appName, name: ctx.appName,
namespace: ctx.namespace, namespace: ctx.namespace,
annotations: { annotations,
'cert-manager.io/cluster-issuer': 'letsencrypt-prod',
},
}, },
spec: { spec: {
ingressClassName: 'nginx', ingressClassName: ingressClass,
rules, rules,
tls: [{ hosts: tlsHosts, secretName: `${ctx.appName}-tls` }], ...(tls.length > 0 ? { tls } : {}),
}, },
}; };
@@ -2780,7 +2795,7 @@ export class KubernetesService implements OnModuleInit {
if (verifiedCustomDomain) { if (verifiedCustomDomain) {
ingressUrl = `https://${verifiedCustomDomain}`; ingressUrl = `https://${verifiedCustomDomain}`;
} else if (previewNumber) { } else if (previewNumber) {
ingressUrl = `https://${namespacePrefix}-${previewNumber}-preview.${previewRootDomain}`; ingressUrl = `https://${namespacePrefix}-${previewNumber}.${previewRootDomain}`;
} }
return { return {