mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
Enable storefront provision from Add Domain via optional git URL.
Persist deploy_slug on domains, call the websites agent /provision endpoint, and drop the hard-coded host map so Deploy appears from the UI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
9ca6e2306f
commit
d933aef40b
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { WebsiteDeployModule } from '../website-deploy/website-deploy.module';
|
||||
import { BusinessCategoriesController } from './business-categories.controller';
|
||||
import { BusinessCategoriesService } from './business-categories.service';
|
||||
import { BusinessAdminController } from './business-admin.controller';
|
||||
@@ -8,7 +9,7 @@ import { LegacyMigrateService } from './legacy-migrate.service';
|
||||
import { LegacyPurgeService } from './legacy-purge.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, WebsiteDeployModule],
|
||||
controllers: [BusinessAdminController, BusinessCategoriesController],
|
||||
providers: [
|
||||
BusinessAdminService,
|
||||
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||
DEFAULT_HOME_CHARTS,
|
||||
} from '../business-settings/business-settings.types';
|
||||
import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service';
|
||||
import {
|
||||
deploySlugFromHost,
|
||||
isValidGitRepoUrl,
|
||||
} from '../website-deploy/website-deploy.util';
|
||||
|
||||
type BusinessRow = {
|
||||
id: bigint;
|
||||
@@ -75,6 +80,7 @@ export class BusinessAdminService {
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly legacyMigrate: LegacyMigrateService,
|
||||
private readonly legacyPurge: LegacyPurgeService,
|
||||
private readonly websiteDeployAgent: WebsiteDeployAgentService,
|
||||
) {}
|
||||
|
||||
private async assertSuperAdmin(actor: AuthUser) {
|
||||
@@ -451,17 +457,29 @@ export class BusinessAdminService {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const host = dto.host.trim();
|
||||
const host = dto.host.trim().toLowerCase();
|
||||
const gitRepoUrl = dto.gitRepoUrl?.trim() || null;
|
||||
|
||||
if (!host) {
|
||||
throw new BadRequestException('host is required');
|
||||
}
|
||||
|
||||
if (gitRepoUrl && !isValidGitRepoUrl(gitRepoUrl)) {
|
||||
throw new BadRequestException(
|
||||
'gitRepoUrl must be an SSH git URL (e.g. git@git.meshkee.com:Meshkee-Websites/oaktasty.git)',
|
||||
);
|
||||
}
|
||||
|
||||
const business = await this.prisma.business.findUnique({ where: { id: businessId } });
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
const existingHost = await this.prisma.domain.findUnique({ where: { host } });
|
||||
if (existingHost) {
|
||||
throw new ConflictException('Domain host is already taken');
|
||||
}
|
||||
|
||||
const hasPrimary = await this.prisma.domain.findFirst({
|
||||
where: { businessId, isPrimary: true },
|
||||
select: { id: true },
|
||||
@@ -469,15 +487,55 @@ export class BusinessAdminService {
|
||||
|
||||
const isPrimary = dto.isPrimary ?? !hasPrimary;
|
||||
|
||||
return this.prisma.domain.create({
|
||||
let deploySlug: string | null = null;
|
||||
let provisionError: string | null = null;
|
||||
|
||||
if (gitRepoUrl) {
|
||||
deploySlug = deploySlugFromHost(host);
|
||||
if (!deploySlug) {
|
||||
throw new BadRequestException('Could not derive deploy slug from host');
|
||||
}
|
||||
|
||||
const slugTaken = await this.prisma.domain.findFirst({
|
||||
where: { deploySlug },
|
||||
select: { id: true },
|
||||
});
|
||||
if (slugTaken) {
|
||||
throw new ConflictException(`Deploy slug "${deploySlug}" is already in use`);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.websiteDeployAgent.provision({
|
||||
slug: deploySlug,
|
||||
host,
|
||||
gitRepoUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
|
||||
provisionError = err.message;
|
||||
} else {
|
||||
provisionError = 'Storefront provision failed on websites server';
|
||||
}
|
||||
deploySlug = null;
|
||||
}
|
||||
}
|
||||
|
||||
const domain = await this.prisma.domain.create({
|
||||
data: {
|
||||
businessId,
|
||||
host,
|
||||
isPrimary,
|
||||
isVerified: false,
|
||||
sslEnabled: false,
|
||||
deploySlug: provisionError ? null : deploySlug,
|
||||
gitRepoUrl: provisionError ? null : gitRepoUrl,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...domain,
|
||||
provisionError,
|
||||
};
|
||||
}
|
||||
|
||||
async updateDomain(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { IsBoolean, IsOptional, IsString, Matches, MinLength } from 'class-validator';
|
||||
|
||||
export class AddDomainDto {
|
||||
@IsString()
|
||||
@@ -8,5 +8,14 @@ export class AddDomainDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPrimary?: boolean;
|
||||
|
||||
/** SSH git URL — when set, provisions storefront deploy on the websites VM. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^(?:git@[\w.-]+:[\w./-]+\.git|ssh:\/\/git@[\w.-]+(?::\d+)?\/[\w./-]+\.git)$/i, {
|
||||
message:
|
||||
'gitRepoUrl must be an SSH git URL (e.g. git@git.meshkee.com:Meshkee-Websites/oaktasty.git)',
|
||||
})
|
||||
gitRepoUrl?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { WebsiteDeployModule } from '../website-deploy/website-deploy.module';
|
||||
import { DomainAdminController } from './domain-admin.controller';
|
||||
import { DomainAdminService } from './domain-admin.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, ConfigModule],
|
||||
imports: [AuthModule, ConfigModule, WebsiteDeployModule],
|
||||
controllers: [DomainAdminController],
|
||||
providers: [DomainAdminService],
|
||||
})
|
||||
|
||||
@@ -11,17 +11,12 @@ import { Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service';
|
||||
import { DisableDomainDto } from './dto/disable-domain.dto';
|
||||
import { ListDomainsDto } from './dto/list-domains.dto';
|
||||
import { ToggleSslDto } from './dto/toggle-ssl.dto';
|
||||
import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto';
|
||||
|
||||
/** Apex hosts that have a storefront deploy on the websites VM. */
|
||||
const WEBSITE_DEPLOY_SLUGS: Record<string, string> = {
|
||||
'ali-mohammadi.ir': 'ali-mohammadi',
|
||||
'meshkee.com': 'meshkee',
|
||||
};
|
||||
|
||||
type DomainRow = {
|
||||
id: bigint;
|
||||
host: string;
|
||||
@@ -33,6 +28,7 @@ type DomainRow = {
|
||||
createdAt: Date;
|
||||
lastDeployedAt: Date | null;
|
||||
lastDeployStatus: string | null;
|
||||
deploySlug: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -41,6 +37,7 @@ export class DomainAdminService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly websiteDeployAgent: WebsiteDeployAgentService,
|
||||
) {}
|
||||
|
||||
private async assertSuperAdmin(actor: AuthUser) {
|
||||
@@ -49,10 +46,6 @@ export class DomainAdminService {
|
||||
}
|
||||
}
|
||||
|
||||
private deploySlugForHost(host: string): string | null {
|
||||
return WEBSITE_DEPLOY_SLUGS[host.trim().toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
async list(query: ListDomainsDto, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
@@ -78,7 +71,8 @@ export class DomainAdminService {
|
||||
d.expires_at AS "expiresAt",
|
||||
d.created_at AS "createdAt",
|
||||
d.last_deployed_at AS "lastDeployedAt",
|
||||
d.last_deploy_status AS "lastDeployStatus"
|
||||
d.last_deploy_status AS "lastDeployStatus",
|
||||
d.deploy_slug AS "deploySlug"
|
||||
FROM domains d
|
||||
JOIN businesses b ON b.id = d.business_id
|
||||
${where}
|
||||
@@ -92,12 +86,7 @@ export class DomainAdminService {
|
||||
`),
|
||||
]);
|
||||
|
||||
const items = rows.map((row) => ({
|
||||
...row,
|
||||
deploySlug: this.deploySlugForHost(row.host),
|
||||
}));
|
||||
|
||||
return { items, total: totalRow[0]?.total ?? 0, page, pageSize };
|
||||
return { items: rows, total: totalRow[0]?.total ?? 0, page, pageSize };
|
||||
}
|
||||
|
||||
async syncSsl(actor: AuthUser) {
|
||||
@@ -149,17 +138,11 @@ export class DomainAdminService {
|
||||
throw new NotFoundException('Domain not found');
|
||||
}
|
||||
|
||||
const slug = this.deploySlugForHost(domain.host);
|
||||
const slug = domain.deploySlug?.trim() || null;
|
||||
if (!slug) {
|
||||
throw new BadRequestException('This domain has no storefront deploy configured');
|
||||
}
|
||||
|
||||
const agentUrl = this.config.get<string>('WEBSITE_DEPLOY_AGENT_URL')?.trim();
|
||||
const token = this.config.get<string>('WEBSITE_DEPLOY_TOKEN')?.trim();
|
||||
if (!agentUrl || !token) {
|
||||
throw new ServiceUnavailableException('Website deploy agent is not configured');
|
||||
}
|
||||
|
||||
const markDeploy = async (status: 'started' | 'failed') => {
|
||||
const updated = await this.prisma.domain.update({
|
||||
where: { id: domainId },
|
||||
@@ -171,26 +154,15 @@ export class DomainAdminService {
|
||||
return updated;
|
||||
};
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(agentUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Deploy-Token': token,
|
||||
},
|
||||
body: JSON.stringify({ slug }),
|
||||
});
|
||||
} catch {
|
||||
await this.websiteDeployAgent.deploy(slug);
|
||||
} catch (err) {
|
||||
await markDeploy('failed');
|
||||
throw new ServiceUnavailableException('Could not reach website deploy agent');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
await markDeploy('failed');
|
||||
const text = await response.text().catch(() => '');
|
||||
if (err instanceof ServiceUnavailableException) {
|
||||
throw err;
|
||||
}
|
||||
throw new ServiceUnavailableException(
|
||||
`Deploy agent rejected request (${response.status})${text ? `: ${text}` : ''}`,
|
||||
err instanceof Error ? err.message : 'Deploy failed to start',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { provisionUrlFromDeployUrl } from './website-deploy.util';
|
||||
|
||||
@Injectable()
|
||||
export class WebsiteDeployAgentService {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
private credentials() {
|
||||
const deployUrl = this.config.get<string>('WEBSITE_DEPLOY_AGENT_URL')?.trim();
|
||||
const token = this.config.get<string>('WEBSITE_DEPLOY_TOKEN')?.trim();
|
||||
if (!deployUrl || !token) {
|
||||
throw new ServiceUnavailableException('Website deploy agent is not configured');
|
||||
}
|
||||
return { deployUrl, token };
|
||||
}
|
||||
|
||||
async provision(input: { slug: string; host: string; gitRepoUrl: string }) {
|
||||
const { deployUrl, token } = this.credentials();
|
||||
const url = provisionUrlFromDeployUrl(deployUrl);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Deploy-Token': token,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
slug: input.slug,
|
||||
host: input.host,
|
||||
gitRepoUrl: input.gitRepoUrl,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('Could not reach website deploy agent');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new ServiceUnavailableException(
|
||||
`Provision agent rejected request (${response.status})${text ? `: ${text}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json().catch(() => ({ status: 'accepted' }));
|
||||
}
|
||||
|
||||
async deploy(slug: string) {
|
||||
const { deployUrl, token } = this.credentials();
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(deployUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Deploy-Token': token,
|
||||
},
|
||||
body: JSON.stringify({ slug }),
|
||||
});
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('Could not reach website deploy agent');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new ServiceUnavailableException(
|
||||
`Deploy agent rejected request (${response.status})${text ? `: ${text}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json().catch(() => ({ status: 'accepted', slug }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WebsiteDeployAgentService } from './website-deploy-agent.service';
|
||||
|
||||
@Module({
|
||||
providers: [WebsiteDeployAgentService],
|
||||
exports: [WebsiteDeployAgentService],
|
||||
})
|
||||
export class WebsiteDeployModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Derive storefront slug from apex host: oaktasty.com → oaktasty, ali-mohammadi.ir → ali-mohammadi */
|
||||
export function deploySlugFromHost(host: string): string {
|
||||
const h = host.trim().toLowerCase();
|
||||
const parts = h.split('.').filter(Boolean);
|
||||
const base = parts.length >= 2 ? parts.slice(0, -1).join('-') : parts[0] ?? h;
|
||||
return base
|
||||
.replace(/[^a-z0-9-]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
const GIT_SSH_RE =
|
||||
/^(?:git@[\w.-]+:[\w./-]+\.git|ssh:\/\/git@[\w.-]+(?::\d+)?\/[\w./-]+\.git)$/i;
|
||||
|
||||
export function isValidGitRepoUrl(url: string): boolean {
|
||||
return GIT_SSH_RE.test(url.trim());
|
||||
}
|
||||
|
||||
/** Turn .../deploy into .../provision (or append /provision if bare). */
|
||||
export function provisionUrlFromDeployUrl(deployUrl: string): string {
|
||||
const trimmed = deployUrl.trim().replace(/\/+$/, '');
|
||||
if (/\/deploy$/i.test(trimmed)) {
|
||||
return trimmed.replace(/\/deploy$/i, '/provision');
|
||||
}
|
||||
return `${trimmed}/provision`;
|
||||
}
|
||||
Reference in New Issue
Block a user