#!/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). // // 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) // --- 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); }); } // --- 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; } 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); } 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); 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, ' | ')); dbg('DEPLOY OK -> ' + HOST_STATIC + '/front'); } 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); });