Files
cloud-host/backend/src/config/configuration.ts
T
keyhan 2679c9d66e Fix Kaniko image refs to use seeded abrban/ images.
v1.27.6-debug does not exist on gcr.io; proxy-gcr pulls are flaky on kubelet. Seed and reference abrban/kaniko-executor:v1.23.2 instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 18:31:37 +03:30

236 lines
9.9 KiB
TypeScript

// FRONTEND_URL may hold a comma-separated list of origins (used for CORS).
// Domain/host resolution must only ever look at a single URL, otherwise
// `new URL('https://a.com,https://b.com')` yields the garbage hostname
// "a.com,https" which then leaks into ingress hosts and gets rejected by k8s.
function firstFrontendUrl(): string | undefined {
const first = (process.env.FRONTEND_URL || '').split(',')[0]?.trim();
return first || undefined;
}
function resolvePlatformDomainFromEnv(): string {
const frontendUrl = firstFrontendUrl();
if (frontendUrl) {
try {
const url = new URL(frontendUrl);
if (url.hostname && !/^\d+\.\d+\.\d+\.\d+$/.test(url.hostname)) {
return url.hostname;
}
} catch {
// ignore parse errors and fall back to PLATFORM_DOMAIN
}
}
return process.env.PLATFORM_DOMAIN || 'apps.cloudhost.local';
}
function stripLeadingSubdomain(hostname: string): string {
const h = (hostname || '').toLowerCase();
if (!h) return h;
// If it looks like an IP (IPv4 or IPv6), don't try to strip labels.
if (h.includes(':') || /^\d+\.\d+\.\d+\.\d+$/.test(h)) {
return h;
}
const parts = h.split('.').filter(Boolean);
if (parts.length >= 3) {
return parts.slice(1).join('.');
}
return h;
}
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 = firstFrontendUrl();
if (frontendUrl) {
try {
const url = new URL(frontendUrl);
if (url.hostname) {
return stripLeadingSubdomain(url.hostname);
}
} catch {
// ignore and fall back
}
}
// Fallback: strip from PLATFORM_DOMAIN if it's already a subdomain host.
return stripLeadingSubdomain(process.env.PLATFORM_DOMAIN || 'apps.cloudhost.local');
}
export default () => ({
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '4000', 10),
database: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
username: process.env.DB_USERNAME || 'cloudhost',
password: process.env.DB_PASSWORD || 'cloudhost_secret',
name: process.env.DB_DATABASE || 'cloudhost',
},
jwt: {
secret: process.env.JWT_SECRET || 'default-jwt-secret',
expiresIn: process.env.JWT_EXPIRES_IN || '1h',
refreshSecret: process.env.JWT_REFRESH_SECRET || 'default-refresh-secret',
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
},
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || undefined,
},
cluster: {
/** AES-256-GCM key for encrypting stored kubeconfigs. Required in production. */
kubeconfigKey: process.env.CLUSTER_KUBECONFIG_KEY || '',
},
// OTP SMS. Provider selectable via SMS_PROVIDER ('mizbansms' | 'kavenegar').
sms: {
provider: (process.env.SMS_PROVIDER || 'mizbansms').trim().toLowerCase(),
// Kavenegar (verify/lookup template API) — kept for fallback.
kavenegarApiKey: process.env.KAVENEGAR_API_KEY || '',
kavenegarOtpTemplate: process.env.KAVENEGAR_OTP_TEMPLATE || '',
// MizbanSMS (services.mizbansms.com) — sends full message text over a
// dedicated line, so the OTP wording is composed here, not from a template.
mizban: {
baseUrl: process.env.MIZBANSMS_BASE_URL || 'http://services.mizbansms.com',
username: process.env.MIZBANSMS_USERNAME || '',
password: process.env.MIZBANSMS_PASSWORD || '',
from: process.env.MIZBANSMS_FROM || '5000467254',
api: parseInt(process.env.MIZBANSMS_API || '2016', 10),
userType: parseInt(process.env.MIZBANSMS_USERTYPE || '2', 10),
// Per-message-kind wording. `{code}` (or `{{CODE}}`) is replaced with the
// 6-digit OTP. \n is allowed for multi-line messages.
templates: {
register:
process.env.MIZBANSMS_OTP_TEMPLATE_REGISTER ||
'به ابربان خوش آمدید.\nکد تأیید شما: {code}',
login:
process.env.MIZBANSMS_OTP_TEMPLATE_LOGIN ||
'رمز یکبار مصرف ابربان:\n{code}',
changePhone:
process.env.MIZBANSMS_OTP_TEMPLATE_CHANGE_PHONE ||
'کد تأیید شماره جدید ابربان:\n{code}',
},
},
},
registry: {
/** In-cluster registry — Kaniko push and app image pull (same host). */
url: process.env.REGISTRY_URL || 'registry.cloudhost-builds.svc.cluster.local:5000',
pullUrl: process.env.REGISTRY_PULL_URL || process.env.REGISTRY_URL || 'registry.cloudhost-builds.svc.cluster.local:5000',
username: process.env.REGISTRY_USERNAME || 'admin',
password: process.env.REGISTRY_PASSWORD || '',
},
build: {
namespace: process.env.BUILD_NAMESPACE || 'cloudhost-builds',
serviceAccount: process.env.BUILD_SERVICE_ACCOUNT || 'kaniko-builder',
/**
* Registry prefix for Docker Hub base images used in generated Dockerfiles
* and managed-service charts (e.g. `node:20-alpine` →
* `registry.abrban.com/proxy-dockerhub/library/node:20-alpine`).
*/
baseImageRegistry: (process.env.BASE_IMAGE_REGISTRY || 'registry.abrban.com/proxy-dockerhub/library')
.trim()
.replace(/\/+$/, ''),
/** Full image refs for Kaniko jobs — override via Helm values or env. */
images: {
kaniko:
process.env.KANIKO_IMAGE ||
'registry.abrban.com/abrban/kaniko-executor:v1.23.2',
alpine: (process.env.BUILD_ALPINE_IMAGE || 'registry.abrban.com/abrban/alpine:3.19').trim(),
alpineGit: (process.env.BUILD_ALPINE_GIT_IMAGE || 'registry.abrban.com/abrban/alpine-git:2.43.0').trim(),
},
/**
* Secret name with HTTP_PROXY / HTTPS_PROXY / NO_PROXY for build pods
* (Kaniko RUN steps: npm, apk, composer, pip; init containers: apk, git clone).
* Empty = disabled (clusters with direct egress).
*/
egressProxySecret: (process.env.BUILD_EGRESS_PROXY_SECRET || '').trim(),
/** Kaniko build container resources — tune for large images. */
kaniko: {
cpuRequest: process.env.KANIKO_CPU_REQUEST || '500m',
cpuLimit: process.env.KANIKO_CPU_LIMIT || '2',
memoryRequest: process.env.KANIKO_MEMORY_REQUEST || '1Gi',
memoryLimit: process.env.KANIKO_MEMORY_LIMIT || '4Gi',
},
},
elasticsearch: {
/** API host for log search. In-cluster: cluster DNS. Local dev: loopback + auto port-forward. */
host:
process.env.ELASTICSEARCH_HOST ||
(process.env.KUBERNETES_SERVICE_HOST
? 'elasticsearch.logging.svc.cluster.local'
: '127.0.0.1'),
port: parseInt(process.env.ELASTICSEARCH_PORT || '9200', 10),
/** In development with loopback host, start kubectl port-forward on API boot (set false to manage manually). */
autoPortForward: process.env.ELASTICSEARCH_AUTO_PORT_FORWARD ?? 'true',
password: process.env.ELASTIC_PASSWORD || 'CloudHost2024!Secure',
fluentbitPassword: process.env.FLUENTBIT_PASSWORD || 'FluentBit2024!Writer',
kibanaPassword: process.env.KIBANA_SYSTEM_PASSWORD || 'Kibana2024!System',
/** Override when cluster nodes cannot reach docker.elastic.co (mirror to local registry). */
images: {
elasticsearch:
process.env.LOGGING_ELASTICSEARCH_IMAGE ||
'docker.elastic.co/elasticsearch/elasticsearch:8.12.0',
kibana: process.env.LOGGING_KIBANA_IMAGE || 'docker.elastic.co/kibana/kibana:8.12.0',
busybox: process.env.LOGGING_BUSYBOX_IMAGE || 'busybox:1.36',
curl: process.env.LOGGING_CURL_IMAGE || 'curlimages/curl:8.5.0',
},
},
sourceStorage: {
endpoint: process.env.SOURCE_STORAGE_ENDPOINT,
region: process.env.SOURCE_STORAGE_REGION || 'us-east-1',
bucket: process.env.SOURCE_STORAGE_BUCKET,
accessKey: process.env.SOURCE_STORAGE_ACCESS_KEY,
secretKey: process.env.SOURCE_STORAGE_SECRET_KEY,
},
platform: {
domain: resolvePlatformDomainFromEnv(),
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',
/** StorageClass for new PVCs; must support allowVolumeExpansion for disk resize */
storageClass: process.env.PLATFORM_STORAGE_CLASS || 'cloudhost-expandable',
/** Install cloudhost-expandable StorageClass via Helm on each app deploy */
createStorageClass: process.env.PLATFORM_CREATE_STORAGE_CLASS !== 'false',
storageProvisioner: process.env.PLATFORM_STORAGE_PROVISIONER || 'rancher.io/local-path',
},
// Lifecycle defaults (can be overridden via PlatformSettings entity by admin)
lifecycle: {
// How long after plan expires to suspend the app (scale to 0)
hourly: {
suspendAfterMs: parseInt(process.env.LIFECYCLE_HOURLY_SUSPEND_MS || String(0), 10), // immediately on expiry
deleteAfterMs: parseInt(process.env.LIFECYCLE_HOURLY_DELETE_MS || String(24 * 3600 * 1000), 10), // 24h after suspend
},
monthly: {
suspendAfterMs: parseInt(process.env.LIFECYCLE_MONTHLY_SUSPEND_MS || String(0), 10),
deleteAfterMs: parseInt(process.env.LIFECYCLE_MONTHLY_DELETE_MS || String(3 * 24 * 3600 * 1000), 10), // 3 days
},
yearly: {
suspendAfterMs: parseInt(process.env.LIFECYCLE_YEARLY_SUSPEND_MS || String(0), 10),
deleteAfterMs: parseInt(process.env.LIFECYCLE_YEARLY_DELETE_MS || String(7 * 24 * 3600 * 1000), 10), // 7 days
},
// Cron interval for the lifecycle scanner
scanIntervalMs: parseInt(process.env.LIFECYCLE_SCAN_INTERVAL_MS || String(60_000), 10), // 1 min
},
});