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 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
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');
|
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
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