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 { 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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user