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
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user