diff --git a/scripts/websites-agent/README.md b/scripts/websites-agent/README.md index 0a052dd..6b10c79 100644 --- a/scripts/websites-agent/README.md +++ b/scripts/websites-agent/README.md @@ -1,7 +1,10 @@ # Websites deploy / provision agent (runs on websites VM: /opt/websites-agent) # # Endpoints (X-Deploy-Token): -# POST /deploy { slug } — git pull + build + pm2 restart +# POST /deploy { slug, wait?: true } — git pull + build + pm2 restart +# wait=true (API default): sync, returns success/failed +# wait=false: fire-and-forget 202 accepted +# GET /deploy-status?slug=… — last deploy status JSON for slug # POST /provision { slug, host, gitRepoUrl } — clone + nginx + ecosystem + allowlist (no certbot) # POST /ssl { host, slug? } — certbot for apex + www (nginx must exist) # GET /health @@ -10,3 +13,4 @@ # # Note: provision.sh intentionally skips certbot. SSL is issued via POST /ssl so # Edit/Add Domain does not hang when www DNS is wrong. +# deploy.sh checks out origin/HEAD (falls back to main, then master). diff --git a/scripts/websites-agent/deploy.sh b/scripts/websites-agent/deploy.sh index 2b54939..b0dc91a 100755 --- a/scripts/websites-agent/deploy.sh +++ b/scripts/websites-agent/deploy.sh @@ -9,19 +9,78 @@ fi ROOT="/var/www/sites/$SLUG" LOG="/var/log/websites/deploy-$SLUG.log" +STATUS="/var/log/websites/deploy-$SLUG.status.json" exec >>"$LOG" 2>&1 +write_status() { + local status="$1" + local detail="${2:-}" + detail="${detail//\\/\\\\}" + detail="${detail//\"/\\\"}" + detail="${detail//$'\n'/ }" + detail="${detail:0:500}" + printf '{"slug":"%s","status":"%s","detail":"%s","at":"%s"}\n' \ + "$SLUG" \ + "$status" \ + "$detail" \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + >"$STATUS" +} + echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy start: $SLUG ====" +write_status "started" "Deploy running" + +cleanup_fail() { + local code=$? + write_status "failed" "deploy.sh exited with code $code" + exit "$code" +} +trap cleanup_fail ERR if [[ ! -d "$ROOT/.git" ]]; then echo "missing site: $ROOT" + write_status "failed" "missing site directory" exit 1 fi cd "$ROOT" +export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -i /root/.ssh/websites_deploy -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new}" + git fetch origin -git reset --hard origin/main + +# Prefer origin/HEAD, then main, then master (repos may use either). +BRANCH="" +if git rev-parse --verify --quiet origin/HEAD >/dev/null; then + BRANCH="$(git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || true)" + BRANCH="${BRANCH#origin/}" +fi +if [[ -z "$BRANCH" || "$BRANCH" == "HEAD" ]]; then + if git rev-parse --verify --quiet origin/main >/dev/null; then + BRANCH="main" + elif git rev-parse --verify --quiet origin/master >/dev/null; then + BRANCH="master" + else + echo "could not determine default branch (tried origin/HEAD, main, master)" + write_status "failed" "no origin/main or origin/master" + exit 1 + fi +fi + +echo "resetting to origin/$BRANCH" +git checkout -B "$BRANCH" "origin/$BRANCH" +git reset --hard "origin/$BRANCH" + +if [[ ! -f package.json ]]; then + echo "package.json missing after checkout" + write_status "failed" "package.json missing — empty or wrong branch?" + exit 1 +fi + npm ci npm run build pm2 restart "$SLUG" --update-env || pm2 start /var/www/sites/ecosystem.config.cjs --only "$SLUG" +pm2 save || true + echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy ok: $SLUG ====" +write_status "success" "Deployed origin/$BRANCH" +trap - ERR diff --git a/scripts/websites-agent/server.js b/scripts/websites-agent/server.js index f2ad8ca..f3cc48d 100644 --- a/scripts/websites-agent/server.js +++ b/scripts/websites-agent/server.js @@ -103,11 +103,34 @@ function runScript(script, args) { }); } +function readDeployStatus(slug) { + const file = `/var/log/websites/deploy-${slug}.status.json`; + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch { + return null; + } +} + 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 === 'GET' && req.url?.startsWith('/deploy-status')) { + if (!assertAuth(req, res)) return; + const url = new URL(req.url, 'http://127.0.0.1'); + const slug = String(url.searchParams.get('slug') || '').trim(); + if (!slug || !allowedSlugs().has(slug)) { + return send(res, 400, { error: 'unknown or disallowed slug' }); + } + const status = readDeployStatus(slug); + if (!status) { + return send(res, 404, { error: 'no deploy status yet', slug }); + } + return send(res, 200, status); + } + if (req.method === 'POST' && req.url === '/deploy') { if (!assertAuth(req, res)) return; @@ -119,17 +142,47 @@ const server = http.createServer(async (req, res) => { } const slug = String(body.slug || '').trim(); + const wait = body.wait === true || body.wait === 'true'; 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(); + if (!wait) { + const child = spawn('/opt/websites-agent/deploy.sh', [slug], { + detached: true, + stdio: 'ignore', + }); + child.unref(); + return send(res, 202, { status: 'accepted', slug }); + } - return send(res, 202, { status: 'accepted', slug }); + try { + const result = await runScript('/opt/websites-agent/deploy.sh', [slug]); + const status = readDeployStatus(slug) || { + slug, + status: 'success', + detail: 'Deploy finished', + at: new Date().toISOString(), + }; + return send(res, 200, { + status: 'success', + slug, + detail: status.detail || null, + at: status.at || null, + log: (result.stdout || '').slice(-2000), + }); + } catch (err) { + const status = readDeployStatus(slug); + const detail = + (status && status.detail) || + (err instanceof Error ? err.message.slice(0, 2000) : String(err)); + return send(res, 500, { + status: 'failed', + slug, + error: 'deploy failed', + detail, + }); + } } if (req.method === 'POST' && req.url === '/provision') { diff --git a/src/domain-admin/domain-admin.service.ts b/src/domain-admin/domain-admin.service.ts index b63c24c..a987925 100644 --- a/src/domain-admin/domain-admin.service.ts +++ b/src/domain-admin/domain-admin.service.ts @@ -417,7 +417,7 @@ export class DomainAdminService { throw new BadRequestException('This domain has no storefront deploy configured'); } - const markDeploy = async (status: 'started' | 'failed') => { + const markDeploy = async (status: 'started' | 'success' | 'failed') => { const updated = await this.prisma.domain.update({ where: { id: domainId }, data: { @@ -428,28 +428,37 @@ export class DomainAdminService { return updated; }; + await markDeploy('started'); + try { - await this.websiteDeployAgent.deploy(slug); + const result = await this.websiteDeployAgent.deploy(slug, { wait: true }); + const updated = await markDeploy( + result.status === 'success' || result.status === 'accepted' + ? 'success' + : 'failed', + ); + + return { + status: 'ok' as const, + slug, + host: domain.host, + message: + result.detail?.trim() || + (updated.lastDeployStatus === 'success' + ? 'Deploy succeeded on websites server' + : 'Deploy finished with unknown status'), + lastDeployedAt: updated.lastDeployedAt?.toISOString() ?? null, + lastDeployStatus: updated.lastDeployStatus, + }; } catch (err) { await markDeploy('failed'); if (err instanceof ServiceUnavailableException) { throw err; } throw new ServiceUnavailableException( - err instanceof Error ? err.message : 'Deploy failed to start', + err instanceof Error ? err.message : 'Deploy failed', ); } - - const updated = await markDeploy('started'); - - return { - status: 'accepted' as const, - slug, - host: domain.host, - message: 'Deploy started on websites server', - lastDeployedAt: updated.lastDeployedAt?.toISOString() ?? null, - lastDeployStatus: updated.lastDeployStatus, - }; } async update(domainIdRaw: string, dto: UpdateDomainAdminDto, actor: AuthUser) { diff --git a/src/website-deploy/website-deploy-agent.service.ts b/src/website-deploy/website-deploy-agent.service.ts index f9dea59..30dbaf3 100644 --- a/src/website-deploy/website-deploy-agent.service.ts +++ b/src/website-deploy/website-deploy-agent.service.ts @@ -47,8 +47,9 @@ export class WebsiteDeployAgentService { return response.json().catch(() => ({ status: 'accepted' })); } - async deploy(slug: string) { + async deploy(slug: string, options?: { wait?: boolean }) { const { deployUrl, token } = this.credentials(); + const wait = options?.wait !== false; let response: Response; try { @@ -58,20 +59,43 @@ export class WebsiteDeployAgentService { 'Content-Type': 'application/json', 'X-Deploy-Token': token, }, - body: JSON.stringify({ slug }), + body: JSON.stringify({ slug, wait }), + signal: AbortSignal.timeout(15 * 60 * 1000), }); - } catch { + } catch (err) { + if (err instanceof Error && err.name === 'TimeoutError') { + throw new ServiceUnavailableException( + 'Deploy timed out waiting for websites server (15m)', + ); + } throw new ServiceUnavailableException('Could not reach website deploy agent'); } + const text = await response.text().catch(() => ''); + let payload: { + status?: string; + slug?: string; + detail?: string; + error?: string; + } = {}; + try { + payload = text ? (JSON.parse(text) as typeof payload) : {}; + } catch { + /* keep raw */ + } + if (!response.ok) { - const text = await response.text().catch(() => ''); + const detail = payload.detail || payload.error || text; throw new ServiceUnavailableException( - `Deploy agent rejected request (${response.status})${text ? `: ${text}` : ''}`, + `Deploy failed (${response.status})${detail ? `: ${detail}` : ''}`, ); } - return response.json().catch(() => ({ status: 'accepted', slug })); + return { + status: (payload.status as string) || (wait ? 'success' : 'accepted'), + slug: payload.slug || slug, + detail: payload.detail ?? null, + }; } /** Issue / renew Let's Encrypt cert for a storefront host on the websites VM. */