From 68d9e3520a1e3c997138149cabe11fc51f7d4af4 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 4 Aug 2026 23:26:50 +0800 Subject: [PATCH] fix(deploy): deterministic mv landing + robust base64 host diagnostics --- scripts/deploy-via-docker.js | 143 +++++++++++++++++------------------ 1 file changed, 68 insertions(+), 75 deletions(-) diff --git a/scripts/deploy-via-docker.js b/scripts/deploy-via-docker.js index 41bec2b..ab3fd1b 100644 --- a/scripts/deploy-via-docker.js +++ b/scripts/deploy-via-docker.js @@ -8,14 +8,15 @@ // bind-mounted directly into the job container. But the host docker.sock IS // mounted by default, so we use the Docker HTTP API to: // 1. tar the built assets straight out of THIS container's filesystem -// (GET /containers/{jobId}/archive?path=.../), and +// (GET /containers/{jobId}/archive?path=...), and // 2. upload that tar into a throwaway helper container that has -// /data/www/userbase mounted RW (PUT /containers/{helperId}/archive). +// /data/www/userbase mounted RW (PUT /containers/{helperId}/archive), +// then `mv` the extracted folder so files land at the correct top level. // -// Diagnostics: every step is also written to /data/www/userbase/_debug.txt on -// the HOST via `docker exec` into the helper (the job container itself cannot -// write there). The Gitea logs API is unavailable here, so this file is our -// only reliable window into what happened. +// Diagnostics: every step is collected and written to /data/www/userbase/_debug.txt +// on the HOST via `docker exec` into the helper (the job container itself cannot +// write there). The Gitea logs API is unavailable here, so this file is our only +// reliable window into what happened. // // No docker CLI and no network egress required — we speak the Docker HTTP API // over the unix socket directly with Node's http module. @@ -27,6 +28,7 @@ const path = require('path'); const SOCKET = '/var/run/docker.sock'; const HOST_STATIC = '/data/www/userbase'; const HELPER_IMAGE = 'node:20'; // already cached on host (used by the job) +const STAGING = HOST_STATIC + '/_staging'; // --- low level docker http helpers ------------------------------------------- @@ -87,14 +89,31 @@ function putArchive(id, p, tarStream) { }); } +// Run a command inside the helper and return its stdout/stderr (Detach:false so +// the call resolves only after the command finishes — reliable for sequencing). +async function runExec(id, cmd) { + const created = await dockerJson('POST', `/containers/${id}/exec`, { + Cmd: ['sh', '-c', cmd], + AttachStdout: true, + AttachStderr: true, + }); + if (created.status >= 400) { + throw new Error('exec create failed: ' + created.status + ' ' + created.body); + } + const execId = JSON.parse(created.body).Id; + const started = await dockerJson('POST', `/exec/${execId}/start`, { + Detach: false, + Tty: false, + }); + return started; // { status, body(stdout+stderr) } +} + // --- helper container + diagnostics ------------------------------------------ -// The job container's ID, derived from its cgroup (robust regardless of hostname). function getContainerId() { const cg = fs.readFileSync('/proc/self/cgroup', 'utf8'); const m = cg.match(/[0-9a-f]{64}/); if (!m) { - // fall back to hostname prefix if cgroup has no full id const h = fs.readFileSync('/etc/hostname', 'utf8').trim(); return h; } @@ -104,38 +123,30 @@ function getContainerId() { let HELPER_ID = null; let DEBUG_BUF = []; -// Append a line to the in-memory debug buffer; flushes to host at key points. function dbg(line) { const ts = new Date().toISOString(); DEBUG_BUF.push(`[${ts}] ${line}`); + console.log(line); // also emit to job log (best-effort) } -// Flush the buffered debug lines into the HOST file via docker exec on helper. +// Write the accumulated debug buffer to the HOST file via a base64 pipe. Passing +// the payload inline (single-quoted) avoids env-var/newline fragility that broke +// the earlier diagnostics channel. Uses Detach:false so we know it completed. async function flushDebug() { - if (!HELPER_ID || DEBUG_BUF.length === 0) return; - const msg = DEBUG_BUF.join('\n') + '\n'; - DEBUG_BUF = []; - const created = await dockerJson('POST', `/containers/${HELPER_ID}/exec`, { - Cmd: ['sh', '-c', 'printf "%s" "$MSG" >> /data/www/userbase/_debug.txt'], - Env: ['MSG=' + msg], - AttachStdin: false, - AttachStdout: false, - AttachStderr: false, - }); - if (created.status >= 400) { - console.error('exec create for debug failed:', created.status, created.body); + if (!HELPER_ID) { + console.error('flushDebug: no helper, cannot write host debug'); return; } - const execId = JSON.parse(created.body).Id; - const started = await dockerJson('POST', `/exec/${execId}/start`, { - Detach: true, - Tty: false, - }); - if (started.status >= 400) { - console.error('exec start for debug failed:', started.status, started.body); + const content = DEBUG_BUF.join('\n') + '\n'; + const b64 = Buffer.from(content, 'utf8').toString('base64'); + const cmd = `echo ${b64} | base64 -d > ${HOST_STATIC}/_debug.txt`; + try { + const r = await runExec(HELPER_ID, cmd); + if (r.status >= 400) console.error('flushDebug exec failed:', r.status, r.body); + else dbg('debug written to host (' + content.length + ' bytes)'); + } catch (e) { + console.error('flushDebug error:', e.message); } - // give the exec a moment to flush before any subsequent cleanup - await new Promise((r) => setTimeout(r, 300)); } // --- main -------------------------------------------------------------------- @@ -144,7 +155,6 @@ function resolveDistPath() { const candidates = [ path.join(process.cwd(), 'front', 'dist', 'prod'), '/workspace/admin/userbase/front/dist/prod', - '/workspace/admin/userbase/front/dist/prod', ]; for (const c of candidates) { if (fs.existsSync(c)) return c; @@ -153,18 +163,14 @@ function resolveDistPath() { } async function main() { - dbg('START script version=2026-08-04b'); + dbg('START script version=2026-08-04c'); dbg('cwd=' + process.cwd()); // 1) helper container with the host static dir mounted RW; keep it alive. const createBody = { Image: HELPER_IMAGE, HostConfig: { NetworkMode: 'none', Binds: [`${HOST_STATIC}:${HOST_STATIC}`] }, - Cmd: [ - 'sh', - '-c', - `rm -rf ${HOST_STATIC}/front && mkdir -p ${HOST_STATIC}/front && exec tail -f /dev/null`, - ], + Cmd: ['sh', '-c', `mkdir -p ${STAGING} && exec tail -f /dev/null`], }; const created = await dockerJson('POST', '/containers/create', createBody); if (created.status >= 400) { @@ -178,21 +184,8 @@ async function main() { throw new Error('start helper failed: ' + started.status + ' ' + started.body); } dbg('helper started'); - - // Truncate the host debug file so each run starts clean (job container itself - // cannot write here; use the helper we just started). - const trunc = await dockerJson('POST', `/containers/${HELPER_ID}/exec`, { - Cmd: ['sh', '-c', `rm -f ${HOST_STATIC}/_debug.txt`], - AttachStdin: false, - AttachStdout: false, - AttachStderr: false, - }); - if (trunc.status < 400) { - const tId = JSON.parse(trunc.body).Id; - await dockerJson('POST', `/exec/${tId}/start`, { Detach: true, Tty: false }); - await new Promise((r) => setTimeout(r, 200)); - } - dbg('debug file truncated for this run'); + // clean any stale staging from a previous failed run + await runExec(HELPER_ID, `rm -rf ${STAGING} && mkdir -p ${STAGING}`); // 2) locate the built assets inside THIS container. const jobId = getContainerId(); @@ -214,9 +207,10 @@ async function main() { dbg('dist listing: ' + fs.readdirSync(distDir).join(' ')); // 3) tar of the built assets, fetched from THIS container's own filesystem. - // Trailing slash => tar contains the directory CONTENTS at the top level, - // so they land directly under /data/www/userbase/front (not under prod/). - const tar = await getArchive(jobId, distDir + '/'); + // We fetch WITHOUT a trailing slash so the tar contains a top-level "prod/" + // folder; we then mv it into place (deterministic, no reliance on the + // trailing-slash "contents only" quirk which this moby build ignores). + const tar = await getArchive(jobId, distDir); if (tar.statusCode !== 200) { let msg = ''; tar.on('data', (c) => (msg += c)); @@ -225,31 +219,30 @@ async function main() { } dbg('fetched dist tar (content-type=' + tar.headers['content-type'] + ')'); - // 4) upload the tar straight into the helper's mounted host dir. - const put = await putArchive(HELPER_ID, `${HOST_STATIC}/front`, tar); + // 4) upload the tar into the staging dir of the helper's mounted host dir. + const put = await putArchive(HELPER_ID, STAGING, tar); dbg('put archive status=' + put.status + (put.status >= 400 ? ' ' + put.body : '')); if (put.status >= 400) { throw new Error('put archive failed: ' + put.status + ' ' + put.body); } - // 5) verify on host via exec into helper. - const verifyExec = await dockerJson('POST', `/containers/${HELPER_ID}/exec`, { - Cmd: [ - 'sh', - '-c', - `echo "count=$(find ${HOST_STATIC}/front -type f | wc -l)"; ls -la ${HOST_STATIC}/front | head -20`, - ], - AttachStdout: true, - AttachStderr: true, - }); - const vExecId = JSON.parse(verifyExec.body).Id; - const vStart = await dockerJson('POST', `/exec/${vExecId}/start`, { - Detach: false, - Tty: false, - }); - dbg('verify output: ' + vStart.body.replace(/\n/g, ' | ')); + // 5) move staging/prod -> HOST_STATIC/front (deterministic top-level landing). + const mv = await runExec( + HELPER_ID, + `rm -rf ${HOST_STATIC}/front && mv ${STAGING}/prod ${HOST_STATIC}/front && rm -rf ${STAGING}` + ); + dbg('mv status=' + mv.status + ' out=' + mv.body.replace(/\n/g, ' | ')); - dbg('DEPLOY OK -> ' + HOST_STATIC + '/front'); + // 6) verify on host via exec into helper. + const verify = await runExec( + HELPER_ID, + `echo "count=$(find ${HOST_STATIC}/front -type f | wc -l)"; ` + + `echo "has_stray_prod=$(test -d ${HOST_STATIC}/front/prod && echo YES || echo NO)"; ` + + `ls -la ${HOST_STATIC}/front | head -20` + ); + dbg('verify output: ' + verify.body.replace(/\n/g, ' | ')); + + dbg('DEPLOY OK -> ' + HOST_STATIC + '/front (top-level, no prod/ nesting)'); } main()