// The Docker packaging of the opt-in Skland account binding: docker-compose.yml, the Dockerfile's runtime stage and // .gitignore form one contract — the credentials live in one SQLite file on a named volume that the unprivileged // `node` user can write, and that file is never committed and never reachable over HTTP. // // No Docker daemon is needed (and CI has none): the files are read as text and the facts that make `docker compose up // -d` work are asserted. docker-compose.yml is parsed with a few lines of indentation-based reading rather than a YAML // dependency, so the assertions rest on the structure (which service, which key) instead of one loose regex. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); const read = (rel) => readFileSync(join(ROOT, rel), 'utf8'); const linesOf = (text) => text.split(/\r?\n/).map((l) => l.replace(/\r$/, '')); /** The indentation of a line (a YAML block is defined by it). */ const indentOf = (l) => l.length - l.replace(/^[ \t]*/, '').length; /** * The lines of the block under `key:` at exactly `indent` — comments and blank lines dropped (they are prose, not * structure), a dedent ends it. Returns [] when the key is absent. Enough YAML to read this one file honestly. * @param {string[]} lines * @param {string} key * @param {number} indent */ function block(lines, key, indent) { const head = new RegExp(`^ {${indent}}${key}:`); const out = []; let inside = false; for (const l of lines) { if (!l.trim() || l.trim().startsWith('#')) continue; if (!inside) { if (indentOf(l) === indent && head.test(l)) inside = true; continue; } if (indentOf(l) <= indent) break; out.push(l); } return out; } /** The scalar of `key: value` inside a block, with surrounding quotes removed; null when unset. */ function scalar(blockLines, key) { for (const l of blockLines) { const m = l.match(new RegExp(`^[ \\t]*${key}:[ \\t]*(.*)$`)); if (m) return m[1].trim().replace(/^(["'])(.*)\1$/, '$2'); } return null; } /** The `- item` entries of the list under `key:` inside a block (quotes removed). */ function items(blockLines, key, indent) { return block(blockLines, key, indent) .map((l) => l.match(/^[ \t]*-[ \t]*(.*)$/)?.[1] ?? '') .filter(Boolean) .map((v) => v.trim().replace(/^(["'])(.*)\1$/, '$2')); } const compose = linesOf(read('docker-compose.yml')); const services = block(compose, 'services', 0); const service = block(services, 'stronghold', 2); test('docker-compose.yml: one stronghold service, built locally, tagged and restarted', () => { assert.deepEqual(services.filter((l) => indentOf(l) === 2 && /:$/.test(l.trim())).map((l) => l.trim()), ['stronghold:'], 'exactly one service'); const build = block(service, 'build', 4); assert.equal(scalar(build, 'context'), '.', 'builds the repository Dockerfile'); assert.equal(scalar(service, 'image'), 'stronghold-protocol', 'tags the image'); assert.equal(scalar(service, 'restart'), 'unless-stopped'); // the art is opt-in: the compose file must not force a 550 MB download unless the operator uncomments it assert.equal(scalar(build, 'args'), null, 'FETCH_ASSETS stays the caller\'s choice'); }); test('docker-compose.yml: publishes 3000:3000', () => { const ports = items(service, 'ports', 4); assert.ok(ports.includes('3000:3000'), `publishes 3000:3000 (got ${JSON.stringify(ports)})`); }); test('docker-compose.yml: SP_SKLAND=1 and SP_SKLAND_DB inside the mounted named volume', () => { const env = block(service, 'environment', 4); assert.equal(scalar(env, 'SP_SKLAND'), '1', 'the Skland module is on'); const db = scalar(env, 'SP_SKLAND_DB'); assert.ok(db, 'SP_SKLAND_DB is set'); // the database must sit inside a mount, and that mount must be the named volume (survives down / rebuilds) const mounts = items(service, 'volumes', 4); const named = mounts.find((m) => m.startsWith('skland-data:')); assert.ok(named, `the named volume skland-data is mounted (got ${JSON.stringify(mounts)})`); const target = named.slice(named.indexOf(':') + 1); assert.ok(target.startsWith('/'), `the volume has an absolute container path (${target})`); assert.ok(db.startsWith(`${target}/`), `SP_SKLAND_DB ${db} is inside the mounted volume ${target}`); // the volume is declared, so `docker compose up` creates it rather than failing on an unknown name assert.ok(block(compose, 'volumes', 0).some((l) => indentOf(l) === 2 && l.trim().startsWith('skland-data')), 'the top-level volumes: section declares skland-data'); }); test('docker-compose.yml: the credential file is outside every static mount (/public, /data)', () => { const db = scalar(block(service, 'environment', 4), 'SP_SKLAND_DB'); for (const dir of ['/app/public', '/app/data', '/app/shared']) { assert.ok(!db.startsWith(`${dir}/`), `${db} is not under ${dir}: the static handler would serve it over HTTP`); } }); test('Dockerfile: /app/skland-data exists and belongs to node before USER node', () => { const df = linesOf(read('Dockerfile')); const user = df.findIndex((l) => /^USER[ \t]+node[ \t]*$/.test(l)); assert.ok(user > 0, 'the runtime stage still runs as USER node'); const mkdir = df.findIndex((l) => /^RUN[^\n]*mkdir -p[^\n]*\/app\/skland-data\b/.test(l)); assert.ok(mkdir >= 0, 'the database directory is created'); assert.ok(mkdir < user, `mkdir (line ${mkdir + 1}) precedes USER node (line ${user + 1})`); assert.match(df[mkdir], /chown[ \t]+node:node[ \t]+\/app\/skland-data\b/, 'owned by node, so node can write it'); // a VOLUME instruction would hand `docker run` an anonymous volume the host cannot find; the mount is explicit assert.ok(!df.some((l) => /^VOLUME\b/.test(l) && l.includes('/app/skland-data')), 'no anonymous VOLUME for the credential directory'); assert.match(read('Dockerfile'), /SP_SKLAND_DB/, 'the top comment documents the data path'); }); test('the compose volume target is the directory the image prepares', () => { const named = items(service, 'volumes', 4).find((m) => m.startsWith('skland-data:')); const target = named.slice(named.indexOf(':') + 1); const df = linesOf(read('Dockerfile')); const mkdir = df.find((l) => l.includes('mkdir -p') && l.includes(target)); assert.ok(mkdir, `the image creates ${target} before USER node, the directory compose mounts`); assert.match(mkdir, new RegExp(`mkdir -p[ \\t]+${target.replace(/\//g, '\\/')}\\b`)); }); test('.gitignore: the runtime database and its SQLite side files are never committed', () => { const lines = linesOf(read('.gitignore')).map((l) => l.trim()).filter((l) => l && !l.startsWith('#')); for (const entry of ['data/skland.db', 'data/skland.db-wal', 'data/skland.db-shm', 'skland-data/']) { assert.ok(lines.includes(entry), `.gitignore lists ${entry}`); } }); test('docs/DEPLOY.md: the Docker section documents SP_SKLAND, SP_SKLAND_DB, the volume and the secret warning', () => { const doc = read('docs/DEPLOY.md'); const start = doc.indexOf('## 3. Docker'); const end = doc.indexOf('## 4. ', start); assert.ok(start >= 0 && end > start, 'the Docker section is still there'); const docker = doc.slice(start, end); assert.match(docker, /`SP_SKLAND`/, 'names SP_SKLAND'); assert.match(docker, /`SP_SKLAND_DB`/, 'names SP_SKLAND_DB'); assert.match(docker, /docker compose up -d/, 'the one-command path'); assert.match(docker, /skland-data/, 'names the volume'); assert.match(docker, /密钥|凭据/, 'warns that the database holds credentials'); assert.match(docker, /chmod|权限/, 'tells the operator to restrict the file mode'); assert.match(docker, /备份/, 'tells the operator to back it up carefully'); assert.match(docker, /HTTP/, 'warns against exposing it over HTTP'); });