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
@@ -78,6 +78,8 @@ DASHBOARD_ADMIN_HOST=manage.meshkee.com
|
|||||||
CENTRAL_API_HOST=api.meshkee.com
|
CENTRAL_API_HOST=api.meshkee.com
|
||||||
|
|
||||||
# Website storefront deploy agent (POST from Super Admin → websites VM)
|
# 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_AGENT_URL=http://89.44.241.119:9050/deploy
|
||||||
WEBSITE_DEPLOY_TOKEN=
|
WEBSITE_DEPLOY_TOKEN=
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -151,6 +151,8 @@ model Domain {
|
|||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
lastDeployedAt DateTime? @map("last_deployed_at") @db.Timestamptz(6)
|
lastDeployedAt DateTime? @map("last_deployed_at") @db.Timestamptz(6)
|
||||||
lastDeployStatus String? @map("last_deploy_status") @db.VarChar(32)
|
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)
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
@@index([businessId], map: "idx_domains_business_id")
|
@@index([businessId], map: "idx_domains_business_id")
|
||||||
|
|||||||
@@ -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
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SLUG="${1:-}"
|
||||||
|
if [[ -z "$SLUG" ]]; then
|
||||||
|
echo "usage: deploy.sh <slug>" >&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 ===="
|
||||||
Executable
+163
@@ -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 <slug> <host> <gitRepoUrl>" >&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" <<NGINX
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name ${HOST} www.${HOST};
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:${PORT};
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
proxy_cache_bypass \$http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
ln -sfn "$NGINX_AVAILABLE" "$NGINX_ENABLED"
|
||||||
|
nginx -t
|
||||||
|
systemctl reload nginx
|
||||||
|
echo "nginx site created for $HOST → :$PORT"
|
||||||
|
else
|
||||||
|
echo "nginx site already exists: $NGINX_AVAILABLE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v certbot >/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"
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { WebsiteDeployModule } from '../website-deploy/website-deploy.module';
|
||||||
import { BusinessCategoriesController } from './business-categories.controller';
|
import { BusinessCategoriesController } from './business-categories.controller';
|
||||||
import { BusinessCategoriesService } from './business-categories.service';
|
import { BusinessCategoriesService } from './business-categories.service';
|
||||||
import { BusinessAdminController } from './business-admin.controller';
|
import { BusinessAdminController } from './business-admin.controller';
|
||||||
@@ -8,7 +9,7 @@ import { LegacyMigrateService } from './legacy-migrate.service';
|
|||||||
import { LegacyPurgeService } from './legacy-purge.service';
|
import { LegacyPurgeService } from './legacy-purge.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule, WebsiteDeployModule],
|
||||||
controllers: [BusinessAdminController, BusinessCategoriesController],
|
controllers: [BusinessAdminController, BusinessCategoriesController],
|
||||||
providers: [
|
providers: [
|
||||||
BusinessAdminService,
|
BusinessAdminService,
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ import {
|
|||||||
DEFAULT_ENABLED_BUSINESS_MODULES,
|
DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||||
DEFAULT_HOME_CHARTS,
|
DEFAULT_HOME_CHARTS,
|
||||||
} from '../business-settings/business-settings.types';
|
} 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 = {
|
type BusinessRow = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
@@ -75,6 +80,7 @@ export class BusinessAdminService {
|
|||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
private readonly legacyMigrate: LegacyMigrateService,
|
private readonly legacyMigrate: LegacyMigrateService,
|
||||||
private readonly legacyPurge: LegacyPurgeService,
|
private readonly legacyPurge: LegacyPurgeService,
|
||||||
|
private readonly websiteDeployAgent: WebsiteDeployAgentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async assertSuperAdmin(actor: AuthUser) {
|
private async assertSuperAdmin(actor: AuthUser) {
|
||||||
@@ -451,17 +457,29 @@ export class BusinessAdminService {
|
|||||||
await this.assertSuperAdmin(actor);
|
await this.assertSuperAdmin(actor);
|
||||||
|
|
||||||
const businessId = BigInt(businessIdRaw);
|
const businessId = BigInt(businessIdRaw);
|
||||||
const host = dto.host.trim();
|
const host = dto.host.trim().toLowerCase();
|
||||||
|
const gitRepoUrl = dto.gitRepoUrl?.trim() || null;
|
||||||
|
|
||||||
if (!host) {
|
if (!host) {
|
||||||
throw new BadRequestException('host is required');
|
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 } });
|
const business = await this.prisma.business.findUnique({ where: { id: businessId } });
|
||||||
if (!business) {
|
if (!business) {
|
||||||
throw new NotFoundException('Business not found');
|
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({
|
const hasPrimary = await this.prisma.domain.findFirst({
|
||||||
where: { businessId, isPrimary: true },
|
where: { businessId, isPrimary: true },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -469,15 +487,55 @@ export class BusinessAdminService {
|
|||||||
|
|
||||||
const isPrimary = dto.isPrimary ?? !hasPrimary;
|
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: {
|
data: {
|
||||||
businessId,
|
businessId,
|
||||||
host,
|
host,
|
||||||
isPrimary,
|
isPrimary,
|
||||||
isVerified: false,
|
isVerified: false,
|
||||||
sslEnabled: false,
|
sslEnabled: false,
|
||||||
|
deploySlug: provisionError ? null : deploySlug,
|
||||||
|
gitRepoUrl: provisionError ? null : gitRepoUrl,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...domain,
|
||||||
|
provisionError,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateDomain(
|
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 {
|
export class AddDomainDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -8,5 +8,14 @@ export class AddDomainDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isPrimary?: boolean;
|
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 { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { WebsiteDeployModule } from '../website-deploy/website-deploy.module';
|
||||||
import { DomainAdminController } from './domain-admin.controller';
|
import { DomainAdminController } from './domain-admin.controller';
|
||||||
import { DomainAdminService } from './domain-admin.service';
|
import { DomainAdminService } from './domain-admin.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, ConfigModule],
|
imports: [AuthModule, ConfigModule, WebsiteDeployModule],
|
||||||
controllers: [DomainAdminController],
|
controllers: [DomainAdminController],
|
||||||
providers: [DomainAdminService],
|
providers: [DomainAdminService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,17 +11,12 @@ import { Prisma } from '@prisma/client';
|
|||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service';
|
||||||
import { DisableDomainDto } from './dto/disable-domain.dto';
|
import { DisableDomainDto } from './dto/disable-domain.dto';
|
||||||
import { ListDomainsDto } from './dto/list-domains.dto';
|
import { ListDomainsDto } from './dto/list-domains.dto';
|
||||||
import { ToggleSslDto } from './dto/toggle-ssl.dto';
|
import { ToggleSslDto } from './dto/toggle-ssl.dto';
|
||||||
import { UpdateDomainAdminDto } from './dto/update-domain-admin.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 = {
|
type DomainRow = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -33,6 +28,7 @@ type DomainRow = {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
lastDeployedAt: Date | null;
|
lastDeployedAt: Date | null;
|
||||||
lastDeployStatus: string | null;
|
lastDeployStatus: string | null;
|
||||||
|
deploySlug: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -41,6 +37,7 @@ export class DomainAdminService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
|
private readonly websiteDeployAgent: WebsiteDeployAgentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async assertSuperAdmin(actor: AuthUser) {
|
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) {
|
async list(query: ListDomainsDto, actor: AuthUser) {
|
||||||
await this.assertSuperAdmin(actor);
|
await this.assertSuperAdmin(actor);
|
||||||
|
|
||||||
@@ -78,7 +71,8 @@ export class DomainAdminService {
|
|||||||
d.expires_at AS "expiresAt",
|
d.expires_at AS "expiresAt",
|
||||||
d.created_at AS "createdAt",
|
d.created_at AS "createdAt",
|
||||||
d.last_deployed_at AS "lastDeployedAt",
|
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
|
FROM domains d
|
||||||
JOIN businesses b ON b.id = d.business_id
|
JOIN businesses b ON b.id = d.business_id
|
||||||
${where}
|
${where}
|
||||||
@@ -92,12 +86,7 @@ export class DomainAdminService {
|
|||||||
`),
|
`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const items = rows.map((row) => ({
|
return { items: rows, total: totalRow[0]?.total ?? 0, page, pageSize };
|
||||||
...row,
|
|
||||||
deploySlug: this.deploySlugForHost(row.host),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { items, total: totalRow[0]?.total ?? 0, page, pageSize };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncSsl(actor: AuthUser) {
|
async syncSsl(actor: AuthUser) {
|
||||||
@@ -149,17 +138,11 @@ export class DomainAdminService {
|
|||||||
throw new NotFoundException('Domain not found');
|
throw new NotFoundException('Domain not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const slug = this.deploySlugForHost(domain.host);
|
const slug = domain.deploySlug?.trim() || null;
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
throw new BadRequestException('This domain has no storefront deploy configured');
|
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 markDeploy = async (status: 'started' | 'failed') => {
|
||||||
const updated = await this.prisma.domain.update({
|
const updated = await this.prisma.domain.update({
|
||||||
where: { id: domainId },
|
where: { id: domainId },
|
||||||
@@ -171,26 +154,15 @@ export class DomainAdminService {
|
|||||||
return updated;
|
return updated;
|
||||||
};
|
};
|
||||||
|
|
||||||
let response: Response;
|
|
||||||
try {
|
try {
|
||||||
response = await fetch(agentUrl, {
|
await this.websiteDeployAgent.deploy(slug);
|
||||||
method: 'POST',
|
} catch (err) {
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Deploy-Token': token,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ slug }),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
await markDeploy('failed');
|
await markDeploy('failed');
|
||||||
throw new ServiceUnavailableException('Could not reach website deploy agent');
|
if (err instanceof ServiceUnavailableException) {
|
||||||
}
|
throw err;
|
||||||
|
}
|
||||||
if (!response.ok) {
|
|
||||||
await markDeploy('failed');
|
|
||||||
const text = await response.text().catch(() => '');
|
|
||||||
throw new ServiceUnavailableException(
|
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