#!/usr/bin/env node // Deploy front/dist/prod to host /data/www/userbase/front using the Docker daemon // that act_runner already mounts into the job container (/var/run/docker.sock). // // Why this script exists: // 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 HTTP API to: // 1. tar the built assets straight out of THIS container's filesystem // (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), // then `mv` the extracted folder so files land at the correct top level. // // 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. 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 STAGING = HOST_STATIC + '/_staging'; // --- low level docker http helpers ------------------------------------------- function dockerJson(method, p, bodyObj) { return new Promise((resolve, reject) => { const data = bodyObj ? JSON.stringify(bodyObj) : null; const headers = {}; if (data) { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = Buffer.byteLength(data); } const req = http.request( { socketPath: SOCKET, path: p, method, headers }, (res) => { let buf = ''; res.on('data', (c) => (buf += c)); res.on('end', () => resolve({ status: res.statusCode, body: buf })); } ); req.on('error', reject); if (data) req.write(data); req.end(); }); } function getArchive(id, p) { return new Promise((resolve, reject) => { const req = http.request( { socketPath: SOCKET, path: `/containers/${id}/archive?path=${encodeURIComponent(p)}`, method: 'GET', }, (res) => resolve(res) ); req.on('error', reject); req.end(); }); } function putArchive(id, p, tarStream) { return new Promise((resolve, reject) => { const req = http.request( { socketPath: SOCKET, path: `/containers/${id}/archive?path=${encodeURIComponent(p)}`, method: 'PUT', headers: { 'Content-Type': 'application/x-tar' }, }, (res) => { let buf = ''; res.on('data', (c) => (buf += c)); res.on('end', () => resolve({ status: res.statusCode, body: buf })); } ); req.on('error', reject); tarStream.pipe(req); }); } // 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 ------------------------------------------ function getContainerId() { const cg = fs.readFileSync('/proc/self/cgroup', 'utf8'); const m = cg.match(/[0-9a-f]{64}/); if (!m) { const h = fs.readFileSync('/etc/hostname', 'utf8').trim(); return h; } return m[0]; } let HELPER_ID = null; let DEBUG_BUF = []; function dbg(line) { const ts = new Date().toISOString(); DEBUG_BUF.push(`[${ts}] ${line}`); console.log(line); // also emit to job log (best-effort) } // 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) { console.error('flushDebug: no helper, cannot write host debug'); return; } 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); } } // --- main -------------------------------------------------------------------- function resolveDistPath() { const candidates = [ path.join(process.cwd(), '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-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', `mkdir -p ${STAGING} && 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'); // 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(); 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. // 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)); await new Promise((r) => tar.on('end', r)); throw new Error('get archive failed (status ' + tar.statusCode + '): ' + msg); } dbg('fetched dist tar (content-type=' + tar.headers['content-type'] + ')'); // 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) 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, ' | ')); // 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() .then(async () => { // On success, keep diagnostics only briefly, then remove the debug file so // it is not left web-served at /userbase/_debug.txt. Failures keep it for // post-mortem (see .catch below). await flushDebug(); try { if (HELPER_ID) await runExec(HELPER_ID, `rm -f ${HOST_STATIC}/_debug.txt`); } catch (e) { console.error('cleanup debug file failed:', e.message); } if (HELPER_ID) await dockerJson('DELETE', `/containers/${HELPER_ID}?force=true`); process.exit(0); }) .catch(async (err) => { dbg('DEPLOY FAILED: ' + err.stack); await flushDebug(); // keep debug file on failure for post-mortem if (HELPER_ID) await dockerJson('DELETE', `/containers/${HELPER_ID}?force=true`); console.error('DEPLOY FAILED:', err.message); process.exit(1); });