diff --git a/.env.example b/.env.example index 04aec4c..efe46ad 100644 --- a/.env.example +++ b/.env.example @@ -78,6 +78,8 @@ DASHBOARD_ADMIN_HOST=manage.meshkee.com CENTRAL_API_HOST=api.meshkee.com # Website storefront deploy agent (POST from Super Admin → websites VM) +# Deploy: WEBSITE_DEPLOY_AGENT_URL=.../deploy +# Provision: derived as same origin .../provision when Add Domain includes gitRepoUrl WEBSITE_DEPLOY_AGENT_URL=http://89.44.241.119:9050/deploy WEBSITE_DEPLOY_TOKEN= diff --git a/database/migrations/051_domain_deploy_slug.sql b/database/migrations/051_domain_deploy_slug.sql new file mode 100644 index 0000000..d056d8e --- /dev/null +++ b/database/migrations/051_domain_deploy_slug.sql @@ -0,0 +1,21 @@ +-- Storefront deploy wiring (Super Admin Add Domain + git URL) +ALTER TABLE domains + ADD COLUMN IF NOT EXISTS deploy_slug VARCHAR(64), + ADD COLUMN IF NOT EXISTS git_repo_url VARCHAR(512); + +CREATE UNIQUE INDEX IF NOT EXISTS domains_deploy_slug_unique + ON domains (deploy_slug) + WHERE deploy_slug IS NOT NULL; + +-- Existing websites VM sites +UPDATE domains +SET deploy_slug = 'ali-mohammadi', + git_repo_url = COALESCE(git_repo_url, 'git@git.meshkee.com:Meshkee-Websites/ali-mohammadi.git') +WHERE host = 'ali-mohammadi.ir' + AND deploy_slug IS NULL; + +UPDATE domains +SET deploy_slug = 'meshkee', + git_repo_url = COALESCE(git_repo_url, 'git@git.meshkee.com:Meshkee-Websites/meshkee.git') +WHERE host = 'meshkee.com' + AND deploy_slug IS NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1f24664..3a61029 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -151,6 +151,8 @@ model Domain { isActive Boolean @default(true) @map("is_active") lastDeployedAt DateTime? @map("last_deployed_at") @db.Timestamptz(6) lastDeployStatus String? @map("last_deploy_status") @db.VarChar(32) + deploySlug String? @map("deploy_slug") @db.VarChar(64) + gitRepoUrl String? @map("git_repo_url") @db.VarChar(512) business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) @@index([businessId], map: "idx_domains_business_id") diff --git a/scripts/websites-agent/README.md b/scripts/websites-agent/README.md new file mode 100644 index 0000000..38db041 --- /dev/null +++ b/scripts/websites-agent/README.md @@ -0,0 +1,8 @@ +# Websites deploy / provision agent (runs on websites VM: /opt/websites-agent) +# +# Endpoints (X-Deploy-Token): +# POST /deploy { slug } — git pull + build + pm2 restart +# POST /provision { slug, host, gitRepoUrl } — clone + nginx + ecosystem + allowlist +# GET /health +# +# Env (.env): PORT, DEPLOY_TOKEN, ALLOWED_SLUGS diff --git a/scripts/websites-agent/deploy.sh b/scripts/websites-agent/deploy.sh new file mode 100755 index 0000000..2b54939 --- /dev/null +++ b/scripts/websites-agent/deploy.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +SLUG="${1:-}" +if [[ -z "$SLUG" ]]; then + echo "usage: deploy.sh " >&2 + exit 1 +fi + +ROOT="/var/www/sites/$SLUG" +LOG="/var/log/websites/deploy-$SLUG.log" +exec >>"$LOG" 2>&1 + +echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy start: $SLUG ====" + +if [[ ! -d "$ROOT/.git" ]]; then + echo "missing site: $ROOT" + exit 1 +fi + +cd "$ROOT" +git fetch origin +git reset --hard origin/main +npm ci +npm run build +pm2 restart "$SLUG" --update-env || pm2 start /var/www/sites/ecosystem.config.cjs --only "$SLUG" +echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy ok: $SLUG ====" diff --git a/scripts/websites-agent/provision.sh b/scripts/websites-agent/provision.sh new file mode 100755 index 0000000..d6c3b68 --- /dev/null +++ b/scripts/websites-agent/provision.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# Provision a new storefront site on the websites VM (clone + nginx + pm2 entry + allowlist). +# Does NOT run npm ci/build — first build happens via deploy.sh (Super Admin Deploy). +set -euo pipefail + +SLUG="${1:-}" +HOST="${2:-}" +GIT_REPO_URL="${3:-}" + +if [[ -z "$SLUG" || -z "$HOST" || -z "$GIT_REPO_URL" ]]; then + echo "usage: provision.sh " >&2 + exit 1 +fi + +if [[ ! "$SLUG" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "invalid slug: $SLUG" >&2 + exit 1 +fi + +if [[ ! "$HOST" =~ ^[a-z0-9.-]+$ ]]; then + echo "invalid host: $HOST" >&2 + exit 1 +fi + +ROOT="/var/www/sites/$SLUG" +ECOSYSTEM="/var/www/sites/ecosystem.config.cjs" +AGENT_DIR="/opt/websites-agent" +ENV_FILE="$AGENT_DIR/.env" +LOG_DIR="/var/log/websites" +NGINX_AVAILABLE="/etc/nginx/sites-available/$HOST" +NGINX_ENABLED="/etc/nginx/sites-enabled/$HOST" +SSH_KEY="/root/.ssh/websites_deploy" + +mkdir -p "$LOG_DIR" /var/www/sites + +export GIT_SSH_COMMAND="ssh -i $SSH_KEY -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" + +if [[ ! -d "$ROOT/.git" ]]; then + echo "cloning $GIT_REPO_URL → $ROOT" + rm -rf "$ROOT" + git clone "$GIT_REPO_URL" "$ROOT" +else + echo "site already cloned: $ROOT" +fi + +PORT="$( + SLUG="$SLUG" ECOSYSTEM="$ECOSYSTEM" node <<'NODE' +const fs = require('fs'); +const path = process.env.ECOSYSTEM; +const slug = process.env.SLUG; +let cfg = { apps: [] }; +try { + delete require.cache[require.resolve(path)]; + cfg = require(path); + if (!Array.isArray(cfg.apps)) cfg.apps = []; +} catch (_) { + cfg = { apps: [] }; +} + +const existing = cfg.apps.find((a) => a.name === slug); +if (existing) { + const fromEnv = Number(existing.env && existing.env.PORT); + const m = String(existing.args || '').match(/--port\s+(\d+)/); + const port = Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : m ? Number(m[1]) : 3005; + process.stdout.write(String(port)); + process.exit(0); +} + +const used = new Set(); +for (const app of cfg.apps) { + const fromEnv = Number(app.env && app.env.PORT); + if (Number.isFinite(fromEnv) && fromEnv > 0) used.add(fromEnv); + const args = String(app.args || ''); + const m = args.match(/--port\s+(\d+)/); + if (m) used.add(Number(m[1])); +} + +let port = 3005; +while (used.has(port)) port += 1; + +cfg.apps.push({ + name: slug, + cwd: '/var/www/sites/' + slug, + script: 'node_modules/next/dist/bin/next', + args: 'start --hostname 127.0.0.1 --port ' + port, + env: { NODE_ENV: 'production', PORT: String(port) }, + error_file: '/var/log/websites/' + slug + '-error.log', + out_file: '/var/log/websites/' + slug + '-out.log', + time: true, +}); +const lines = ['module.exports = {', ' apps: [']; +cfg.apps.forEach((app, idx) => { + lines.push(' {'); + lines.push(` name: ${JSON.stringify(app.name)},`); + lines.push(` cwd: ${JSON.stringify(app.cwd)},`); + lines.push(` script: ${JSON.stringify(app.script)},`); + lines.push(` args: ${JSON.stringify(app.args)},`); + lines.push(' env: {'); + lines.push(` NODE_ENV: ${JSON.stringify(app.env.NODE_ENV)},`); + lines.push(` PORT: ${JSON.stringify(app.env.PORT)},`); + lines.push(' },'); + lines.push(` error_file: ${JSON.stringify(app.error_file)},`); + lines.push(` out_file: ${JSON.stringify(app.out_file)},`); + lines.push(' time: true,'); + lines.push(idx === cfg.apps.length - 1 ? ' }' : ' },'); +}); +lines.push(' ],'); +lines.push('};'); +lines.push(''); +fs.writeFileSync(path, lines.join('\n')); +process.stdout.write(String(port)); +NODE +)" + +echo "using port $PORT for $SLUG" + +if [[ ! -f "$NGINX_AVAILABLE" ]]; then + cat >"$NGINX_AVAILABLE" </dev/null 2>&1; then + certbot --nginx -d "$HOST" -d "www.$HOST" --non-interactive --agree-tos --register-unsafely-without-email --redirect \ + || echo "certbot skipped/failed (non-fatal)" +fi + +if [[ -f "$ENV_FILE" ]]; then + CURRENT="$(grep -E '^ALLOWED_SLUGS=' "$ENV_FILE" | head -1 | cut -d= -f2- || true)" + if [[ -z "$CURRENT" ]]; then + if grep -qE '^ALLOWED_SLUGS=' "$ENV_FILE"; then + sed -i -E "s|^ALLOWED_SLUGS=.*|ALLOWED_SLUGS=${SLUG}|" "$ENV_FILE" + else + echo "ALLOWED_SLUGS=$SLUG" >>"$ENV_FILE" + fi + elif [[ ",$CURRENT," != *",$SLUG,"* ]]; then + sed -i -E "s|^ALLOWED_SLUGS=.*|ALLOWED_SLUGS=${CURRENT},${SLUG}|" "$ENV_FILE" + fi +fi + +echo "provision ok: slug=$SLUG host=$HOST port=$PORT" diff --git a/scripts/websites-agent/server.js b/scripts/websites-agent/server.js new file mode 100644 index 0000000..aa87e3d --- /dev/null +++ b/scripts/websites-agent/server.js @@ -0,0 +1,184 @@ +const http = require('http'); +const { spawn } = require('child_process'); +const { timingSafeEqual } = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const PORT = Number(process.env.PORT || 9050); +const ENV_FILE = path.join(__dirname, '.env'); + +function loadEnvFile() { + try { + const raw = fs.readFileSync(ENV_FILE, 'utf8'); + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const i = trimmed.indexOf('='); + if (i <= 0) continue; + const key = trimmed.slice(0, i).trim(); + const val = trimmed.slice(i + 1).trim(); + process.env[key] = val; + } + } catch (_) { + /* optional */ + } +} + +loadEnvFile(); + +function token() { + return String(process.env.DEPLOY_TOKEN || '').trim(); +} + +function allowedSlugs() { + loadEnvFile(); + return new Set( + String(process.env.ALLOWED_SLUGS || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ); +} + +function safeEqual(a, b) { + const x = Buffer.from(a); + const y = Buffer.from(b); + if (x.length !== y.length) return false; + return timingSafeEqual(x, y); +} + +function readJson(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + try { + const raw = Buffer.concat(chunks).toString('utf8') || '{}'; + resolve(JSON.parse(raw)); + } catch (e) { + reject(e); + } + }); + req.on('error', reject); + }); +} + +function send(res, status, body) { + const data = JSON.stringify(body); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(data), + }); + res.end(data); +} + +function assertAuth(req, res) { + const provided = String(req.headers['x-deploy-token'] || '').trim(); + const expected = token(); + if (!expected || !provided || !safeEqual(provided, expected)) { + send(res, 401, { error: 'unauthorized' }); + return false; + } + return true; +} + +function runScript(script, args) { + return new Promise((resolve, reject) => { + const child = spawn(script, args, { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => { + stdout += d.toString(); + }); + child.stderr.on('data', (d) => { + stderr += d.toString(); + }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) resolve({ stdout, stderr }); + else reject(new Error(stderr || stdout || `exit ${code}`)); + }); + }); +} + +const server = http.createServer(async (req, res) => { + if (req.method === 'GET' && req.url === '/health') { + return send(res, 200, { ok: true, allowed: [...allowedSlugs()] }); + } + + if (req.method === 'POST' && req.url === '/deploy') { + if (!assertAuth(req, res)) return; + + let body; + try { + body = await readJson(req); + } catch { + return send(res, 400, { error: 'invalid json' }); + } + + const slug = String(body.slug || '').trim(); + if (!slug || !allowedSlugs().has(slug)) { + return send(res, 400, { error: 'unknown or disallowed slug' }); + } + + const child = spawn('/opt/websites-agent/deploy.sh', [slug], { + detached: true, + stdio: 'ignore', + }); + child.unref(); + + return send(res, 202, { status: 'accepted', slug }); + } + + if (req.method === 'POST' && req.url === '/provision') { + if (!assertAuth(req, res)) return; + + let body; + try { + body = await readJson(req); + } catch { + return send(res, 400, { error: 'invalid json' }); + } + + const slug = String(body.slug || '').trim(); + const host = String(body.host || '').trim().toLowerCase(); + const gitRepoUrl = String(body.gitRepoUrl || body.git_repo_url || '').trim(); + + if (!slug || !host || !gitRepoUrl) { + return send(res, 400, { error: 'slug, host, and gitRepoUrl are required' }); + } + + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) { + return send(res, 400, { error: 'invalid slug' }); + } + + try { + const result = await runScript('/opt/websites-agent/provision.sh', [ + slug, + host, + gitRepoUrl, + ]); + // Reload allowlist from .env (provision.sh appends slug) + loadEnvFile(); + return send(res, 200, { + status: 'provisioned', + slug, + host, + log: (result.stdout || '').slice(-2000), + }); + } catch (err) { + return send(res, 500, { + error: 'provision failed', + detail: err instanceof Error ? err.message.slice(0, 2000) : String(err), + }); + } + } + + return send(res, 404, { error: 'not found' }); +}); + +server.listen(PORT, '0.0.0.0', () => { + console.log('websites-deploy-agent listening on :' + PORT); +}); diff --git a/src/business-admin/business-admin.module.ts b/src/business-admin/business-admin.module.ts index 0a21651..3b93aa4 100644 --- a/src/business-admin/business-admin.module.ts +++ b/src/business-admin/business-admin.module.ts @@ -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, diff --git a/src/business-admin/business-admin.service.ts b/src/business-admin/business-admin.service.ts index 89fbb5b..fc594c2 100644 --- a/src/business-admin/business-admin.service.ts +++ b/src/business-admin/business-admin.service.ts @@ -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( diff --git a/src/business-admin/dto/add-domain.dto.ts b/src/business-admin/dto/add-domain.dto.ts index 9b46df7..6b4e1a9 100644 --- a/src/business-admin/dto/add-domain.dto.ts +++ b/src/business-admin/dto/add-domain.dto.ts @@ -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; } diff --git a/src/domain-admin/domain-admin.module.ts b/src/domain-admin/domain-admin.module.ts index 8f2a1d8..001c62b 100644 --- a/src/domain-admin/domain-admin.module.ts +++ b/src/domain-admin/domain-admin.module.ts @@ -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], }) diff --git a/src/domain-admin/domain-admin.service.ts b/src/domain-admin/domain-admin.service.ts index db47b7b..640b13e 100644 --- a/src/domain-admin/domain-admin.service.ts +++ b/src/domain-admin/domain-admin.service.ts @@ -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 = { - '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('WEBSITE_DEPLOY_AGENT_URL')?.trim(); - const token = this.config.get('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', ); } diff --git a/src/website-deploy/website-deploy-agent.service.ts b/src/website-deploy/website-deploy-agent.service.ts new file mode 100644 index 0000000..8230fbf --- /dev/null +++ b/src/website-deploy/website-deploy-agent.service.ts @@ -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('WEBSITE_DEPLOY_AGENT_URL')?.trim(); + const token = this.config.get('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 })); + } +} diff --git a/src/website-deploy/website-deploy.module.ts b/src/website-deploy/website-deploy.module.ts new file mode 100644 index 0000000..d6bdd68 --- /dev/null +++ b/src/website-deploy/website-deploy.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { WebsiteDeployAgentService } from './website-deploy-agent.service'; + +@Module({ + providers: [WebsiteDeployAgentService], + exports: [WebsiteDeployAgentService], +}) +export class WebsiteDeployModule {} diff --git a/src/website-deploy/website-deploy.util.ts b/src/website-deploy/website-deploy.util.ts new file mode 100644 index 0000000..f5d76d0 --- /dev/null +++ b/src/website-deploy/website-deploy.util.ts @@ -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`; +}