CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
148 lines
7.8 KiB
JavaScript
148 lines
7.8 KiB
JavaScript
// 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');
|
|
});
|