feat(laravel): auto-set APP_URL from platform or custom domain

Inject APP_URL for Laravel/PHP apps when missing: on create from the
platform subdomain, on custom-domain verification from the verified host,
and at deploy time as a safety net. Never overwrites user-provided values.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-15 00:51:09 +03:30
parent b6c8996a2c
commit abd8848180
5 changed files with 157 additions and 4 deletions
@@ -0,0 +1,83 @@
import { AppRuntime, CustomDomainStatus } from '../common/enums';
import {
ensureAppUrlEnv,
getApplicationPublicUrl,
hasAppUrlEnv,
usesAppUrl,
} from './app-url.util';
describe('app-url.util', () => {
const platformDomain = 'apps.cloudhost.ir';
it('detects Laravel and PHP as APP_URL runtimes', () => {
expect(usesAppUrl(AppRuntime.LARAVEL)).toBe(true);
expect(usesAppUrl(AppRuntime.PHP)).toBe(true);
expect(usesAppUrl(AppRuntime.NODEJS)).toBe(false);
});
it('builds platform URL from subdomain', () => {
expect(
getApplicationPublicUrl(
{
name: 'my-app',
subdomain: 'my-app-abc',
customDomain: undefined,
customDomainStatus: CustomDomainStatus.NONE,
},
platformDomain,
),
).toBe('https://my-app-abc.apps.cloudhost.ir');
});
it('prefers verified custom domain for public URL', () => {
expect(
getApplicationPublicUrl(
{
name: 'my-app',
subdomain: 'my-app-abc',
customDomain: 'www.example.com',
customDomainStatus: CustomDomainStatus.VERIFIED,
},
platformDomain,
),
).toBe('https://www.example.com');
});
it('sets APP_URL when missing for Laravel apps', () => {
const envVars = ensureAppUrlEnv(
{
name: 'laravel2',
runtime: AppRuntime.LARAVEL,
subdomain: 'laravel2-c02087e3',
customDomain: undefined,
customDomainStatus: CustomDomainStatus.NONE,
envVars: { APP_ENV: 'production' },
},
platformDomain,
);
expect(envVars.APP_URL).toBe('https://laravel2-c02087e3.apps.cloudhost.ir');
expect(envVars.APP_ENV).toBe('production');
});
it('does not overwrite user-provided APP_URL', () => {
const envVars = ensureAppUrlEnv(
{
name: 'laravel2',
runtime: AppRuntime.LARAVEL,
subdomain: 'laravel2-c02087e3',
customDomain: 'www.example.com',
customDomainStatus: CustomDomainStatus.VERIFIED,
envVars: { APP_URL: 'https://custom.local' },
},
platformDomain,
);
expect(envVars.APP_URL).toBe('https://custom.local');
});
it('treats empty APP_URL as missing', () => {
expect(hasAppUrlEnv({ APP_URL: ' ' })).toBe(false);
expect(hasAppUrlEnv({ APP_URL: 'null' })).toBe(false);
});
});
+46
View File
@@ -0,0 +1,46 @@
import { AppRuntime, CustomDomainStatus } from '../common/enums';
import { Application } from './entities/application.entity';
type AppUrlContext = Pick<
Application,
'subdomain' | 'customDomain' | 'customDomainStatus' | 'name' | 'envVars' | 'runtime'
>;
const APP_URL_RUNTIMES = new Set<AppRuntime>([AppRuntime.LARAVEL, AppRuntime.PHP]);
export function usesAppUrl(runtime: AppRuntime): boolean {
return APP_URL_RUNTIMES.has(runtime);
}
export function hasAppUrlEnv(envVars?: Record<string, string> | null): boolean {
const value = envVars?.APP_URL?.trim();
return !!value && value !== 'null';
}
export function getApplicationPublicHost(
app: Pick<Application, 'subdomain' | 'customDomain' | 'customDomainStatus' | 'name'>,
platformDomain: string,
): string {
if (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED) {
return app.customDomain;
}
const subdomain = app.subdomain || app.name;
return `${subdomain}.${platformDomain}`;
}
export function getApplicationPublicUrl(
app: Pick<Application, 'subdomain' | 'customDomain' | 'customDomainStatus' | 'name'>,
platformDomain: string,
): string {
return `https://${getApplicationPublicHost(app, platformDomain)}`;
}
/** Set APP_URL from the active public domain when the user has not provided one. */
export function ensureAppUrlEnv(app: AppUrlContext, platformDomain: string): Record<string, string> {
const envVars = { ...(app.envVars || {}) };
if (!usesAppUrl(app.runtime) || hasAppUrlEnv(envVars)) {
return envVars;
}
envVars.APP_URL = getApplicationPublicUrl(app, platformDomain);
return envVars;
}
@@ -9,6 +9,7 @@ import { Application } from './entities/application.entity';
import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto'; import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto';
import { ClustersService } from '../clusters/clusters.service'; import { ClustersService } from '../clusters/clusters.service';
import { UserRole, DatabaseType, CustomDomainStatus, AppRuntime } from '../common/enums'; import { UserRole, DatabaseType, CustomDomainStatus, AppRuntime } from '../common/enums';
import { ensureAppUrlEnv } from './app-url.util';
@Injectable() @Injectable()
export class ApplicationsService { export class ApplicationsService {
@@ -82,6 +83,9 @@ export class ApplicationsService {
? 80 ? 80
: 3000; : 3000;
const subdomain = `${dto.name}-${userId.split('-')[0]}`;
const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir';
const app = this.appsRepository.create({ const app = this.appsRepository.create({
...dto, ...dto,
userId, userId,
@@ -90,9 +94,20 @@ export class ApplicationsService {
dbUsername, dbUsername,
dbPassword, dbPassword,
port: dto.port ?? defaultPort, port: dto.port ?? defaultPort,
subdomain: `${dto.name}-${userId.split('-')[0]}`, subdomain,
customDomain: customDomain || undefined, customDomain: customDomain || undefined,
customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE, customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE,
envVars: ensureAppUrlEnv(
{
name: dto.name,
runtime: dto.runtime,
subdomain,
customDomain: customDomain || undefined,
customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE,
envVars: dto.envVars,
},
platformDomain,
),
}); });
return this.appsRepository.save(app); return this.appsRepository.save(app);
} }
@@ -12,6 +12,7 @@ import * as dns from 'dns';
import { Application } from './entities/application.entity'; import { Application } from './entities/application.entity';
import { PlatformSetting } from '../billing/entities/platform-setting.entity'; import { PlatformSetting } from '../billing/entities/platform-setting.entity';
import { CustomDomainStatus } from '../common/enums'; import { CustomDomainStatus } from '../common/enums';
import { ensureAppUrlEnv } from './app-url.util';
@Injectable() @Injectable()
export class DomainService { export class DomainService {
@@ -93,6 +94,8 @@ export class DomainService {
if (isValid) { if (isValid) {
app.customDomainStatus = CustomDomainStatus.VERIFIED; app.customDomainStatus = CustomDomainStatus.VERIFIED;
app.customDomainVerifiedAt = new Date(); app.customDomainVerifiedAt = new Date();
const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir';
app.envVars = ensureAppUrlEnv(app, platformDomain);
const saved = await this.appRepo.save(app); const saved = await this.appRepo.save(app);
this.logger.log(`DNS verified for ${app.name}: ${app.customDomain}`); this.logger.log(`DNS verified for ${app.name}: ${app.customDomain}`);
return { return {
+9 -3
View File
@@ -8,6 +8,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 { ensureAppUrlEnv } from '../applications/app-url.util';
import { AppRuntime, DatabaseType, CustomDomainStatus } from '../common/enums'; import { AppRuntime, DatabaseType, CustomDomainStatus } from '../common/enums';
import { HelmService } from './helm.service'; import { HelmService } from './helm.service';
@@ -101,6 +102,11 @@ export class KubernetesService implements OnModuleInit {
/** /**
* Build Helm values object from an Application entity and image URI. * Build Helm values object from an Application entity and image URI.
*/ */
private resolveEnvVars(app: Application): Record<string, string> {
const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir';
return ensureAppUrlEnv(app, platformDomain);
}
private buildHelmValues(app: Application, imageUri: string): Record<string, any> { private buildHelmValues(app: Application, imageUri: string): Record<string, any> {
const domain = this.configService.get('platform.domain'); const domain = this.configService.get('platform.domain');
const pullRegistryUrl = this.configService.get<string>('registry.pullUrl') || 'localhost:30500'; const pullRegistryUrl = this.configService.get<string>('registry.pullUrl') || 'localhost:30500';
@@ -124,7 +130,7 @@ export class KubernetesService implements OnModuleInit {
memoryRequest: app.memoryRequest, memoryRequest: app.memoryRequest,
memoryLimit: app.memoryLimit, memoryLimit: app.memoryLimit,
}, },
envVars: app.envVars || {}, envVars: this.resolveEnvVars(app),
ingress: { ingress: {
enabled: true, enabled: true,
subdomain: app.subdomain || app.name, subdomain: app.subdomain || app.name,
@@ -224,7 +230,7 @@ export class KubernetesService implements OnModuleInit {
cpuLimit: app.cpuLimit, cpuLimit: app.cpuLimit,
memoryRequest: app.memoryRequest, memoryRequest: app.memoryRequest,
memoryLimit: app.memoryLimit, memoryLimit: app.memoryLimit,
envVars: app.envVars || {}, envVars: this.resolveEnvVars(app),
runtime: app.runtime, runtime: app.runtime,
databaseType: app.databaseType, databaseType: app.databaseType,
domain, domain,
@@ -282,7 +288,7 @@ export class KubernetesService implements OnModuleInit {
cpuLimit: app.cpuLimit, cpuLimit: app.cpuLimit,
memoryRequest: app.memoryRequest, memoryRequest: app.memoryRequest,
memoryLimit: app.memoryLimit, memoryLimit: app.memoryLimit,
envVars: app.envVars || {}, envVars: this.resolveEnvVars(app),
runtime: app.runtime, runtime: app.runtime,
databaseType: app.databaseType, databaseType: app.databaseType,
domain: domain, domain: domain,