diff --git a/.gitea/workflows/deploy-front.yml b/.gitea/workflows/deploy-front.yml index 3ce9cb1..abb1d2f 100644 --- a/.gitea/workflows/deploy-front.yml +++ b/.gitea/workflows/deploy-front.yml @@ -52,4 +52,4 @@ jobs: run: node scripts/deploy-via-docker.js - name: Done - run: 'echo "deploy step finished; verify on host: /data/www/userbase/front"' + run: 'echo "deploy step finished; inspect host /data/www/userbase/_debug.txt for the full report"' diff --git a/scripts/deploy-via-docker.js b/scripts/deploy-via-docker.js index baf8093..cd478a5 100644 --- a/scripts/deploy-via-docker.js +++ b/scripts/deploy-via-docker.js @@ -6,32 +6,31 @@ // act_runner (v0.6.1) ignores both `container.options` extra --volume and // workflow `container.volumes` in this setup, so the host static dir can't be // bind-mounted directly into the job container. But the host docker.sock IS -// mounted by default, so we use the Docker API to: +// 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), 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). // +// 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. +// // No docker CLI and no network egress required — we speak the Docker HTTP API // over the unix socket directly with Node's http module. const http = require('http'); const fs = require('fs'); +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 WS_DIST = '/workspace/admin/userbase/front/dist/prod'; -// 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) throw new Error('cannot determine container id from cgroup'); - return m[0]; -} +// --- low level docker http helpers ------------------------------------------- -function dockerJson(method, path, bodyObj) { +function dockerJson(method, p, bodyObj) { return new Promise((resolve, reject) => { const data = bodyObj ? JSON.stringify(bodyObj) : null; const headers = {}; @@ -40,7 +39,7 @@ function dockerJson(method, path, bodyObj) { headers['Content-Length'] = Buffer.byteLength(data); } const req = http.request( - { socketPath: SOCKET, path, method, headers }, + { socketPath: SOCKET, path: p, method, headers }, (res) => { let buf = ''; res.on('data', (c) => (buf += c)); @@ -53,11 +52,14 @@ function dockerJson(method, path, bodyObj) { }); } -// Returns the raw tar response stream for a container path. -function getArchive(id, path) { +function getArchive(id, p) { return new Promise((resolve, reject) => { const req = http.request( - { socketPath: SOCKET, path: `/containers/${id}/archive?path=${encodeURIComponent(path)}`, method: 'GET' }, + { + socketPath: SOCKET, + path: `/containers/${id}/archive?path=${encodeURIComponent(p)}`, + method: 'GET', + }, (res) => resolve(res) ); req.on('error', reject); @@ -65,13 +67,12 @@ function getArchive(id, path) { }); } -// Pipes a tar stream into a container path via PUT /archive. -function putArchive(id, path, tarStream) { +function putArchive(id, p, tarStream) { return new Promise((resolve, reject) => { const req = http.request( { socketPath: SOCKET, - path: `/containers/${id}/archive?path=${encodeURIComponent(path)}`, + path: `/containers/${id}/archive?path=${encodeURIComponent(p)}`, method: 'PUT', headers: { 'Content-Type': 'application/x-tar' }, }, @@ -86,55 +87,166 @@ function putArchive(id, path, tarStream) { }); } -async function main() { - const jobId = getContainerId(); - console.log('job container id:', jobId); +// --- helper container + diagnostics ------------------------------------------ - // 1) tar of the built assets, fetched from THIS container's own filesystem. - const tar = await getArchive(jobId, WS_DIST); +// 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; + } + return m[0]; +} + +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}`); +} + +// Flush the buffered debug lines into the HOST file via docker exec on helper. +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); + 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); + } + // give the exec a moment to flush before any subsequent cleanup + await new Promise((r) => setTimeout(r, 300)); +} + +// --- main -------------------------------------------------------------------- + +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; + } + return null; +} + +async function main() { + dbg('START script version=2026-08-04b'); + 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`, + ], + }; + const created = await dockerJson('POST', '/containers/create', createBody); + if (created.status >= 400) { + throw new Error('create helper failed: ' + created.status + ' ' + created.body); + } + HELPER_ID = JSON.parse(created.body).Id; + dbg('helper created id=' + HELPER_ID); + + const started = await dockerJson('POST', `/containers/${HELPER_ID}/start`); + if (started.status >= 400) { + throw new Error('start helper failed: ' + started.status + ' ' + started.body); + } + dbg('helper started'); + + // 2) locate the built assets inside THIS container. + const jobId = getContainerId(); + dbg('job container id=' + jobId); + + const distDir = resolveDistPath(); + if (!distDir) { + dbg('dist dir NOT found; cwd listing:'); + try { + dbg(fs.readdirSync(process.cwd()).join(' ')); + const front = path.join(process.cwd(), 'front'); + if (fs.existsSync(front)) dbg('front listing: ' + fs.readdirSync(front).join(' ')); + } catch (e) { + dbg('ls err: ' + e.message); + } + throw new Error('built dist/prod not found under any candidate path'); + } + dbg('dist dir=' + distDir); + 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 + '/'); if (tar.statusCode !== 200) { let msg = ''; tar.on('data', (c) => (msg += c)); await new Promise((r) => tar.on('end', r)); throw new Error('get archive failed (status ' + tar.statusCode + '): ' + msg); } - console.log('fetched dist tar (content-type:', tar.headers['content-type'], ')'); + dbg('fetched dist tar (content-type=' + tar.headers['content-type'] + ')'); - // 2) helper container with the host static dir mounted RW; keep it alive for the PUT. - 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`], - }; - const created = await dockerJson('POST', '/containers/create', createBody); - if (created.status >= 400) { - throw new Error('create failed: ' + created.status + ' ' + created.body); - } - const helperId = JSON.parse(created.body).Id; - console.log('helper container:', helperId); - - const started = await dockerJson('POST', `/containers/${helperId}/start`); - if (started.status >= 400) { - throw new Error('start failed: ' + started.status + ' ' + started.body); - } - - // 3) upload the tar straight into the helper's mounted host dir. - const put = await putArchive(helperId, `${HOST_STATIC}/front`, tar); - console.log('put archive status:', put.status); + // 4) upload the tar straight into the helper's mounted host dir. + const put = await putArchive(HELPER_ID, `${HOST_STATIC}/front`, 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); } - // 4) cleanup helper. - await dockerJson('DELETE', `/containers/${helperId}?force=true`); + // 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, ' | ')); - console.log('deploy OK ->', HOST_STATIC + '/front'); + dbg('DEPLOY OK -> ' + HOST_STATIC + '/front'); } -main().then( - () => process.exit(0), - (err) => { +main() + .then(async () => { + await flushDebug(); + if (HELPER_ID) await dockerJson('DELETE', `/containers/${HELPER_ID}?force=true`); + process.exit(0); + }) + .catch(async (err) => { + dbg('DEPLOY FAILED: ' + err.stack); + await flushDebug(); + if (HELPER_ID) await dockerJson('DELETE', `/containers/${HELPER_ID}?force=true`); console.error('DEPLOY FAILED:', err.message); process.exit(1); - } -); + });