mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
Track real website deploy success/failure and support master branch.
Deploy agent waits for build completion, writes status, and checks out origin/HEAD (main or master) so empty-main repos like mashinify can deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
acc8f35682
commit
f7cee26975
@@ -1,7 +1,10 @@
|
|||||||
# Websites deploy / provision agent (runs on websites VM: /opt/websites-agent)
|
# Websites deploy / provision agent (runs on websites VM: /opt/websites-agent)
|
||||||
#
|
#
|
||||||
# Endpoints (X-Deploy-Token):
|
# 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 /provision { slug, host, gitRepoUrl } — clone + nginx + ecosystem + allowlist (no certbot)
|
||||||
# POST /ssl { host, slug? } — certbot for apex + www (nginx must exist)
|
# POST /ssl { host, slug? } — certbot for apex + www (nginx must exist)
|
||||||
# GET /health
|
# GET /health
|
||||||
@@ -10,3 +13,4 @@
|
|||||||
#
|
#
|
||||||
# Note: provision.sh intentionally skips certbot. SSL is issued via POST /ssl so
|
# Note: provision.sh intentionally skips certbot. SSL is issued via POST /ssl so
|
||||||
# Edit/Add Domain does not hang when www DNS is wrong.
|
# Edit/Add Domain does not hang when www DNS is wrong.
|
||||||
|
# deploy.sh checks out origin/HEAD (falls back to main, then master).
|
||||||
|
|||||||
@@ -9,19 +9,78 @@ fi
|
|||||||
|
|
||||||
ROOT="/var/www/sites/$SLUG"
|
ROOT="/var/www/sites/$SLUG"
|
||||||
LOG="/var/log/websites/deploy-$SLUG.log"
|
LOG="/var/log/websites/deploy-$SLUG.log"
|
||||||
|
STATUS="/var/log/websites/deploy-$SLUG.status.json"
|
||||||
exec >>"$LOG" 2>&1
|
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 ===="
|
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
|
if [[ ! -d "$ROOT/.git" ]]; then
|
||||||
echo "missing site: $ROOT"
|
echo "missing site: $ROOT"
|
||||||
|
write_status "failed" "missing site directory"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd "$ROOT"
|
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 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 ci
|
||||||
npm run build
|
npm run build
|
||||||
pm2 restart "$SLUG" --update-env || pm2 start /var/www/sites/ecosystem.config.cjs --only "$SLUG"
|
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 ===="
|
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy ok: $SLUG ===="
|
||||||
|
write_status "success" "Deployed origin/$BRANCH"
|
||||||
|
trap - ERR
|
||||||
|
|||||||
@@ -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) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
if (req.method === 'GET' && req.url === '/health') {
|
if (req.method === 'GET' && req.url === '/health') {
|
||||||
return send(res, 200, { ok: true, allowed: [...allowedSlugs()] });
|
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 (req.method === 'POST' && req.url === '/deploy') {
|
||||||
if (!assertAuth(req, res)) return;
|
if (!assertAuth(req, res)) return;
|
||||||
|
|
||||||
@@ -119,17 +142,47 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const slug = String(body.slug || '').trim();
|
const slug = String(body.slug || '').trim();
|
||||||
|
const wait = body.wait === true || body.wait === 'true';
|
||||||
if (!slug || !allowedSlugs().has(slug)) {
|
if (!slug || !allowedSlugs().has(slug)) {
|
||||||
return send(res, 400, { error: 'unknown or disallowed slug' });
|
return send(res, 400, { error: 'unknown or disallowed slug' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = spawn('/opt/websites-agent/deploy.sh', [slug], {
|
if (!wait) {
|
||||||
detached: true,
|
const child = spawn('/opt/websites-agent/deploy.sh', [slug], {
|
||||||
stdio: 'ignore',
|
detached: true,
|
||||||
});
|
stdio: 'ignore',
|
||||||
child.unref();
|
});
|
||||||
|
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') {
|
if (req.method === 'POST' && req.url === '/provision') {
|
||||||
|
|||||||
@@ -417,7 +417,7 @@ export class DomainAdminService {
|
|||||||
throw new BadRequestException('This domain has no storefront deploy configured');
|
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({
|
const updated = await this.prisma.domain.update({
|
||||||
where: { id: domainId },
|
where: { id: domainId },
|
||||||
data: {
|
data: {
|
||||||
@@ -428,28 +428,37 @@ export class DomainAdminService {
|
|||||||
return updated;
|
return updated;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await markDeploy('started');
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
await markDeploy('failed');
|
await markDeploy('failed');
|
||||||
if (err instanceof ServiceUnavailableException) {
|
if (err instanceof ServiceUnavailableException) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
throw new ServiceUnavailableException(
|
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) {
|
async update(domainIdRaw: string, dto: UpdateDomainAdminDto, actor: AuthUser) {
|
||||||
|
|||||||
@@ -47,8 +47,9 @@ export class WebsiteDeployAgentService {
|
|||||||
return response.json().catch(() => ({ status: 'accepted' }));
|
return response.json().catch(() => ({ status: 'accepted' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async deploy(slug: string) {
|
async deploy(slug: string, options?: { wait?: boolean }) {
|
||||||
const { deployUrl, token } = this.credentials();
|
const { deployUrl, token } = this.credentials();
|
||||||
|
const wait = options?.wait !== false;
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
@@ -58,20 +59,43 @@ export class WebsiteDeployAgentService {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Deploy-Token': token,
|
'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');
|
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) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => '');
|
const detail = payload.detail || payload.error || text;
|
||||||
throw new ServiceUnavailableException(
|
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. */
|
/** Issue / renew Let's Encrypt cert for a storefront host on the websites VM. */
|
||||||
|
|||||||
Reference in New Issue
Block a user