Merge pull request #159 from technomancer702/codex/container-health

Add container readiness checks and exclude local state from images
This commit is contained in:
Technomancer702
2026-10-12 16:41:44 -07:00
committed by GitHub
8 changed files with 161 additions and 1 deletions

19
.dockerignore Normal file
View File

@ -0,0 +1,19 @@
# Keep local credentials, state, and build output out of the image.
.git
.github
.codex
.agents
.aws
.env*
node_modules
data
transcode-cache
coverage
dist
release
test
tests
test-results
playwright-report
*.log
*.patch

39
.github/workflows/container.yml vendored Normal file
View File

@ -0,0 +1,39 @@
name: Container Smoke Test
on:
pull_request:
paths: [Dockerfile, .dockerignore, 'server/**', scripts/healthcheck.cjs, 'package*.json', .github/workflows/container.yml]
permissions:
contents: read
jobs:
container:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- name: Build and check readiness
shell: bash
run: |
set -euo pipefail
mkdir -p data
printf 'build context sentinel' > .env.container-sentinel
printf 'build context sentinel' > data/container-sentinel
docker build -t nodecast-smoke .
docker run -d --name nodecast-smoke nodecast-smoke
for attempt in $(seq 1 60); do
if docker exec nodecast-smoke node scripts/healthcheck.cjs; then
docker exec nodecast-smoke sh -c 'test ! -e /app/.env.container-sentinel && test ! -e /app/data/container-sentinel'
exit 0
fi
sleep 2
done
docker logs nodecast-smoke
exit 1
- name: Show container logs on failure
if: failure()
run: docker logs nodecast-smoke || true
- name: Remove smoke container
if: always()
run: docker rm -f nodecast-smoke || true

View File

@ -52,5 +52,8 @@ RUN mkdir -p /app/data /app/transcode-cache && chmod 777 /app/transcode-cache
# Expose port # Expose port
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD ["node", "scripts/healthcheck.cjs"]
# Start server # Start server
CMD ["node", "server/index.js"] CMD ["node", "server/index.js"]

31
scripts/healthcheck.cjs Normal file
View File

@ -0,0 +1,31 @@
const http = require('node:http');
function checkHealth(port = process.env.PORT || 3000, timeout = 4000) {
return new Promise(resolve => {
let settled = false;
const finish = healthy => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(healthy);
};
const request = http.get({ host: '127.0.0.1', port, path: '/api/health' }, response => {
let body = '';
response.setEncoding('utf8');
response.on('data', chunk => {
body += chunk;
if (body.length > 4096) { finish(false); request.destroy(); }
});
response.on('error', () => finish(false));
response.on('end', () => {
try { finish(response.statusCode === 200 && JSON.parse(body).status === 'ok'); }
catch { finish(false); }
});
});
request.on('error', () => finish(false));
const timer = setTimeout(() => { finish(false); request.destroy(); }, timeout);
});
}
if (require.main === module) checkHealth().then(healthy => { process.exitCode = healthy ? 0 : 1; });
module.exports = { checkHealth };

View File

@ -523,4 +523,4 @@ const users = {
} }
}; };
module.exports = { loadDb, saveDb, sources, hiddenItems, favorites, settings, users, getDefaultSettings, getUserAgent, USER_AGENT_PRESETS }; module.exports = { loadDb, saveDb, checkHealth: readDbForMutation, sources, hiddenItems, favorites, settings, users, getDefaultSettings, getUserAgent, USER_AGENT_PRESETS };

View File

@ -8,6 +8,7 @@ const syncService = require('./services/syncService');
require('./db'); require('./db');
const app = express(); const app = express();
app.locals.ready = false;
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
// Trust proxy headers (X-Forwarded-Proto, X-Forwarded-For, etc.) // Trust proxy headers (X-Forwarded-Proto, X-Forwarded-For, etc.)
@ -154,6 +155,7 @@ async function loadPlugins() {
// Graceful shutdown handler for plugins with shutdown hooks // Graceful shutdown handler for plugins with shutdown hooks
process.on('SIGTERM', async () => { process.on('SIGTERM', async () => {
app.locals.ready = false;
console.log('SIGTERM received, shutting down plugins...'); console.log('SIGTERM received, shutting down plugins...');
for (const { name, plugin } of loadedPlugins) { for (const { name, plugin } of loadedPlugins) {
if (plugin && typeof plugin.shutdown === 'function') { if (plugin && typeof plugin.shutdown === 'function') {
@ -169,6 +171,7 @@ process.on('SIGTERM', async () => {
}); });
// API Routes // API Routes
app.get('/api/health', require('./services/health'));
app.use('/api/auth', require('./routes/auth')); app.use('/api/auth', require('./routes/auth'));
app.use('/api/sources', require('./routes/sources')); app.use('/api/sources', require('./routes/sources'));
app.use('/api/proxy', require('./routes/proxy')); app.use('/api/proxy', require('./routes/proxy'));
@ -205,6 +208,7 @@ app.listen(PORT, async () => {
await loadPlugins().catch(err => { await loadPlugins().catch(err => {
console.error('Plugin initialization failed:', err); console.error('Plugin initialization failed:', err);
}); });
app.locals.ready = true;
// Trigger background sync with delay to allow server to settle // Trigger background sync with delay to allow server to settle
setTimeout(async () => { setTimeout(async () => {

18
server/services/health.js Normal file
View File

@ -0,0 +1,18 @@
const database = require('../db');
const { getDb } = require('../db/sqlite');
async function health(req, res) {
res.setHeader('Cache-Control', 'no-store');
if (!req.app.locals.ready) return res.status(503).json({ status: 'unavailable' });
try {
await database.checkHealth();
getDb().prepare('SELECT 1').get();
// Readiness may change while the asynchronous JSON check is running.
if (!req.app.locals.ready) return res.status(503).json({ status: 'unavailable' });
res.json({ status: 'ok' });
} catch {
res.status(503).json({ status: 'unavailable' });
}
}
module.exports = health;

46
test/health.test.js Normal file
View File

@ -0,0 +1,46 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const http = require('node:http');
const { checkHealth } = require('../scripts/healthcheck.cjs');
test('readiness fails during startup, shutdown and either database failure without exposing details', async () => {
let jsonError = false, sqlError = false, shutdown = false;
const module = { exports: {} }, filename = path.join(__dirname, '../server/services/health.js');
const app = { locals: { ready: false } };
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { module, require(id) {
if (id === '../db') return { async checkHealth() {
if (jsonError) throw new Error('Sensitive database path');
if (shutdown) app.locals.ready = false;
} };
return { getDb: () => ({ prepare: () => ({ get() { if (sqlError) throw new Error('Sensitive SQL'); } }) }) };
} });
for (const scenario of ['startup', 'healthy', 'json', 'sql', 'shutdown']) {
app.locals.ready = scenario !== 'startup';
jsonError = scenario === 'json'; sqlError = scenario === 'sql'; shutdown = scenario === 'shutdown';
const res = { code: 200, headers: {}, setHeader(key, value) { this.headers[key] = value; },
status(code) { this.code = code; return this; }, json(body) { this.body = body; } };
await module.exports({ app }, res);
assert.equal(res.code, scenario === 'healthy' ? 200 : 503, scenario);
assert.equal(JSON.stringify(res.body), JSON.stringify({ status: scenario === 'healthy' ? 'ok' : 'unavailable' }));
assert.equal(res.headers['Cache-Control'], 'no-store');
}
});
test('container probe rejects unhealthy status, SPA responses and stalled connections', async t => {
let mode = 'healthy';
const server = http.createServer((req, res) => {
assert.equal(req.url, '/api/health');
if (mode === 'stall') return;
if (mode === 'spa') return res.end('<html>Application</html>');
res.statusCode = mode === 'healthy' ? 200 : 503;
res.end(JSON.stringify({ status: 'ok' }));
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }));
for (mode of ['healthy', 'unhealthy', 'spa', 'stall']) {
assert.equal(await checkHealth(server.address().port, 100), mode === 'healthy', mode);
}
});