Add container readiness checks and exclude local state from images
Adapted from MikaelKW/nodecast-tv-plus readiness and container context work. Co-authored-by: MikaelKW <87399352+MikaelKW@users.noreply.github.com>
This commit is contained in:
19
.dockerignore
Normal file
19
.dockerignore
Normal 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
39
.github/workflows/container.yml
vendored
Normal 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
|
||||
@ -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"]
|
||||
|
||||
31
scripts/healthcheck.cjs
Normal file
31
scripts/healthcheck.cjs
Normal 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 };
|
||||
@ -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 };
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
18
server/services/health.js
Normal file
18
server/services/health.js
Normal 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
46
test/health.test.js
Normal 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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user