diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2c840f8 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml new file mode 100644 index 0000000..28f4b5b --- /dev/null +++ b/.github/workflows/container.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 73316e7..10f8e8d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,5 +52,8 @@ RUN mkdir -p /app/data /app/transcode-cache && chmod 777 /app/transcode-cache # Expose port EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD ["node", "scripts/healthcheck.cjs"] + # Start server CMD ["node", "server/index.js"] diff --git a/scripts/healthcheck.cjs b/scripts/healthcheck.cjs new file mode 100644 index 0000000..2cd8bfa --- /dev/null +++ b/scripts/healthcheck.cjs @@ -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 }; diff --git a/server/db.js b/server/db.js index 87fe159..b5a0512 100644 --- a/server/db.js +++ b/server/db.js @@ -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 }; diff --git a/server/index.js b/server/index.js index 0774e48..0ff9807 100644 --- a/server/index.js +++ b/server/index.js @@ -8,6 +8,7 @@ const syncService = require('./services/syncService'); require('./db'); const app = express(); +app.locals.ready = false; const PORT = process.env.PORT || 3000; // 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 process.on('SIGTERM', async () => { + app.locals.ready = false; console.log('SIGTERM received, shutting down plugins...'); for (const { name, plugin } of loadedPlugins) { if (plugin && typeof plugin.shutdown === 'function') { @@ -169,6 +171,7 @@ process.on('SIGTERM', async () => { }); // API Routes +app.get('/api/health', require('./services/health')); app.use('/api/auth', require('./routes/auth')); app.use('/api/sources', require('./routes/sources')); app.use('/api/proxy', require('./routes/proxy')); @@ -205,6 +208,7 @@ app.listen(PORT, async () => { await loadPlugins().catch(err => { console.error('Plugin initialization failed:', err); }); + app.locals.ready = true; // Trigger background sync with delay to allow server to settle setTimeout(async () => { diff --git a/server/services/health.js b/server/services/health.js new file mode 100644 index 0000000..70f7812 --- /dev/null +++ b/server/services/health.js @@ -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; diff --git a/test/health.test.js b/test/health.test.js new file mode 100644 index 0000000..62ffe18 --- /dev/null +++ b/test/health.test.js @@ -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('Application'); + 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); + } +});