Files
hop.noname.company/tests/docker/nas-config.mjs

202 lines
7.5 KiB
JavaScript

import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import {
mkdtempSync,
mkdirSync,
cpSync,
writeFileSync,
readFileSync,
statSync,
rmSync,
realpathSync,
} from 'node:fs';
import { resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
const root = resolve('.');
const folder = mkdtempSync(resolve(tmpdir(), 'hop-nas-config-'));
const realDocker = execFileSync('which', ['docker'], { encoding: 'utf8' }).trim();
let checkedContainer, checkedVolume;
try {
mkdirSync(resolve(folder, 'scripts'));
mkdirSync(resolve(folder, 'bin'));
for (const name of ['configure-nas.sh', 'prepare-nas-storage.sh'])
cpSync(resolve(root, 'scripts', name), resolve(folder, 'scripts', name));
const composeFile = resolve(folder, 'docker-compose.yaml');
cpSync(resolve(root, 'docker-compose.yaml'), composeFile);
const originalCompose = readFileSync(composeFile, 'utf8');
const envFile = resolve(folder, '.env');
writeFileSync(
envFile,
[
'HOP_BIND_IP=192.0.2.1',
'HOP_HOST_PORT=50189',
'HOP_CONTAINER_PORT=50189',
'APP_URL=https://hop.test',
'SESSION_SECRET=keep-test-session-value-1234567890',
'DISCOVERY_SECRET=keep-test-discovery-value-1234567890',
'PROXY_DIAGNOSTIC_TOKEN=keep-test-diagnostic-value-1234567890',
'USER_STORAGE_LIMIT_MB=2048',
'',
].join('\n'),
);
const log = resolve(folder, 'docker-operations.log');
writeFileSync(
resolve(folder, 'bin', 'docker'),
`#!/bin/sh
set -eu
case "$1" in
compose) exec "$HOP_TEST_REAL_DOCKER" "$@" ;;
inspect)
if [ "$HOP_TEST_RUNNING" = 1 ]; then
if [ "$2" = --format ]; then printf '%s\\n' true; else printf '%s\\n' '{}'; fi
exit 0
fi
exit 1 ;;
volume|run) printf '%s\\n' "$*" >> "$HOP_TEST_DOCKER_LOG" ;;
*) exit 1 ;;
esac
`,
{ mode: 0o755 },
);
const environment = {
...process.env,
PATH: `${folder}/bin:${process.env.PATH}`,
SUDO_UID: '12001',
SUDO_GID: '12002',
HOP_TEST_REAL_DOCKER: realDocker,
HOP_TEST_DOCKER_LOG: log,
HOP_TEST_RUNNING: '0',
};
execFileSync('sh', ['scripts/configure-nas.sh'], { cwd: folder, env: environment });
const updated = readFileSync(envFile, 'utf8');
assert(updated.includes('HOP_RUN_UID=12001\n'));
assert(updated.includes('HOP_RUN_GID=12002\n'));
assert(updated.includes('SESSION_SECRET=keep-test-session-value-1234567890'));
assert(updated.includes('DISCOVERY_SECRET=keep-test-discovery-value-1234567890'));
assert.equal(statSync(envFile).mode & 0o777, 0o600);
const yaml = readFileSync(composeFile, 'utf8');
assert.equal(yaml, originalCompose, 'NAS setup must not overwrite the tracked Compose file');
assert(!yaml.includes('keep-test-session-value'));
const config = JSON.parse(
execFileSync(
realDocker,
['compose', '-f', 'docker-compose.yaml', 'config', '--no-env-resolution', '--format', 'json'],
{ cwd: folder, encoding: 'utf8' },
),
);
assert.equal(config.services.hop.user, '12001:12002');
assert.equal(config.name, 'hop');
assert.equal(config.services.hop.container_name, 'hop');
assert.equal(config.networks.hop_network.name, 'hop_hop_network');
const files = config.services.hop.volumes.find((mount) => mount.target === '/data/uploads');
assert.equal(files.type, 'bind');
assert.equal(files.source, realpathSync(resolve(folder, 'uploads')));
assert.equal(files.bind.create_host_path, false);
const database = config.services.hop.volumes.find((mount) => mount.target === '/data/database');
assert.equal(database.volume.nocopy, true);
assert.equal(config.volumes.hop_database.name, 'hop_hop_database');
assert.equal(config.volumes.hop_uploads, undefined);
assert(!readFileSync(log, 'utf8').includes('volume rm'));
assert(readFileSync(log, 'utf8').includes('com.docker.compose.volume=hop_database'));
const before = readFileSync(log, 'utf8');
assert.throws(
() =>
execFileSync('sh', ['scripts/prepare-nas-storage.sh'], {
cwd: folder,
env: { ...environment, HOP_TEST_RUNNING: '1' },
stdio: 'pipe',
}),
/Command failed/,
);
assert.equal(readFileSync(log, 'utf8'), before, 'running deployment refuses permission changes');
assert(readFileSync(resolve(root, '.gitignore'), 'utf8').includes('/uploads/'));
assert(readFileSync(resolve(root, '.dockerignore'), 'utf8').split('\n').includes('uploads'));
if (process.env.HOP_CHECK_IMAGE) {
const suffix = randomUUID().slice(0, 8),
project = `hop-compose-check-${suffix}`;
checkedContainer = project;
checkedVolume = `${project}-db`;
const run = (...args) => execFileSync(realDocker, args, { encoding: 'utf8', stdio: 'pipe' });
run(
'volume',
'create',
'--label',
`com.docker.compose.project=${project}`,
'--label',
'com.docker.compose.volume=hop_database',
checkedVolume,
);
run(
'run',
'--rm',
'--network',
'none',
'--user',
'0:0',
'--mount',
`type=volume,source=${checkedVolume},target=/database`,
'--mount',
`type=bind,source=${realpathSync(resolve(folder, 'uploads'))},target=/uploads`,
process.env.HOP_CHECK_IMAGE,
'sh',
'-eu',
'-c',
'chown 12001:12002 /database /uploads; chmod 700 /database /uploads',
);
config.name = project;
config.volumes.hop_database.name = checkedVolume;
Object.assign(config.services.hop, {
image: process.env.HOP_CHECK_IMAGE,
// This fixture checks mounts with a prebuilt image, without source builds.
pull_policy: 'never',
container_name: checkedContainer,
ports: [],
network_mode: 'none',
});
delete config.services.hop.build;
delete config.services.hop.networks;
delete config.networks;
const checkYaml = resolve(folder, 'compose.check.json');
writeFileSync(checkYaml, JSON.stringify(config));
run('compose', '-f', checkYaml, 'up', '-d', '--no-build');
run(
'exec',
checkedContainer,
'node',
'-e',
"(async()=>{for(let i=0;i<60;i++){try{if((await fetch('http://127.0.0.1:50189/api/health')).ok)return}catch{}await new Promise(r=>setTimeout(r,250));}process.exit(1)})()",
);
const mounts = JSON.parse(run('inspect', '--format', '{{json .Mounts}}', checkedContainer));
const bind = mounts.find((mount) => mount.Destination === '/data/uploads');
assert.equal(bind.Type, 'bind');
const hostSource =
process.platform === 'darwin' ? bind.Source.replace(/^\/host_mnt/, '') : bind.Source;
assert.equal(realpathSync(hostSource), realpathSync(resolve(folder, 'uploads')));
assert.equal(
run('inspect', '--format', '{{.Config.User}}', checkedContainer).trim(),
'12001:12002',
);
assert.equal(
run('inspect', '--format', '{{json .HostConfig.PortBindings}}', checkedContainer).trim(),
'{}',
);
}
console.log(
'PASS: single tracked Compose unchanged by setup, existing project/container/network/database identities, NAS UID/GID, secret preservation, private env file, resolved uploads bind path, no DB copy-up, stopped-container guard, no implicit data deletion, uploads excluded from Git/build. Compose parsed with real Docker; storage mutations isolated through a test shim.',
);
} finally {
if (checkedContainer) {
try {
execFileSync(realDocker, ['rm', '-f', checkedContainer], { stdio: 'pipe' });
} catch {}
}
if (checkedVolume) {
try {
execFileSync(realDocker, ['volume', 'rm', checkedVolume], { stdio: 'pipe' });
} catch {}
}
rmSync(folder, { recursive: true, force: true });
}