diff --git a/apps/server/src/compiler.ts b/apps/server/src/compiler.ts index b21989a..0057e29 100644 --- a/apps/server/src/compiler.ts +++ b/apps/server/src/compiler.ts @@ -398,6 +398,7 @@ export class Compiler { config.dockerContainer, '--restart', 'unless-stopped', + '--init', '--cpus=2', '--memory=1g', '--pids-limit=128', @@ -452,6 +453,7 @@ function workerMatches(containerJson: string, imageJson: string, jobsDir: string container.Image === image.Id && container.Config?.Image === config.dockerImage && container.Config?.User === 'tectonic' && + container.HostConfig?.Init === true && container.HostConfig?.ReadonlyRootfs === true && container.HostConfig?.Memory === 1024 * 1024 * 1024 && container.HostConfig?.PidsLimit === 128 && diff --git a/apps/server/tests/compiler-docker.test.ts b/apps/server/tests/compiler-docker.test.ts index 5731517..6eaad78 100644 --- a/apps/server/tests/compiler-docker.test.ts +++ b/apps/server/tests/compiler-docker.test.ts @@ -1,13 +1,13 @@ import { seedUser } from './helpers.js'; import { execFileSync } from 'node:child_process'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect, it, vi } from 'vitest'; import { buildApp } from './helpers.js'; import { config } from '../src/config.js'; -it.skipIf(process.env.OVERROOT_TEST_DOCKER !== '1')('compiles a nested private source snapshot as the isolated Docker UID and reuses SyncTeX', async () => { +it.skipIf(process.env.OVERROOT_TEST_DOCKER !== '1')('replaces a legacy worker, reaps orphaned processes, compiles private sources and reuses SyncTeX', async () => { const dir = await mkdtemp(join(tmpdir(), 'overroot-docker-')); const previousContainer = config.dockerContainer; const previousFake = config.fakeCompiler; @@ -15,6 +15,15 @@ it.skipIf(process.env.OVERROOT_TEST_DOCKER !== '1')('compiles a nested private s config.dockerContainer = `overroot-tectonic-test-${process.pid}`; const { app, context } = await buildApp({ databasePath: join(dir, 'test.sqlite3'), logger: false }); try { + // Match every existing worker requirement except init, reproducing pre-fix containers. + execFileSync('docker', ['build', '-t', config.dockerImage, config.dockerfileDir], { timeout: 10 * 60_000 }); + const legacyId = execFileSync('docker', [ + 'run', '-d', '--name', config.dockerContainer, '--restart', 'unless-stopped', + '--cpus=2', '--memory=1g', '--pids-limit=128', '--cap-drop=ALL', + '--security-opt=no-new-privileges', '--read-only', '--tmpfs', '/tmp:rw,nosuid,nodev,size=256m', + '-v', 'overroot-tectonic-cache:/var/cache/tectonic', + '-v', `${await realpath(join(dir, 'jobs'))}:/work:rw`, config.dockerImage + ], { encoding: 'utf8', timeout: 30_000 }).trim(); const user = seedUser(context.db, 'compiler-test', true); const modules = Array.from({ length: 20 }, (_, i) => ({ path: `sections/deep/chapter-${i % 4}/part-${i}.tex`, @@ -44,6 +53,20 @@ it.skipIf(process.env.OVERROOT_TEST_DOCKER !== '1')('compiles a nested private s expect(result.status, result.error ?? result.log).toBe('succeeded'); expect((await readFile(result.pdf_path!)).subarray(0, 5).toString()).toBe('%PDF-'); expect(result.synctex_path).not.toBeNull(); + const worker = JSON.parse(execFileSync('docker', ['inspect', config.dockerContainer], { encoding: 'utf8', timeout: 10_000 }))[0]; + expect(worker.Id).not.toBe(legacyId); + expect(worker.HostConfig.Init).toBe(true); + // The shell exits first; its child is adopted by PID 1 and must be reaped after exit. + const orphanPid = execFileSync('docker', [ + 'exec', config.dockerContainer, 'sh', '-c', 'sleep 1 /dev/null 2>&1 & echo $!' + ], { encoding: 'utf8', timeout: 10_000 }).trim(); + expect(orphanPid).toMatch(/^\d+$/); + await vi.waitFor(() => { + expect(execFileSync('docker', [ + 'exec', config.dockerContainer, 'sh', '-c', + 'if [ -e /proc/"$1" ]; then echo present; else echo reaped; fi', 'sh', orphanPid + ], { encoding: 'utf8', timeout: 5_000 }).trim()).toBe('reaped'); + }, { timeout: 10_000, interval: 250 }); context.projects.renameFile(body.id, body.path); const cached = await context.compiler.request(project, actor); expect(cached.status).toBe('succeeded'); diff --git a/deploy/uptime/overroot.py b/deploy/uptime/overroot.py new file mode 100644 index 0000000..f54b150 --- /dev/null +++ b/deploy/uptime/overroot.py @@ -0,0 +1,22 @@ +"""Read-only Overroot responses and compiler init configuration; install in ~/uptime/.""" +from _probe import main + +SPEC = { + 'name': 'Overroot LaTeX studio', + 'group': 'Apps', + 'scope': 'Origin/public frontend and status API, dedicated Cloudflare connector readiness, and compiler container running with init. Does not log in, read documents, edit, or compile LaTeX.', + 'probes': [ + {'kind': 'http', 'name': 'Origin health', 'url': 'http://127.0.0.1:4317/health', 'equals': {'ok': True}}, + {'kind': 'http', 'name': 'Connector ready', 'url': 'http://127.0.0.1:20417/ready', 'equals': {'status': 200}, 'keys': ['readyConnections']}, + {'kind': 'http', 'name': 'Public API', 'url': 'https://overroot.toni.foo/api/v1/status', 'equals': {'ok': True, 'name': 'Overroot', 'publicUrl': 'https://overroot.toni.foo', 'identityOrigin': 'https://accounts.toni.foo'}}, + {'kind': 'http', 'name': 'Public frontend', 'url': 'https://overroot.toni.foo/', 'contains': 'Overroot'}, + {'kind': 'command', 'name': 'Compiler worker init', + 'argv': ['docker', 'inspect', '--format', '{"running":{{json .State.Running}},"init":{{json .HostConfig.Init}}}', 'overroot-tectonic-worker'], + 'equals': {'running': True, 'init': True}}, + ], + 'boot': {'user': ['overroot.service', 'overroot-cloudflared.service'], 'system': [], 'containers': ['overroot-tectonic-worker']}, + 'link': 'https://overroot.toni.foo/', +} + +if __name__ == '__main__': + main(SPEC)