Archived
forked from admin/userbase
fix(deploy): deterministic mv landing + robust base64 host diagnostics
This commit is contained in:
@@ -8,14 +8,15 @@
|
|||||||
// bind-mounted directly into the job container. But the host docker.sock IS
|
// bind-mounted directly into the job container. But the host docker.sock IS
|
||||||
// mounted by default, so we use the Docker HTTP 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
|
// 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
|
// 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
|
// Diagnostics: every step is collected and written to /data/www/userbase/_debug.txt
|
||||||
// the HOST via `docker exec` into the helper (the job container itself cannot
|
// 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
|
// write there). The Gitea logs API is unavailable here, so this file is our only
|
||||||
// only reliable window into what happened.
|
// reliable window into what happened.
|
||||||
//
|
//
|
||||||
// No docker CLI and no network egress required — we speak the Docker HTTP API
|
// No docker CLI and no network egress required — we speak the Docker HTTP API
|
||||||
// over the unix socket directly with Node's http module.
|
// 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 SOCKET = '/var/run/docker.sock';
|
||||||
const HOST_STATIC = '/data/www/userbase';
|
const HOST_STATIC = '/data/www/userbase';
|
||||||
const HELPER_IMAGE = 'node:20'; // already cached on host (used by the job)
|
const HELPER_IMAGE = 'node:20'; // already cached on host (used by the job)
|
||||||
|
const STAGING = HOST_STATIC + '/_staging';
|
||||||
|
|
||||||
// --- low level docker http helpers -------------------------------------------
|
// --- 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 ------------------------------------------
|
// --- helper container + diagnostics ------------------------------------------
|
||||||
|
|
||||||
// The job container's ID, derived from its cgroup (robust regardless of hostname).
|
|
||||||
function getContainerId() {
|
function getContainerId() {
|
||||||
const cg = fs.readFileSync('/proc/self/cgroup', 'utf8');
|
const cg = fs.readFileSync('/proc/self/cgroup', 'utf8');
|
||||||
const m = cg.match(/[0-9a-f]{64}/);
|
const m = cg.match(/[0-9a-f]{64}/);
|
||||||
if (!m) {
|
if (!m) {
|
||||||
// fall back to hostname prefix if cgroup has no full id
|
|
||||||
const h = fs.readFileSync('/etc/hostname', 'utf8').trim();
|
const h = fs.readFileSync('/etc/hostname', 'utf8').trim();
|
||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
@@ -104,38 +123,30 @@ function getContainerId() {
|
|||||||
let HELPER_ID = null;
|
let HELPER_ID = null;
|
||||||
let DEBUG_BUF = [];
|
let DEBUG_BUF = [];
|
||||||
|
|
||||||
// Append a line to the in-memory debug buffer; flushes to host at key points.
|
|
||||||
function dbg(line) {
|
function dbg(line) {
|
||||||
const ts = new Date().toISOString();
|
const ts = new Date().toISOString();
|
||||||
DEBUG_BUF.push(`[${ts}] ${line}`);
|
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() {
|
async function flushDebug() {
|
||||||
if (!HELPER_ID || DEBUG_BUF.length === 0) return;
|
if (!HELPER_ID) {
|
||||||
const msg = DEBUG_BUF.join('\n') + '\n';
|
console.error('flushDebug: no helper, cannot write host debug');
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
const execId = JSON.parse(created.body).Id;
|
const content = DEBUG_BUF.join('\n') + '\n';
|
||||||
const started = await dockerJson('POST', `/exec/${execId}/start`, {
|
const b64 = Buffer.from(content, 'utf8').toString('base64');
|
||||||
Detach: true,
|
const cmd = `echo ${b64} | base64 -d > ${HOST_STATIC}/_debug.txt`;
|
||||||
Tty: false,
|
try {
|
||||||
});
|
const r = await runExec(HELPER_ID, cmd);
|
||||||
if (started.status >= 400) {
|
if (r.status >= 400) console.error('flushDebug exec failed:', r.status, r.body);
|
||||||
console.error('exec start for debug failed:', started.status, started.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 --------------------------------------------------------------------
|
// --- main --------------------------------------------------------------------
|
||||||
@@ -144,7 +155,6 @@ function resolveDistPath() {
|
|||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(process.cwd(), 'front', 'dist', 'prod'),
|
path.join(process.cwd(), 'front', 'dist', 'prod'),
|
||||||
'/workspace/admin/userbase/front/dist/prod',
|
'/workspace/admin/userbase/front/dist/prod',
|
||||||
'/workspace/admin/userbase/front/dist/prod',
|
|
||||||
];
|
];
|
||||||
for (const c of candidates) {
|
for (const c of candidates) {
|
||||||
if (fs.existsSync(c)) return c;
|
if (fs.existsSync(c)) return c;
|
||||||
@@ -153,18 +163,14 @@ function resolveDistPath() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
dbg('START script version=2026-08-04b');
|
dbg('START script version=2026-08-04c');
|
||||||
dbg('cwd=' + process.cwd());
|
dbg('cwd=' + process.cwd());
|
||||||
|
|
||||||
// 1) helper container with the host static dir mounted RW; keep it alive.
|
// 1) helper container with the host static dir mounted RW; keep it alive.
|
||||||
const createBody = {
|
const createBody = {
|
||||||
Image: HELPER_IMAGE,
|
Image: HELPER_IMAGE,
|
||||||
HostConfig: { NetworkMode: 'none', Binds: [`${HOST_STATIC}:${HOST_STATIC}`] },
|
HostConfig: { NetworkMode: 'none', Binds: [`${HOST_STATIC}:${HOST_STATIC}`] },
|
||||||
Cmd: [
|
Cmd: ['sh', '-c', `mkdir -p ${STAGING} && exec tail -f /dev/null`],
|
||||||
'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);
|
const created = await dockerJson('POST', '/containers/create', createBody);
|
||||||
if (created.status >= 400) {
|
if (created.status >= 400) {
|
||||||
@@ -178,21 +184,8 @@ async function main() {
|
|||||||
throw new Error('start helper failed: ' + started.status + ' ' + started.body);
|
throw new Error('start helper failed: ' + started.status + ' ' + started.body);
|
||||||
}
|
}
|
||||||
dbg('helper started');
|
dbg('helper started');
|
||||||
|
// clean any stale staging from a previous failed run
|
||||||
// Truncate the host debug file so each run starts clean (job container itself
|
await runExec(HELPER_ID, `rm -rf ${STAGING} && mkdir -p ${STAGING}`);
|
||||||
// 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');
|
|
||||||
|
|
||||||
// 2) locate the built assets inside THIS container.
|
// 2) locate the built assets inside THIS container.
|
||||||
const jobId = getContainerId();
|
const jobId = getContainerId();
|
||||||
@@ -214,9 +207,10 @@ async function main() {
|
|||||||
dbg('dist listing: ' + fs.readdirSync(distDir).join(' '));
|
dbg('dist listing: ' + fs.readdirSync(distDir).join(' '));
|
||||||
|
|
||||||
// 3) tar of the built assets, fetched from THIS container's own filesystem.
|
// 3) tar of the built assets, fetched from THIS container's own filesystem.
|
||||||
// Trailing slash => tar contains the directory CONTENTS at the top level,
|
// We fetch WITHOUT a trailing slash so the tar contains a top-level "prod/"
|
||||||
// so they land directly under /data/www/userbase/front (not under prod/).
|
// folder; we then mv it into place (deterministic, no reliance on the
|
||||||
const tar = await getArchive(jobId, distDir + '/');
|
// trailing-slash "contents only" quirk which this moby build ignores).
|
||||||
|
const tar = await getArchive(jobId, distDir);
|
||||||
if (tar.statusCode !== 200) {
|
if (tar.statusCode !== 200) {
|
||||||
let msg = '';
|
let msg = '';
|
||||||
tar.on('data', (c) => (msg += c));
|
tar.on('data', (c) => (msg += c));
|
||||||
@@ -225,31 +219,30 @@ async function main() {
|
|||||||
}
|
}
|
||||||
dbg('fetched dist tar (content-type=' + tar.headers['content-type'] + ')');
|
dbg('fetched dist tar (content-type=' + tar.headers['content-type'] + ')');
|
||||||
|
|
||||||
// 4) upload the tar straight into the helper's mounted host dir.
|
// 4) upload the tar into the staging dir of the helper's mounted host dir.
|
||||||
const put = await putArchive(HELPER_ID, `${HOST_STATIC}/front`, tar);
|
const put = await putArchive(HELPER_ID, STAGING, tar);
|
||||||
dbg('put archive status=' + put.status + (put.status >= 400 ? ' ' + put.body : ''));
|
dbg('put archive status=' + put.status + (put.status >= 400 ? ' ' + put.body : ''));
|
||||||
if (put.status >= 400) {
|
if (put.status >= 400) {
|
||||||
throw new Error('put archive failed: ' + put.status + ' ' + put.body);
|
throw new Error('put archive failed: ' + put.status + ' ' + put.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5) verify on host via exec into helper.
|
// 5) move staging/prod -> HOST_STATIC/front (deterministic top-level landing).
|
||||||
const verifyExec = await dockerJson('POST', `/containers/${HELPER_ID}/exec`, {
|
const mv = await runExec(
|
||||||
Cmd: [
|
HELPER_ID,
|
||||||
'sh',
|
`rm -rf ${HOST_STATIC}/front && mv ${STAGING}/prod ${HOST_STATIC}/front && rm -rf ${STAGING}`
|
||||||
'-c',
|
);
|
||||||
`echo "count=$(find ${HOST_STATIC}/front -type f | wc -l)"; ls -la ${HOST_STATIC}/front | head -20`,
|
dbg('mv status=' + mv.status + ' out=' + mv.body.replace(/\n/g, ' | '));
|
||||||
],
|
|
||||||
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');
|
// 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()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user