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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 { ClustersService } from '../clusters/clusters.service';
|
||||
import { UserRole, DatabaseType, CustomDomainStatus, AppRuntime } from '../common/enums';
|
||||
import { ensureAppUrlEnv } from './app-url.util';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationsService {
|
||||
@@ -82,6 +83,9 @@ export class ApplicationsService {
|
||||
? 80
|
||||
: 3000;
|
||||
|
||||
const subdomain = `${dto.name}-${userId.split('-')[0]}`;
|
||||
const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir';
|
||||
|
||||
const app = this.appsRepository.create({
|
||||
...dto,
|
||||
userId,
|
||||
@@ -90,9 +94,20 @@ export class ApplicationsService {
|
||||
dbUsername,
|
||||
dbPassword,
|
||||
port: dto.port ?? defaultPort,
|
||||
subdomain: `${dto.name}-${userId.split('-')[0]}`,
|
||||
subdomain,
|
||||
customDomain: customDomain || undefined,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import * as dns from 'dns';
|
||||
import { Application } from './entities/application.entity';
|
||||
import { PlatformSetting } from '../billing/entities/platform-setting.entity';
|
||||
import { CustomDomainStatus } from '../common/enums';
|
||||
import { ensureAppUrlEnv } from './app-url.util';
|
||||
|
||||
@Injectable()
|
||||
export class DomainService {
|
||||
@@ -93,6 +94,8 @@ export class DomainService {
|
||||
if (isValid) {
|
||||
app.customDomainStatus = CustomDomainStatus.VERIFIED;
|
||||
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);
|
||||
this.logger.log(`DNS verified for ${app.name}: ${app.customDomain}`);
|
||||
return {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { promisify } from 'util';
|
||||
import { PassThrough } from 'stream';
|
||||
import { ClustersService } from '../clusters/clusters.service';
|
||||
import { Application } from '../applications/entities/application.entity';
|
||||
import { ensureAppUrlEnv } from '../applications/app-url.util';
|
||||
import { AppRuntime, DatabaseType, CustomDomainStatus } from '../common/enums';
|
||||
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.
|
||||
*/
|
||||
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> {
|
||||
const domain = this.configService.get('platform.domain');
|
||||
const pullRegistryUrl = this.configService.get<string>('registry.pullUrl') || 'localhost:30500';
|
||||
@@ -124,7 +130,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
memoryRequest: app.memoryRequest,
|
||||
memoryLimit: app.memoryLimit,
|
||||
},
|
||||
envVars: app.envVars || {},
|
||||
envVars: this.resolveEnvVars(app),
|
||||
ingress: {
|
||||
enabled: true,
|
||||
subdomain: app.subdomain || app.name,
|
||||
@@ -224,7 +230,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
cpuLimit: app.cpuLimit,
|
||||
memoryRequest: app.memoryRequest,
|
||||
memoryLimit: app.memoryLimit,
|
||||
envVars: app.envVars || {},
|
||||
envVars: this.resolveEnvVars(app),
|
||||
runtime: app.runtime,
|
||||
databaseType: app.databaseType,
|
||||
domain,
|
||||
@@ -282,7 +288,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
cpuLimit: app.cpuLimit,
|
||||
memoryRequest: app.memoryRequest,
|
||||
memoryLimit: app.memoryLimit,
|
||||
envVars: app.envVars || {},
|
||||
envVars: this.resolveEnvVars(app),
|
||||
runtime: app.runtime,
|
||||
databaseType: app.databaseType,
|
||||
domain: domain,
|
||||
|
||||
Reference in New Issue
Block a user